1104 lines
46 KiB
Python
Executable File
1104 lines
46 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
|
|
# Self-voiced Terminal Menu System for Stormux game image
|
|
|
|
import os
|
|
import platform
|
|
import sys
|
|
import time
|
|
import threading
|
|
import signal
|
|
import curses
|
|
import subprocess
|
|
import speechd # Python bindings for Speech Dispatcher
|
|
import configparser
|
|
import pathlib
|
|
import re
|
|
import simpleaudio as sa
|
|
|
|
class VoicedMenu:
|
|
def __init__(self, title="Stormux Game Menu"):
|
|
self.title = title
|
|
self.menuSections = {} # Dictionary to hold sections and their items
|
|
self.sectionNames = [] # List to maintain section order
|
|
self.currentSection = 0 # Index of current section
|
|
self.currentItemIndices = {} # Current item index for each section
|
|
self.stdscr = None
|
|
|
|
# System services dictionary - maps friendly names to service names
|
|
self.systemMenuServices = {
|
|
'Braille': 'brltty.path',
|
|
'D L N A Server': 'minidlna.service',
|
|
'Fenrir Screen Reader': 'fenrirscreenreader-tty.service',
|
|
'Bluetooth': 'bluetooth.service',
|
|
'SSH': 'sshd.service',
|
|
'Battery Monitoring': 'battery-monitor.service'
|
|
}
|
|
|
|
# Config settings
|
|
self.configDir = os.path.expanduser("~/.config/stormux")
|
|
self.configFile = os.path.join(self.configDir, "game_launcher.conf")
|
|
self.config = configparser.ConfigParser()
|
|
|
|
# Default settings
|
|
self.speechRate = 0 # Normal speech rate (0 is default in speechd)
|
|
self.volume = 50 # Default volume level
|
|
|
|
# Load settings
|
|
self.load_settings()
|
|
|
|
# Initialize speech client
|
|
self.speechClient = None
|
|
self.init_speech()
|
|
|
|
# Track playing sound
|
|
self.currentSound = None
|
|
|
|
def init_speech(self):
|
|
"""Initialize the speech client"""
|
|
try:
|
|
self.speechClient = speechd.SSIPClient('stormux_menu')
|
|
self.speechClient.set_priority(speechd.Priority.IMPORTANT)
|
|
self.speechClient.set_punctuation(speechd.PunctuationMode.SOME)
|
|
|
|
# Apply speech rate from settings
|
|
self.speechClient.set_rate(self.speechRate)
|
|
except Exception as e:
|
|
print(f"Could not initialize speech: {e}")
|
|
# Fallback to None - the speak method will handle this
|
|
|
|
def load_settings(self):
|
|
"""Load settings from config file"""
|
|
# Create default settings if they don't exist
|
|
if not os.path.exists(self.configFile):
|
|
self.save_settings()
|
|
return
|
|
|
|
try:
|
|
self.config.read(self.configFile)
|
|
|
|
# Load speech settings
|
|
if 'Speech' in self.config:
|
|
self.speechRate = self.config.getint('Speech', 'rate', fallback=0)
|
|
|
|
# Load volume settings
|
|
if 'Volume' in self.config:
|
|
self.volume = self.config.getint('Volume', 'level', fallback=50)
|
|
except Exception as e:
|
|
print(f"Error loading settings: {e}")
|
|
# If loading fails, we'll use default values
|
|
|
|
def save_settings(self):
|
|
"""Save settings to config file"""
|
|
# Ensure config directory exists
|
|
os.makedirs(self.configDir, exist_ok=True)
|
|
|
|
# Update config object
|
|
if 'Speech' not in self.config:
|
|
self.config['Speech'] = {}
|
|
|
|
self.config['Speech']['rate'] = str(self.speechRate)
|
|
|
|
# Save volume settings
|
|
if 'Volume' not in self.config:
|
|
self.config['Volume'] = {}
|
|
|
|
self.config['Volume']['level'] = str(self.volume)
|
|
|
|
# Write to file
|
|
try:
|
|
with open(self.configFile, 'w') as f:
|
|
self.config.write(f)
|
|
except Exception as e:
|
|
print(f"Error saving settings: {e}")
|
|
|
|
def increase_speech_rate(self):
|
|
"""Increase speech rate"""
|
|
self.speechRate = min(100, self.speechRate + 10) # Max is 100
|
|
if self.speechClient:
|
|
try:
|
|
self.speechClient.set_rate(self.speechRate)
|
|
self.speak(f"Speech rate: {self.speechRate}")
|
|
except Exception as e:
|
|
print(f"Error adjusting speech rate: {e}")
|
|
|
|
# Save the new setting
|
|
self.save_settings()
|
|
|
|
def decrease_speech_rate(self):
|
|
"""Decrease speech rate"""
|
|
self.speechRate = max(-100, self.speechRate - 10) # Min is -100
|
|
if self.speechClient:
|
|
try:
|
|
self.speechClient.set_rate(self.speechRate)
|
|
self.speak(f"Speech rate: {self.speechRate}")
|
|
except Exception as e:
|
|
print(f"Error adjusting speech rate: {e}")
|
|
|
|
# Save the new setting
|
|
self.save_settings()
|
|
|
|
def get_current_volume(self):
|
|
"""Get the current system volume percentage"""
|
|
try:
|
|
result = subprocess.run(
|
|
['pactl', 'get-sink-volume', '@DEFAULT_SINK@'],
|
|
capture_output=True, text=True, check=True
|
|
)
|
|
output = result.stdout
|
|
# Extract percentage from output like "Volume: front-left: 27111 / 41% / -23.00 dB"
|
|
match = re.search(r'(\d+)%', output)
|
|
if match:
|
|
return int(match.group(1))
|
|
return self.volume # Return saved volume if parsing fails
|
|
except Exception as e:
|
|
print(f"Error getting volume: {e}")
|
|
return self.volume # Return saved volume if command fails
|
|
|
|
def set_volume(self, volumePercent):
|
|
"""Set the system volume to the specified percentage"""
|
|
# Ensure volume is between 0 and 150%
|
|
volumePercent = max(0, min(150, volumePercent))
|
|
|
|
try:
|
|
subprocess.run(
|
|
['pactl', 'set-sink-volume', '@DEFAULT_SINK@', f'{volumePercent}%'],
|
|
check=True
|
|
)
|
|
self.volume = volumePercent
|
|
self.save_settings()
|
|
return True
|
|
except Exception as e:
|
|
print(f"Error setting volume: {e}")
|
|
return False
|
|
|
|
def increase_volume(self):
|
|
"""Increase the system volume by 5%"""
|
|
currentVolume = self.get_current_volume()
|
|
newVolume = min(150, currentVolume + 5) # Max 150%
|
|
if self.set_volume(newVolume):
|
|
self.speak(f"Volume {newVolume} percent")
|
|
self.draw_menu()
|
|
|
|
def decrease_volume(self):
|
|
"""Decrease the system volume by 5%"""
|
|
currentVolume = self.get_current_volume()
|
|
newVolume = max(0, currentVolume - 5) # Min 0%
|
|
if self.set_volume(newVolume):
|
|
self.speak(f"Volume {newVolume} percent")
|
|
self.draw_menu()
|
|
|
|
def play_sound(self, soundName):
|
|
"""Play a sound effect using simpleaudio with ability to cancel previous sounds"""
|
|
try:
|
|
# Cancel any currently playing sound
|
|
self.stop_sound()
|
|
|
|
soundsDir = "/usr/share/sounds/stormux"
|
|
soundFile = os.path.join(soundsDir, f"{soundName}.wav")
|
|
|
|
# Check if the file exists
|
|
if not os.path.exists(soundFile):
|
|
print(f"Sound file not found: {soundFile}")
|
|
return False
|
|
|
|
# Play the sound and store the play_obj
|
|
waveObj = sa.WaveObject.from_wave_file(soundFile)
|
|
self.currentSound = waveObj.play()
|
|
return True
|
|
except Exception as e:
|
|
print(f"Error playing sound {soundName}: {e}")
|
|
return False
|
|
|
|
def stop_sound(self):
|
|
"""Stop any currently playing sound"""
|
|
if self.currentSound is not None and self.currentSound.is_playing():
|
|
self.currentSound.stop()
|
|
self.currentSound = None
|
|
|
|
def check_service_status(self, serviceName):
|
|
"""Check if a system service is active"""
|
|
try:
|
|
result = subprocess.run(
|
|
['systemctl', 'is-active', serviceName],
|
|
capture_output=True, text=True
|
|
)
|
|
return result.stdout.strip() == 'active'
|
|
except Exception as e:
|
|
print(f"Error checking {serviceName} status: {e}")
|
|
return False
|
|
|
|
def toggle_service(self, friendlyName):
|
|
"""Toggle a system service on/off"""
|
|
# Get the actual service name from the dictionary
|
|
serviceName = self.systemMenuServices.get(friendlyName)
|
|
if not serviceName:
|
|
print(f"Unknown service: {friendlyName}")
|
|
return
|
|
|
|
isActive = self.check_service_status(serviceName)
|
|
|
|
# Clean up curses before running the command with sudo
|
|
curses.endwin()
|
|
|
|
# Clean up speech client
|
|
if self.speechClient:
|
|
self.speechClient.close()
|
|
self.speechClient = None
|
|
|
|
# Execute the appropriate command based on current status
|
|
action = ""
|
|
if serviceName not in ["fenrirscreenreader-tty.service", "minidlna.service"]:
|
|
action = "disable" if isActive else "enable"
|
|
else:
|
|
action = "stop" if isActive else "start"
|
|
|
|
command = f"sudo systemctl {action} {serviceName} --now"
|
|
|
|
try:
|
|
os.system(command)
|
|
print(f"{friendlyName} {action}d successfully")
|
|
except Exception as e:
|
|
print(f"Error {action}ing {friendlyName}: {e}")
|
|
|
|
# Restart speech client
|
|
self.init_speech()
|
|
|
|
# Restart curses
|
|
self.stdscr = curses.initscr()
|
|
curses.noecho()
|
|
curses.cbreak()
|
|
self.stdscr.keypad(True)
|
|
|
|
# Announce the action
|
|
self.speak(f"{friendlyName} {action}d")
|
|
|
|
# Update the menu with the new service status
|
|
self.update_service_menu_items()
|
|
|
|
# Update Bluetooth menu items if Bluetooth service was toggled
|
|
if friendlyName == "Bluetooth":
|
|
self.update_bluetooth_menu_items()
|
|
|
|
# Redraw the menu
|
|
self.draw_menu()
|
|
|
|
def toggle_screen(self, screenType):
|
|
"""Toggle between screen output and headless mode"""
|
|
try:
|
|
# Platform-specific handling
|
|
is_x86 = platform.machine() == "x86_64"
|
|
|
|
if screenType == "headless":
|
|
if is_x86:
|
|
# On x86, remove all 10-* files for best compatibility
|
|
os.system("sudo rm -f /etc/X11/xorg.conf.d/10-*.conf")
|
|
else:
|
|
# On Pi, only remove non-essential files, preserve fbdev
|
|
os.system("sudo rm -f /etc/X11/xorg.conf.d/10-screen.conf")
|
|
|
|
configFile = "/home/stormux/.local/files/10-headless.conf"
|
|
message = "Screen disabled."
|
|
else:
|
|
# For enabling screen, remove any existing configuration first
|
|
os.system("sudo rm -f /etc/X11/xorg.conf.d/10-*.conf")
|
|
configFile = "/home/stormux/.local/files/10-screen.conf"
|
|
message = "Screen enabled."
|
|
|
|
# Copy the configuration file
|
|
os.system(f"sudo cp {configFile} /etc/X11/xorg.conf.d/")
|
|
|
|
self.speak(message, interrupt=False)
|
|
|
|
except Exception as e:
|
|
message = f"Error changing screen configuration: {e}"
|
|
print(message)
|
|
self.speak(message)
|
|
|
|
def report_battery_status(self):
|
|
"""Report current battery status"""
|
|
try:
|
|
import pathlib
|
|
power_supply_dir = pathlib.Path('/sys/class/power_supply')
|
|
|
|
# Check if power supply directory exists
|
|
if not power_supply_dir.exists():
|
|
self.speak("No battery found.")
|
|
return
|
|
|
|
battery_found = False
|
|
battery_level = None
|
|
is_charging = False
|
|
|
|
# Look for battery
|
|
for item in power_supply_dir.iterdir():
|
|
type_file = item / 'type'
|
|
if type_file.exists():
|
|
try:
|
|
if type_file.read_text().strip() == 'Battery':
|
|
battery_found = True
|
|
|
|
# Get battery level
|
|
capacity_file = item / 'capacity'
|
|
if capacity_file.exists():
|
|
battery_level = int(capacity_file.read_text().strip())
|
|
|
|
# Check charging status
|
|
status_file = item / 'status'
|
|
if status_file.exists():
|
|
status = status_file.read_text().strip()
|
|
is_charging = status in ['Charging', 'Full']
|
|
|
|
break
|
|
except Exception:
|
|
continue
|
|
|
|
# Check for AC power
|
|
ac_connected = False
|
|
for item in power_supply_dir.iterdir():
|
|
type_file = item / 'type'
|
|
online_file = item / 'online'
|
|
if type_file.exists() and online_file.exists():
|
|
try:
|
|
device_type = type_file.read_text().strip()
|
|
if device_type in ['ADP', 'Mains', 'AC']:
|
|
online = int(online_file.read_text().strip())
|
|
if online == 1:
|
|
ac_connected = True
|
|
break
|
|
except Exception:
|
|
continue
|
|
|
|
if not battery_found:
|
|
self.speak("No battery found.")
|
|
return
|
|
|
|
# Build status message
|
|
if battery_level is not None:
|
|
message = f"Battery level: {battery_level} percent"
|
|
|
|
if is_charging:
|
|
message += ", charging"
|
|
elif ac_connected:
|
|
message += ", plugged in"
|
|
else:
|
|
message += ", on battery power"
|
|
|
|
# Add warning context for low levels
|
|
if battery_level <= 5:
|
|
message += ". Critical level!"
|
|
elif battery_level <= 10:
|
|
message += ". Low battery!"
|
|
else:
|
|
message = "Battery detected but unable to read level"
|
|
|
|
self.speak(message)
|
|
|
|
except Exception as e:
|
|
self.speak(f"Error reading battery status: {e}")
|
|
|
|
def update_service_menu_items(self):
|
|
"""Update all service-related menu items based on their current status"""
|
|
# Remove any existing service items
|
|
if "System" in self.menuSections:
|
|
# Find and remove any service-related items
|
|
self.menuSections["System"] = [item for item in self.menuSections["System"]
|
|
if not any(service in item[0] for service in self.systemMenuServices.keys())]
|
|
|
|
# Add the appropriate items based on current status for each service
|
|
for friendlyName, serviceName in self.systemMenuServices.items():
|
|
isActive = self.check_service_status(serviceName)
|
|
if isActive:
|
|
self.add_item("System", f"Disable {friendlyName}",
|
|
lambda fn=friendlyName: self.toggle_service(fn))
|
|
else:
|
|
self.add_item("System", f"Enable {friendlyName}",
|
|
lambda fn=friendlyName: self.toggle_service(fn))
|
|
|
|
def install_and_launch(self, executable_name, launch_mode="gui"):
|
|
"""Launch application via xinitrc (which handles installation if needed)"""
|
|
try:
|
|
# Launch the application - xinitrc will handle installation
|
|
if launch_mode == "gui":
|
|
command = f"GAME='{executable_name}' startx"
|
|
else: # cli mode
|
|
command = f"GAME='{executable_name}' /home/stormux/.clirc"
|
|
|
|
# Use the existing execute_current_item infrastructure by temporarily setting command
|
|
# Save current state
|
|
original_sections = self.sectionNames.copy()
|
|
original_current_section = self.currentSection
|
|
original_items = {}
|
|
for section in self.menuSections:
|
|
original_items[section] = self.menuSections[section].copy()
|
|
|
|
# Create temporary item to execute
|
|
temp_section = "temp_install_launch"
|
|
self.add_section(temp_section)
|
|
self.add_item(temp_section, f"Launch {executable_name}", command)
|
|
|
|
# Set to the temporary section and item
|
|
self.currentSection = len(self.sectionNames) - 1
|
|
self.currentItemIndices[temp_section] = 0
|
|
|
|
# Execute the command using existing infrastructure
|
|
self.execute_current_item()
|
|
|
|
# Restore original state
|
|
self.sectionNames = original_sections
|
|
self.currentSection = original_current_section
|
|
self.menuSections = original_items
|
|
|
|
except Exception as e:
|
|
error_msg = f"Error launching {executable_name}: {e}"
|
|
self.speak(error_msg, interrupt=False)
|
|
|
|
def update_bluetooth_menu_items(self):
|
|
"""Update Bluetooth-related menu items in Accessories section"""
|
|
if "Accessories" in self.menuSections:
|
|
# Remove any existing Bluetooth-related items
|
|
self.menuSections["Accessories"] = [item for item in self.menuSections["Accessories"]
|
|
if "Bluetooth" not in item[0]]
|
|
|
|
# Add Bluetooth management item only if Bluetooth is enabled
|
|
if self.check_service_status('bluetooth.service'):
|
|
self.add_item("Accessories", "Manage Bluetooth Devices",
|
|
"GAME=blueman-manager startx")
|
|
|
|
def scan_documentation_files(self):
|
|
"""Scan Documents directory for .md files and add them to help menu"""
|
|
docs_dir = os.path.expanduser("~/Documents")
|
|
|
|
if not os.path.exists(docs_dir):
|
|
return
|
|
|
|
try:
|
|
# Get all .md files in Documents directory
|
|
md_files = []
|
|
for file in os.listdir(docs_dir):
|
|
if file.endswith('.md'):
|
|
file_path = os.path.join(docs_dir, file)
|
|
if os.path.isfile(file_path):
|
|
# Create a friendly display name from filename
|
|
# Remove .md extension and replace underscores with spaces
|
|
display_name = file[:-3].replace('_', ' ').title()
|
|
md_files.append((display_name, file))
|
|
|
|
# Sort files alphabetically by display name
|
|
md_files.sort(key=lambda x: x[0])
|
|
|
|
# Add each markdown file to the help section
|
|
for display_name, filename in md_files:
|
|
file_path = f"~/Documents/{filename}"
|
|
self.add_item("Help and Documentation", display_name, f"GAME={file_path} /home/stormux/.clirc")
|
|
|
|
except Exception as e:
|
|
print(f"Error scanning documentation files: {e}")
|
|
|
|
def add_section(self, sectionName):
|
|
"""Add a new section to the menu"""
|
|
if sectionName not in self.menuSections:
|
|
self.menuSections[sectionName] = []
|
|
self.sectionNames.append(sectionName)
|
|
self.currentItemIndices[sectionName] = 0
|
|
|
|
def add_item(self, sectionName, name, command):
|
|
"""Add a menu item to a specific section"""
|
|
# Create section if it doesn't exist
|
|
if sectionName not in self.menuSections:
|
|
self.add_section(sectionName)
|
|
|
|
self.menuSections[sectionName].append((name, command))
|
|
|
|
def speak(self, text, interrupt=True):
|
|
"""Speak the given text with option to interrupt existing speech"""
|
|
if self.speechClient is None:
|
|
return
|
|
|
|
try:
|
|
if interrupt:
|
|
self.stop_speech()
|
|
|
|
self.speechClient.speak(text)
|
|
except Exception as e:
|
|
# If speech fails, try to reinitialize and try once more
|
|
try:
|
|
self.init_speech()
|
|
if self.speechClient:
|
|
self.speechClient.speak(text)
|
|
except:
|
|
# If reinitializing fails, just give up silently
|
|
pass
|
|
|
|
def stop_speech(self):
|
|
"""Stop any ongoing speech"""
|
|
if self.speechClient is None:
|
|
return
|
|
|
|
try:
|
|
self.speechClient.cancel()
|
|
except Exception as e:
|
|
# If cancel fails, try to reinitialize
|
|
self.init_speech()
|
|
|
|
def get_current_items(self):
|
|
"""Get items from the current section"""
|
|
currentSectionName = self.sectionNames[self.currentSection]
|
|
return self.menuSections[currentSectionName]
|
|
|
|
def get_current_item_index(self):
|
|
"""Get the current item index in the current section"""
|
|
currentSectionName = self.sectionNames[self.currentSection]
|
|
return self.currentItemIndices[currentSectionName]
|
|
|
|
def set_current_item_index(self, index):
|
|
"""Set the current item index for the current section"""
|
|
currentSectionName = self.sectionNames[self.currentSection]
|
|
self.currentItemIndices[currentSectionName] = index
|
|
|
|
def announce_current_section(self, interrupt=True):
|
|
"""Announce the currently selected section"""
|
|
if 0 <= self.currentSection < len(self.sectionNames):
|
|
sectionName = self.sectionNames[self.currentSection]
|
|
self.speak(sectionName, interrupt=interrupt)
|
|
|
|
def announce_current_item(self, interrupt=True):
|
|
"""Announce the currently selected menu item"""
|
|
if len(self.sectionNames) > 0:
|
|
items = self.get_current_items()
|
|
index = self.get_current_item_index()
|
|
|
|
if 0 <= index < len(items):
|
|
name = items[index][0]
|
|
self.speak(name, interrupt=interrupt)
|
|
|
|
def execute_current_item(self):
|
|
"""Execute the currently selected menu item"""
|
|
if len(self.sectionNames) > 0:
|
|
items = self.get_current_items()
|
|
index = self.get_current_item_index()
|
|
|
|
if 0 <= index < len(items):
|
|
name, command = items[index]
|
|
|
|
# Announce we're launching the program
|
|
self.speak(f"Launching {name}")
|
|
|
|
# Check if command is a function (for service toggles and other functions)
|
|
if callable(command):
|
|
command()
|
|
return
|
|
|
|
# Save current terminal state before any changes
|
|
savedTerminalState = None
|
|
try:
|
|
result = subprocess.run(['stty', '-g'], capture_output=True, text=True, check=True)
|
|
savedTerminalState = result.stdout.strip()
|
|
except Exception as e:
|
|
print(f"Warning: Could not save terminal state: {e}")
|
|
|
|
# Cleanup before running the command
|
|
self.stop_speech()
|
|
|
|
# Close speech client
|
|
if self.speechClient:
|
|
try:
|
|
self.speechClient.close()
|
|
except:
|
|
pass
|
|
self.speechClient = None
|
|
|
|
# Curses cleanup - be thorough
|
|
if self.stdscr:
|
|
try:
|
|
curses.nocbreak()
|
|
self.stdscr.keypad(False)
|
|
curses.echo()
|
|
curses.endwin()
|
|
except:
|
|
pass
|
|
self.stdscr = None
|
|
|
|
# Complete terminal reset to ensure clean state for games
|
|
try:
|
|
subprocess.run(['reset'], check=False, stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
except:
|
|
pass
|
|
|
|
# Ensure we're in a new process group to avoid signal conflicts
|
|
# Execute the command with proper terminal isolation
|
|
try:
|
|
# For applications that need dialog/curses, we need to ensure they
|
|
# become the controlling process with full terminal access
|
|
# Use os.system with proper terminal restoration as it gives
|
|
# the subprocess complete control over the terminal
|
|
|
|
# First, save our current process group
|
|
original_pgrp = os.getpgrp()
|
|
|
|
# Execute using os.system which gives the subprocess complete terminal control
|
|
exit_code = os.system(command)
|
|
|
|
# Try to regain terminal control after the subprocess exits
|
|
try:
|
|
# Get the current terminal file descriptor
|
|
tty_fd = os.open('/dev/tty', os.O_RDWR)
|
|
# Try to make our process group the foreground group again
|
|
os.tcsetpgrp(tty_fd, original_pgrp)
|
|
os.close(tty_fd)
|
|
except:
|
|
# If we can't regain control, that's okay
|
|
pass
|
|
|
|
except Exception as e:
|
|
print(f"Error launching {name}: {e}")
|
|
|
|
# Another terminal reset after game exits to clean up any changes
|
|
try:
|
|
subprocess.run(['reset'], check=False, stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
except:
|
|
pass
|
|
|
|
# Restore the saved terminal state if we have it
|
|
if savedTerminalState:
|
|
try:
|
|
subprocess.run(['stty', savedTerminalState], check=False)
|
|
except Exception as e:
|
|
print(f"Warning: Could not restore terminal state: {e}")
|
|
|
|
# Brief delay to let terminal stabilize
|
|
time.sleep(0.5)
|
|
|
|
# Reinitialize speech and display
|
|
self.init_speech()
|
|
|
|
# Reinitialize curses with better error handling
|
|
try:
|
|
self.stdscr = curses.initscr()
|
|
curses.noecho()
|
|
curses.cbreak()
|
|
self.stdscr.keypad(True)
|
|
self.stdscr.clear()
|
|
self.stdscr.refresh()
|
|
except Exception as e:
|
|
print(f"Error reinitializing display: {e}")
|
|
# Try a more aggressive recovery
|
|
try:
|
|
curses.endwin()
|
|
time.sleep(1)
|
|
self.stdscr = curses.initscr()
|
|
curses.noecho()
|
|
curses.cbreak()
|
|
self.stdscr.keypad(True)
|
|
self.stdscr.clear()
|
|
self.stdscr.refresh()
|
|
except:
|
|
print("Could not recover display. Please restart the menu.")
|
|
sys.exit(1)
|
|
|
|
# Update service menu items and redraw
|
|
self.update_service_menu_items()
|
|
self.draw_menu()
|
|
|
|
# Let the user know the menu is activated
|
|
self.speak("Game menu activated")
|
|
|
|
def speak_help(self):
|
|
"""Speak help information"""
|
|
helpText = """
|
|
Navigation controls:
|
|
Up arrow: Previous menu item.
|
|
Down arrow: Next menu item.
|
|
Left arrow: Previous section.
|
|
Right arrow: Next section.
|
|
Enter: Launch selected item.
|
|
H key: Hear these instructions again.
|
|
B key: Report battery status.
|
|
Left bracket: Decrease speech rate.
|
|
Right bracket: Increase speech rate.
|
|
9 key: Decrease volume.
|
|
0 key: Increase volume.
|
|
Escape: Refresh the menu.
|
|
Q: Exit the menu.
|
|
Any key will interrupt speech.
|
|
"""
|
|
self.speak(helpText)
|
|
|
|
def draw_menu(self):
|
|
"""Draw the menu on the screen"""
|
|
self.stdscr.clear()
|
|
h, w = self.stdscr.getmaxyx()
|
|
|
|
# Draw title
|
|
title = f" {self.title} "
|
|
x = max(0, w // 2 - len(title) // 2)
|
|
self.stdscr.addstr(1, x, title, curses.A_BOLD)
|
|
|
|
# Draw help line
|
|
helpText = "Ãvigate | Enter: Select | H: Help | [ ] Rate | 9 0 Volume | Esc: Refresh"
|
|
x = max(0, w // 2 - len(helpText) // 2)
|
|
self.stdscr.addstr(3, x, helpText)
|
|
|
|
# Draw current section
|
|
if len(self.sectionNames) > 0:
|
|
currentSectionName = self.sectionNames[self.currentSection]
|
|
sectionText = f"== {currentSectionName} =="
|
|
x = max(0, w // 2 - len(sectionText) // 2)
|
|
self.stdscr.addstr(5, x, sectionText, curses.A_BOLD)
|
|
|
|
# Draw menu items for current section
|
|
items = self.get_current_items()
|
|
currentItemIndex = self.get_current_item_index()
|
|
|
|
for i, (name, _) in enumerate(items):
|
|
y = i + 7 # Start items 2 lines below section header
|
|
if y < h - 1: # Ensure we don't draw outside the window
|
|
# Highlight the selected item
|
|
if i == currentItemIndex:
|
|
text = f" > {name} "
|
|
attr = curses.A_REVERSE
|
|
else:
|
|
text = f" {name} "
|
|
attr = curses.A_NORMAL
|
|
|
|
x = max(0, w // 2 - len(text) // 2)
|
|
self.stdscr.addstr(y, x, text, attr)
|
|
|
|
# Draw speech rate indicator
|
|
rateText = f"Speech Rate: {self.speechRate}"
|
|
self.stdscr.addstr(h-2, 2, rateText)
|
|
|
|
# Draw volume indicator
|
|
volumeText = f"Volume: {self.get_current_volume()}%"
|
|
self.stdscr.addstr(h-2, w-len(volumeText)-2, volumeText)
|
|
|
|
self.stdscr.refresh()
|
|
|
|
def cleanup(self):
|
|
"""Clean up resources before exiting"""
|
|
# Stop any speech
|
|
self.stop_speech()
|
|
|
|
# Close speech client
|
|
if self.speechClient:
|
|
try:
|
|
self.speechClient.close()
|
|
except:
|
|
pass
|
|
self.speechClient = None
|
|
|
|
# Restore terminal settings
|
|
curses.nocbreak()
|
|
self.stdscr.keypad(False)
|
|
curses.echo()
|
|
curses.endwin()
|
|
|
|
def run(self):
|
|
"""Run the menu system"""
|
|
if not self.sectionNames:
|
|
print("Menu is empty. Exiting.")
|
|
return
|
|
|
|
try:
|
|
# Initialize curses
|
|
self.stdscr = curses.initscr()
|
|
curses.noecho() # Don't echo keypresses
|
|
curses.cbreak() # React to keys instantly
|
|
self.stdscr.keypad(True) # Enable special keys
|
|
|
|
# Update all service menu items based on current status
|
|
self.update_service_menu_items()
|
|
|
|
# Update Bluetooth menu items based on current status
|
|
self.update_bluetooth_menu_items()
|
|
|
|
# Initial draw
|
|
self.draw_menu()
|
|
|
|
# Welcome message - don't interrupt this initial speech
|
|
self.speak(f"Welcome to {self.title}. Use left and right arrows to navigate sections. Up and down for items. Press H for help.")
|
|
|
|
# Wait for initial speech to finish before announcing section
|
|
time.sleep(1)
|
|
|
|
# Announce initial section and item without interrupting welcome speech
|
|
self.announce_current_section(interrupt=False)
|
|
time.sleep(0.5) # Wait before announcing first item
|
|
self.announce_current_item(interrupt=False)
|
|
|
|
# Main loop
|
|
while True:
|
|
key = self.stdscr.getch()
|
|
|
|
# Stop any speech when a key is pressed
|
|
self.stop_speech()
|
|
|
|
# Handle navigation
|
|
if key == curses.KEY_UP:
|
|
# Move to previous item in current section
|
|
items = self.get_current_items()
|
|
if items:
|
|
prevIndex = self.get_current_item_index()
|
|
self.set_current_item_index((prevIndex - 1) % len(items))
|
|
newIndex = self.get_current_item_index()
|
|
self.draw_menu()
|
|
# Play sound if selection actually changed
|
|
if newIndex != prevIndex:
|
|
self.play_sound("menu_move")
|
|
self.announce_current_item()
|
|
|
|
elif key == curses.KEY_DOWN:
|
|
# Move to next item in current section
|
|
items = self.get_current_items()
|
|
if items:
|
|
prevIndex = self.get_current_item_index()
|
|
self.set_current_item_index((prevIndex + 1) % len(items))
|
|
newIndex = self.get_current_item_index()
|
|
self.draw_menu()
|
|
# Play sound if selection actually changed
|
|
if newIndex != prevIndex:
|
|
self.play_sound("menu_move")
|
|
self.announce_current_item()
|
|
|
|
elif key == curses.KEY_LEFT:
|
|
# Move to previous section
|
|
if self.sectionNames:
|
|
prevSection = self.currentSection
|
|
self.currentSection = (self.currentSection - 1) % len(self.sectionNames)
|
|
self.draw_menu()
|
|
# Play category change sound if section actually changed
|
|
if prevSection != self.currentSection:
|
|
self.play_sound("menu_category")
|
|
# Announce section and current item without interruption between them
|
|
self.announce_current_section()
|
|
time.sleep(0.5) # Brief pause between section and item announcement
|
|
self.announce_current_item(interrupt=False) # Don't interrupt the section announcement
|
|
|
|
elif key == curses.KEY_RIGHT:
|
|
# Move to next section
|
|
if self.sectionNames:
|
|
prevSection = self.currentSection
|
|
self.currentSection = (self.currentSection + 1) % len(self.sectionNames)
|
|
self.draw_menu()
|
|
# Play category change sound if section actually changed
|
|
if prevSection != self.currentSection:
|
|
self.play_sound("menu_category")
|
|
# Announce section and current item without interruption between them
|
|
self.announce_current_section()
|
|
time.sleep(0.5) # Brief pause between section and item announcement
|
|
self.announce_current_item(interrupt=False) # Don't interrupt the section announcement
|
|
|
|
elif key == curses.KEY_ENTER or key == 10 or key == 13: # Enter key
|
|
self.play_sound("menu_select")
|
|
self.execute_current_item()
|
|
|
|
elif key == ord('h') or key == ord('H'): # Help
|
|
self.speak_help()
|
|
|
|
elif key == ord('['): # Decrease speech rate
|
|
self.decrease_speech_rate()
|
|
self.draw_menu()
|
|
|
|
elif key == ord(']'): # Increase speech rate
|
|
self.increase_speech_rate()
|
|
self.draw_menu()
|
|
|
|
elif key == ord('9'): # Decrease volume
|
|
self.decrease_volume()
|
|
|
|
elif key == ord('0'): # Increase volume
|
|
self.increase_volume()
|
|
|
|
elif key == ord('b') or key == ord('B'): # Battery status
|
|
self.report_battery_status()
|
|
|
|
elif key == 27: # Esc key - restart the application
|
|
try:
|
|
# Announce restart first while speech still works
|
|
self.speak("Restarting menu")
|
|
|
|
# Wait for speech to complete
|
|
time.sleep(1)
|
|
|
|
# Now clean up resources
|
|
self.cleanup()
|
|
|
|
# Try to kill speech-dispatcher if needed
|
|
subprocess.run(["sudo", "killall", "speech-dispatcher"], check=False)
|
|
|
|
# Restart the application
|
|
os.execv(sys.argv[0], sys.argv)
|
|
|
|
except Exception as e:
|
|
print(f"Error during restart: {e}")
|
|
# If restart fails, we need to recover the UI
|
|
self.stdscr = curses.initscr()
|
|
curses.noecho()
|
|
curses.cbreak()
|
|
self.stdscr.keypad(True)
|
|
self.draw_menu()
|
|
except Exception as e:
|
|
# End curses in case of error
|
|
curses.endwin()
|
|
print(f"An error occurred: {e}")
|
|
finally:
|
|
# Clean up
|
|
self.cleanup()
|
|
|
|
# Example usage
|
|
if __name__ == "__main__":
|
|
# Create the menu with sections
|
|
menu = VoicedMenu(title="Stormux Gaming Menu")
|
|
|
|
# Add arcade section
|
|
menu.add_section("Arcade")
|
|
menu.add_item("Arcade", "Audio Disc", "GAME='Audio Disc' startx")
|
|
menu.add_item("Arcade", "BallBouncer", "GAME=BallBouncer startx")
|
|
menu.add_item("Arcade", "Battle of the Hunter", "GAME='Battle of the Hunter' startx")
|
|
menu.add_item("Arcade", "Challenge of the Horse", "GAME='Challenge of the Horse' startx")
|
|
menu.add_item("Arcade", "Clashes of the Sky", "GAME='Clashes of the Sky' startx")
|
|
menu.add_item("Arcade", "Constant Motion", "GAME='Constant Motion' startx")
|
|
menu.add_item("Arcade", "Crazy Party", "GAME='Crazy Party' startx")
|
|
menu.add_item("Arcade", "Doom", "GAME=Doom startx")
|
|
menu.add_item("Arcade", "Haunted House", "GAME='Haunted House' startx")
|
|
menu.add_item("Arcade", "Haunted Party", "GAME='Haunted Party' startx")
|
|
menu.add_item("Arcade", "kaskade", "GAME=Kaskade startx")
|
|
menu.add_item("Arcade", "Oh Shitt", "GAME='Oh Shit' startx")
|
|
menu.add_item("Arcade", "Q9 Action Game", "GAME='Q9 Action Game' startx")
|
|
menu.add_item("Arcade", "River Raiders", "GAME='River Raiders' startx")
|
|
menu.add_item("Arcade", "Scramble", "GAME=Scramble startx")
|
|
menu.add_item("Arcade", "Screaming Strike 2", "GAME='Screaming Strike 2' startx")
|
|
menu.add_item("Arcade", "Scrolling Battles", "GAME='Scrolling Battles' startx")
|
|
menu.add_item("Arcade", "Shooter", "GAME=Shooter startx")
|
|
menu.add_item("Arcade", "Side Party", "GAME='Side Party' startx")
|
|
menu.add_item("Arcade", "Skateboarder Pro", "GAME='Skateboarder Pro' startx")
|
|
menu.add_item("Arcade", "Sketchbook (Your World)", "GAME='Sketchbook (Your World)' startx")
|
|
menu.add_item("Arcade", "Super Egg Hunt", "GAME='Super Egg Hunt' startx")
|
|
menu.add_item("Arcade", "Super Liam", "GAME='Super Liam' startx")
|
|
menu.add_item("Arcade", "The Blind Swordsman", "GAME='The Blind Swordsman' startx")
|
|
menu.add_item("Arcade", "The Tornado Chicken", "GAME='The Tornado Chicken' startx")
|
|
menu.add_item("Arcade", "Toy Mania", "GAME='Toy Mania' startx")
|
|
menu.add_item("Arcade", "Villains From Beyond", "GAME='Villains From Beyond' startx")
|
|
menu.add_item("Arcade", "Wicked Quest", "GAME='Wicked Quest' startx")
|
|
menu.add_item("Arcade", "Zombowl", "GAME='Zombowl' startx")
|
|
|
|
# Add Kitchen's Sink and Swamp only on x86_64
|
|
if platform.machine() == "x86_64":
|
|
menu.add_item("Arcade", "Kitchen's Sink", "GAME=\"Kitchen's Sink\" startx")
|
|
menu.add_item("Arcade", "Swamp", "GAME='Swamp' startx")
|
|
|
|
# Add board and card games section
|
|
menu.add_section("Board and Card Games")
|
|
menu.add_item("Board and Card Games", "RS Games", "GAME='RS Games' startx")
|
|
|
|
# Add emulators section
|
|
menu.add_section("Emulators")
|
|
menu.add_item("Emulators", "Apple 2e", "/usr/local/bin/apple_2e.py")
|
|
menu.add_item("Emulators", "Bop It", "GAME='Bop It' startx")
|
|
menu.add_item("Emulators", "Dosbox", "GAME=Dosbox startx")
|
|
menu.add_item("Emulators", "Game Console Menu", "/usr/local/bin/rom_launcher.py")
|
|
menu.add_item("Emulators", "Retro Arch", "GAME='Retro Arch' startx")
|
|
|
|
# Add Steam only on x86_64
|
|
if platform.machine() == "x86_64":
|
|
menu.add_item("Emulators", "Steam", "GAME='Steam' startx")
|
|
|
|
# Add MUD section
|
|
menu.add_section("MUDs")
|
|
menu.add_item("MUDs", "Alter Aeon", "GAME='Alter Aeon' /home/stormux/.clirc")
|
|
menu.add_item("MUDs", "Empire MUD", "GAME='Empire MUD' /home/stormux/.clirc")
|
|
menu.add_item("MUDs", "End of Time", "GAME='End of Time' /home/stormux/.clirc")
|
|
menu.add_item("MUDs", "Kallisti MUD", "GAME='Kallisti MUD' /home/stormux/.clirc")
|
|
|
|
# Add racing section
|
|
menu.add_section("Racing")
|
|
menu.add_item("Racing", "Mach1", "GAME=Mach1 startx")
|
|
menu.add_item("Racing", "Mine Racer", "GAME='Mine Racer' startx")
|
|
menu.add_item("Racing", "Top Speed 3", "GAME=\"Top Speed 3\" startx")
|
|
menu.add_item("Racing", "Wheels of Prio", "GAME=\"Wheels of Prio\" startx")
|
|
|
|
# Add rpg section
|
|
menu.add_section("RPG")
|
|
menu.add_item("RPG", "Bokurano Daibouken", "GAME='Bokurano Daibouken' startx")
|
|
menu.add_item("RPG", "Bokurano Daibouken 2", "GAME='Bokurano Daibouken 2' startx")
|
|
menu.add_item("RPG", "Bokurano Daibouken 3", "GAME='Bokurano Daibouken 3' startx")
|
|
menu.add_item("RPG", "Fantasy Story 2", "GAME='Fantasy Story 2' startx")
|
|
menu.add_item("RPG", "Manamon 2", "GAME='Manamon 2' startx")
|
|
menu.add_item("RPG", "Shadow Line", "GAME='Shadow Line' startx")
|
|
|
|
# Add sports section
|
|
menu.add_section("Sports")
|
|
menu.add_item("Sports", "Golf", "GAME=Golf startx")
|
|
menu.add_item("Sports", "Horseshoes", "GAME=Horseshoes startx")
|
|
menu.add_item("Sports", "Pong", "GAME=Pong startx")
|
|
|
|
# Add strategy section
|
|
menu.add_section("Strategy")
|
|
menu.add_item("Strategy", "SoundRTS", "GAME=SoundRTS startx")
|
|
menu.add_item("Strategy", "Warsim", "/home/stormux/.Warsim")
|
|
|
|
# Add text games section
|
|
menu.add_section("Text Games")
|
|
menu.add_item("Text Games", "BPG", "GAME=BPG /home/stormux/.clirc")
|
|
menu.add_item("Text Games", "Colossal Cave Adventure", "GAME=/usr/bin/adventure /home/stormux/.clirc")
|
|
menu.add_item("Text Games", "Go Fish", "GAME=/usr/bin/gofish /home/stormux/.clirc")
|
|
menu.add_item("Text Games", "RS Games", "GAME=\"RS Games\" /home/stormux/.clirc")
|
|
menu.add_item("Text Games", "Slay the Text", "GAME=\"Slay the Text\" /home/stormux/.clirc")
|
|
menu.add_item("Text Games", "Stationfall", "GAME=Stationfall /home/stormux/.clirc")
|
|
menu.add_item("Text Games", "Planetfall", "GAME=Planetfall /home/stormux/.clirc")
|
|
menu.add_item("Text Games", "The Hitchhiker's Guide to the Galaxy", "GAME=\"The Hitchhiker's Guide to the Galaxy\" /home/stormux/.clirc")
|
|
menu.add_item("Text Games", "Upheaval", "GAME=Upheaval /home/stormux/.Upheaval")
|
|
menu.add_item("Text Games", "Zork 1", "GAME='Zork 1' /home/stormux/.clirc")
|
|
menu.add_item("Text Games", "Zork 2", "GAME='Zork 2' /home/stormux/.clirc")
|
|
menu.add_item("Text Games", "Zork 3", "GAME='Zork 3' /home/stormux/.clirc")
|
|
|
|
# Add web section
|
|
menu.add_section("Web")
|
|
menu.add_item("Web", "Aliens", "GAME=https://files.jantrid.net/aliens// startx")
|
|
menu.add_item("Web", "Echo Commander", "GAME=https://echo-commander.vercel.app/ startx")
|
|
menu.add_item("Web", "Periphery Synthetic EP", "GAME=https://shiftbacktick.itch.io/periphery-synthetic-ep startx")
|
|
menu.add_item("Web", "Pontoon", "GAME='https://oneswitch.org.uk/jsbeeb/?autotype=CHAIN%22PONTOON%22%0A&disc=/Blind_Access/Pontoon_1983_2025.dsd' startx")
|
|
menu.add_item("Web", "QuentinC Play Room", "GAME=https://qcsalon.net/ startx")
|
|
menu.add_item("Web", "soundStrider", "GAME=https://shiftbacktick.itch.io/soundstrider startx")
|
|
|
|
# Add help and documentation section
|
|
menu.add_section("Help and Documentation")
|
|
# Dynamically scan and add all .md files from Documents directory
|
|
menu.scan_documentation_files()
|
|
# Add the IRC help item
|
|
menu.add_item("Help and Documentation", "Get help on IRC", "GAME=IRC /home/stormux/.clirc")
|
|
|
|
# Add accessories section
|
|
menu.add_section("Accessories")
|
|
menu.add_item("Accessories", "Music Player", "/usr/local/bin/music_player.py")
|
|
menu.add_item("Accessories", "Local IP Address", "/usr/local/bin/ip_info.py local")
|
|
menu.add_item("Accessories", "Remote IP Address", "/usr/local/bin/ip_info.py remote")
|
|
menu.add_item("Accessories", "Web Browser", "GAME=Brave startx")
|
|
menu.add_item("Accessories", "LibreOffice", lambda: menu.install_and_launch("libreoffice", "gui"))
|
|
menu.add_item("Accessories", "Thunderbird", lambda: menu.install_and_launch("thunderbird", "gui"))
|
|
|
|
# Add system section
|
|
menu.add_section("System")
|
|
|
|
# Add installer only on x86_64
|
|
if platform.machine() == "x86_64":
|
|
menu.add_item("System", "Install System to Hard Drive", "GAME='Install to Disk' /home/stormux/.clirc")
|
|
|
|
menu.add_item("System", "Internet Configuration", "GAME=\"Network Configuration\" /home/stormux/.clirc")
|
|
menu.add_item("System", "Enable Screen", lambda: menu.toggle_screen("screen"))
|
|
menu.add_item("System", "Disable Screen", lambda: menu.toggle_screen("headless"))
|
|
menu.add_item("System", "Set System Speech Settings", "/usr/local/bin/speechd_rate.py")
|
|
menu.add_item("System", "Set Default Voice", "/usr/local/bin/set-voice.py")
|
|
menu.add_item("System", "Set Timezone", "GAME='Set Timezone' /home/stormux/.clirc")
|
|
menu.add_item("System", "Upload Files", "/home/stormux/.local/upload_server/uploader.py")
|
|
menu.add_item("System", "Restart: Can Take Several Minutes", "sudo reboot")
|
|
menu.add_item("System", "Power Off: Wait 2 Minutes Before Disconnecting Power", "sudo poweroff")
|
|
# Service menu items will be added dynamically in run() method via update_service_menu_items()
|
|
menu.add_item("System", "Resize to fill empty space on disk", "sudo growpartfs $(df --output='source' / | tail -1)")
|
|
|
|
# Run the menu
|
|
menu.run()
|