#!/usr/bin/env python3 import os import sys import configparser import dialog import subprocess import time import select import tempfile from typing import Dict, List, Optional, Tuple class FenrirConfigTool: def __init__(self): os.environ['DIALOGOPTS'] = '--no-lines --visit-items' self.tui = dialog.Dialog(dialog="dialog") self.settingsFile = '/etc/fenrirscreenreader/settings/settings.conf' # Check if we need to re-run with elevated privileges if not self.check_root(): self.escalate_privileges() sys.exit(0) self.instructions = { 'menu': "\nNavigation: Use Up/Down arrows to move, Enter to select, Escape to go back", 'radiolist': "\nNavigation: Use Up/Down arrows to move, Space to select option, Enter to confirm, Escape to cancel", 'inputbox': "\nEnter your value and press Enter to confirm, or Escape to cancel" } # Configuration presets and help text from original FenrirConfig self.presetOptions = { 'sound.driver': ['genericDriver', 'gstreamerDriver'], 'speech.driver': ['speechdDriver', 'genericDriver'], 'screen.driver': ['vcsaDriver', 'dummyDriver', 'ptyDriver', 'debugDriver'], 'keyboard.driver': ['evdevDriver', 'dummyDriver'], 'remote.driver': ['unixDriver', 'tcpDriver'], 'keyboard.charEchoMode': ['0', '1', '2'], 'general.punctuationLevel': ['none', 'some', 'most', 'all'], 'general.debugMode': ['File', 'Print'] } self.helpText = { 'sound.volume': 'Volume level from 0 (quietest) to 1.0 (loudest)', 'speech.rate': 'Speech rate from 0 (slowest) to 1.0 (fastest)', 'speech.pitch': 'Voice pitch from 0 (lowest) to 1.0 (highest)', 'keyboard.charEchoMode': '0 = None, 1 = always, 2 = only while capslock' } def check_root(self) -> bool: return os.geteuid() == 0 def find_privilege_escalation_tool(self) -> Optional[str]: for tool in ['sudo', 'doas']: if subprocess.run(['which', tool], stdout=subprocess.PIPE).returncode == 0: return tool return None def escalate_privileges(self): tool = self.find_privilege_escalation_tool() if not tool: self.tui.msgbox("Error: Neither sudo nor doas found. Please run this script as root.") sys.exit(1) try: scriptPath = os.path.abspath(sys.argv[0]) command = [tool, sys.executable, scriptPath] + sys.argv[1:] os.execvp(tool, command) except Exception as e: self.tui.msgbox(f"Error escalating privileges: {str(e)}") sys.exit(1) def is_boolean_option(self, value: str) -> bool: """Check if the current value is likely a boolean option""" return value.lower() in ['true', 'false'] def validate_input(self, section: str, option: str, value: str) -> tuple[bool, str]: """Validate user input based on the option type and constraints""" try: if option.endswith('volume') or option.endswith('rate') or option.endswith('pitch'): floatVal = float(value) if not 0 <= floatVal <= 1.0: return False, "Value must be between 0 and 1.0" return True, value except ValueError: return False, "Invalid number format" def get_value_with_presets(self, section: str, option: str, currentValue: str) -> Optional[str]: """Get value using appropriate input method based on option type""" key = f"{section}.{option}" # Handle boolean options if self.is_boolean_option(currentValue): choices = [ ('True', '', currentValue.lower() == 'true'), ('False', '', currentValue.lower() == 'false') ] code, tag = self.tui.radiolist( f"Select value for '{option}'" + self.instructions['radiolist'], choices=choices ) return tag if code == self.tui.OK else None # Handle other preset options elif key in self.presetOptions: choices = [(opt, "", opt == currentValue) for opt in self.presetOptions[key]] code, tag = self.tui.radiolist( f"Select value for '{option}'" + (f"\n{self.helpText[key]}" if key in self.helpText else "") + self.instructions['radiolist'], choices=choices ) return tag if code == self.tui.OK else None # Handle free-form input else: helpText = self.helpText.get(key, "") code, value = self.tui.inputbox( f"Enter value for '{option}'" + (f"\n{helpText}" if helpText else "") + self.instructions['inputbox'], init=currentValue ) if code == self.tui.OK: isValid, message = self.validate_input(section, option, value) if not isValid: self.tui.msgbox(f"Invalid input: {message}") return None return value return None def run_command(self, cmd: List[str], needsRoot: bool = False) -> Optional[str]: try: if needsRoot and not self.check_root(): tool = self.find_privilege_escalation_tool() if tool: cmd = [tool] + cmd result = subprocess.run(cmd, capture_output=True, text=True) return result.stdout.strip() if result.returncode == 0 else None except Exception as e: self.tui.msgbox(f"Error running command {' '.join(cmd)}: {e}") return None def get_speechd_modules(self) -> List[str]: output = self.run_command(['spd-say', '-O'], True) if output: lines = output.split('\n') return [line.strip() for line in lines[1:] if line.strip()] return [] def process_espeak_voice(self, voiceLine: str) -> Optional[str]: parts = [p for p in voiceLine.split() if p] if len(parts) < 2: return None langCode = parts[-2].lower() variant = parts[-1].lower() return f"{langCode}+{variant}" if variant and variant != 'none' else langCode def get_module_voices(self, moduleName: str) -> List[str]: output = self.run_command(['spd-say', '-o', moduleName, '-L'], True) if output: lines = output.split('\n') voices = [] for line in lines[1:]: if not line.strip(): continue if moduleName.lower() == 'espeak-ng': voice = self.process_espeak_voice(line) if voice: voices.append(voice) else: voices.append(line.strip()) return voices return [] def configure_speech(self) -> None: moduleList = self.get_speechd_modules() if not moduleList: self.tui.msgbox("No speech-dispatcher modules found!") return code, moduleChoice = self.tui.menu( "Select speech module:" + self.instructions['menu'], choices=[(module, "") for module in moduleList] ) if code != self.tui.OK: return voiceList = self.get_module_voices(moduleChoice) if not voiceList: self.tui.msgbox(f"No voices found for module {moduleChoice}") return code, voice = self.tui.menu( f"Select voice for {moduleChoice}:" + self.instructions['menu'], choices=[(v, "") for v in voiceList] ) if code != self.tui.OK: return # Test voice configuration if self.test_voice(moduleChoice, voice): config = configparser.ConfigParser(interpolation=None) config.read(self.settingsFile) if 'speech' not in config: config['speech'] = {} config['speech'].update({ 'driver': 'speechdDriver', 'module': moduleChoice, 'voice': voice, 'enabled': 'True', 'rate': '0.25', 'pitch': '0.5', 'volume': '1.0' }) with open(self.settingsFile, 'w') as configfile: config.write(configfile) self.tui.msgbox("Speech configuration updated successfully!\nPlease restart Fenrir for changes to take effect.") def test_voice(self, moduleName: str, voiceName: str) -> bool: testMessage = "If you hear this message, press Enter within 30 seconds to confirm." try: process = subprocess.Popen( ['spd-say', '-o', moduleName, '-y', voiceName, testMessage] ) code = self.tui.pause( "Waiting for voice test...\n" "Press Enter if you hear the message, or wait for timeout.", 30 ) process.terminate() return code == self.tui.OK except Exception as e: self.tui.msgbox(f"Error testing voice: {e}") return False def edit_general_config(self) -> None: while True: config = configparser.ConfigParser(interpolation=None) config.read(self.settingsFile) sections = config.sections() code, section = self.tui.menu( "Select a section to configure:" + self.instructions['menu'], choices=[(s, "") for s in sections] + [("Go Back", "")] ) if code != self.tui.OK or section == "Go Back": break while True: options = config.options(section) choices = [(o, f"Current: {config.get(section, o)}") for o in options] choices.append(("Go Back", "")) code, option = self.tui.menu( f"Select option to edit in '{section}':" + self.instructions['menu'], choices=choices ) if code != self.tui.OK or option == "Go Back": break currentValue = config.get(section, option) newValue = self.get_value_with_presets(section, option, currentValue) if newValue is not None and newValue != currentValue: config.set(section, option, newValue) with open(self.settingsFile, 'w') as configfile: config.write(configfile) self.tui.msgbox("Setting updated successfully.") def run(self): while True: code, choice = self.tui.menu( "Fenrir Configuration Tool" + self.instructions['menu'], choices=[ ("speech-dispatcher", "Configure module and voice"), ("Advanced", "Edit Fenrir settings"), ("Exit", "") ] ) if code != self.tui.OK or choice == "Exit": break if choice == "speech-dispatcher": self.configure_speech() elif choice == "Advanced": self.edit_general_config() if __name__ == "__main__": configTool = FenrirConfigTool() try: configTool.run() except Exception as e: print(f"Unexpected error occurred: {str(e)}", file=sys.stderr) sys.exit(1)