Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f462ca7990 | |||
| f0bbcb8a38 | |||
| aed627ec2a | |||
| e62b887e9c | |||
| bf0d134187 | |||
| c66a9ba9c2 | |||
| 2092a3e257 | |||
| d46d8de3ee | |||
| 75a8447759 | |||
| 1650eec768 | |||
| 5bb786ef4c | |||
| 7f7faa17d3 | |||
| 2766f70c5d | |||
| 8d781643bc | |||
| c184cf023a | |||
| 841c221c7b | |||
| 87553bdc38 | |||
| 77a3aae5a4 | |||
| aabc202d83 | |||
| 2f3a114790 |
@@ -4,12 +4,10 @@ Wants=systemd-udev-settle.service
|
||||
After=systemd-udev-settle.service getty.target
|
||||
[Service]
|
||||
Type=forking
|
||||
PIDFile=/var/run/fenrir.pid
|
||||
PIDFile=/run/fenrir.pid
|
||||
ExecStart=/usr/bin/fenrir
|
||||
ExecReload=/usr/bin/kill -HUP $MAINPID
|
||||
Restart=always
|
||||
#Group=fenrirscreenreader
|
||||
#User=fenrirscreenreader
|
||||
|
||||
[Install]
|
||||
WantedBy=getty.target
|
||||
|
||||
@@ -4,7 +4,7 @@ Wants=systemd-udev-settle.service
|
||||
After=systemd-udev-settle.service sound.target
|
||||
[Service]
|
||||
Type=forking
|
||||
PIDFile=/var/run/fenrir.pid
|
||||
PIDFile=/run/fenrir.pid
|
||||
ExecStart=/usr/local/bin/fenrir
|
||||
ExecReload=/usr/bin/kill -HUP $MAINPID
|
||||
Restart=always
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Please report Bugs and feature requests to:
|
||||
https://github.com/chrys87/fenrir/issues
|
||||
Please report bugs and feature requests to:
|
||||
https://git.stormux.org/storm/fenrir/issues
|
||||
|
||||
For bugs, please provide a debug file that shows the issue.
|
||||
How to create a debug file:
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
enabled=True
|
||||
|
||||
# Select the driver used to play sounds, choices are genericDriver and gstreamerDriver.
|
||||
# Sox is the default.
|
||||
#driver=gstreamerDriver
|
||||
driver=genericDriver
|
||||
# Gstreamer is the default.
|
||||
driver=gstreamerDriver
|
||||
#driver=genericDriver
|
||||
|
||||
# Sound themes. These are the pack of sounds used for sound alerts.
|
||||
# Sound packs may be located at /usr/share/sounds
|
||||
@@ -110,7 +110,7 @@ keyboardLayout=desktop
|
||||
# echo chars while typing.
|
||||
# 0 = None
|
||||
# 1 = always
|
||||
# 2 = only while capslock
|
||||
# 2 = only while capslock (not compatible with capslock as fenrir key)
|
||||
charEchoMode=1
|
||||
# echo deleted chars
|
||||
charDeleteEcho=True
|
||||
@@ -217,6 +217,13 @@ list=
|
||||
|
||||
[menu]
|
||||
vmenuPath=
|
||||
# quickMenu: Semicolon-separated list of settings for quick adjustment
|
||||
# Format: section#setting;section#setting;...
|
||||
# Supported settings:
|
||||
# - speech#rate, speech#pitch, speech#volume (0.0-1.0)
|
||||
# - speech#module, speech#voice (speechdDriver only, auto-added)
|
||||
# Note: speech#module and speech#voice are automatically added when
|
||||
# speechdDriver is active. Do not add them manually.
|
||||
quickMenu=speech#rate;speech#pitch;speech#volume
|
||||
|
||||
[prompt]
|
||||
|
||||
+6
-6
@@ -7,14 +7,14 @@ configurable and easy to customize and extend.
|
||||
=== Credit and intended audience
|
||||
|
||||
This document is just a customization for Slint of the genuine
|
||||
https://github.com/chrys87/fenrir/blob/master/docu/user.txt[Fenrir User
|
||||
https://git.stormux.org/storm/fenrir/src/branch/master/docs/user.txt[Fenrir User
|
||||
Manual] motly written by Chrys, main developer of Fenrir.
|
||||
|
||||
It has been adapted to its intended audience: end users of Fenrir on
|
||||
Slint where it is already installed, thus concentrates on its setting
|
||||
and usage. You will find more information about its features,
|
||||
installation and how customize and troubleshoot it and contribute to its
|
||||
development on https://github.com/chrys87/fenrir[the Fenrir Git
|
||||
development on https://git.stormux.org/storm/fenrir[the Fenrir Git
|
||||
repository].
|
||||
|
||||
=== Getting started with Fenrir
|
||||
@@ -2193,9 +2193,9 @@ settings.conf). Commands are python files with a special scheme. You can
|
||||
assign them to a shortcut using the filename without an extension or
|
||||
place them in a hook trigger like OnInput or OnScreenChange. For further
|
||||
information see developer guide. Good Examples:
|
||||
https://github.com/chrys87/fenrir/blob/master/src/fenrir/commands/commands/date.py["date.py"]
|
||||
https://git.stormux.org/storm/fenrir/src/branch/master/src/fenrirscreenreader/commands/commands/date.py["date.py"]
|
||||
(announce the Date),
|
||||
https://github.com/chrys87/fenrir/blob/master/src/fenrir/commands/commands/shut_up.py["shut_up.py"]
|
||||
https://git.stormux.org/storm/fenrir/src/branch/master/src/fenrirscreenreader/commands/commands/shut_up.py["shut_up.py"]
|
||||
(interrupt output) the basic scheme for a command is as follows:
|
||||
|
||||
....
|
||||
@@ -2218,7 +2218,7 @@ class command():
|
||||
pass
|
||||
....
|
||||
|
||||
* https://github.com/chrys87/fenrir/blob/master/src/fenrir/commands/command_template.py[Template
|
||||
* https://git.stormux.org/storm/fenrir/src/branch/master/src/fenrirscreenreader/commands/command_template.py[Template
|
||||
lives here]
|
||||
* The class needs to have the name "command".
|
||||
* "initialize" is running once whilst loading the command.
|
||||
@@ -2276,7 +2276,7 @@ root.
|
||||
=== Bugreports and feature requests
|
||||
|
||||
Please report Bugs and feature requests to:
|
||||
https://github.com/chrys87/fenrir/issues
|
||||
https://git.stormux.org/storm/fenrir/issues
|
||||
|
||||
for bugs please provide a link:#Howto create a debug file[debug] file
|
||||
that shows the issue.
|
||||
|
||||
+6
-6
@@ -160,7 +160,7 @@ For Arch there are PKGBUILDs in the AUR:
|
||||
|
||||
- Download the latest stable version from the [[https://linux-a11y.org/index.php?page=fenrir-screenreader|Fenrir-Project]] site.
|
||||
- Unpack the archive
|
||||
- Check the needed Dependencys by running [[https://github.com/chrys87/fenrir/blob/master/check-dependencies.py|check-dependencys.py]] script
|
||||
- Check the needed Dependencys by running [[https://git.stormux.org/storm/fenrir/src/branch/master/check-dependencies.py|check-dependencys.py]] script
|
||||
- install the missing dependencies an standard installation requires the following:
|
||||
* python3 >= 3.3 (and all the following is needed for python3 )
|
||||
* python3-speechd (screen)
|
||||
@@ -171,7 +171,7 @@ For Arch there are PKGBUILDs in the AUR:
|
||||
* python3-pyenchant (spellchecker)
|
||||
* your language for aspell (aspell-<lang>) (spellchecker)
|
||||
* sox (sound)
|
||||
* For an individual installation see [[#Support and Requirements|Support and Requirements]] or consult the [[https://github.com/chrys87/fenrir/blob/master/README.md|Readme]])
|
||||
* For an individual installation see [[#Support and Requirements|Support and Requirements]] or consult the [[https://git.stormux.org/storm/fenrir/src/branch/master/README.md|Readme]])
|
||||
- run "install.sh" as root
|
||||
|
||||
this installs Fenrir as the following
|
||||
@@ -185,7 +185,7 @@ to remove Fenrir just run uninstall.sh as root
|
||||
|
||||
if you want to get the latest code you can use git to get a development snapshot:
|
||||
|
||||
git clone https://github.com/chrys87/fenrir.git
|
||||
git clone https://git.stormux.org/storm/fenrir.git
|
||||
|
||||
===== Auto Start =====
|
||||
|
||||
@@ -1270,7 +1270,7 @@ File: ''/usr/share/fenrirscreenreader/scripts/helloWorld__-__key_h.sh'':
|
||||
===== Commands =====
|
||||
You can place your own commands in "/usr/share/fenrirscreenreader/commands" (path is configurable in settings.conf).
|
||||
Commands are python files with a special scheme. You can assign them to a shortcut using the filename without an extension or place them in a hook trigger like OnInput or OnScreenChange. For further information see developer guide.
|
||||
Good Examples: [[https://github.com/chrys87/fenrir/blob/master/src/fenrir/commands/commands/date.py|"date.py"]] (announce the Date), [[https://github.com/chrys87/fenrir/blob/master/src/fenrir/commands/commands/shut_up.py|"shut_up.py"]] (interrupt output)
|
||||
Good Examples: [[https://git.stormux.org/storm/fenrir/src/branch/master/src/fenrirscreenreader/commands/commands/date.py|"date.py"]] (announce the Date), [[https://git.stormux.org/storm/fenrir/src/branch/master/src/fenrirscreenreader/commands/commands/shut_up.py|"shut_up.py"]] (interrupt output)
|
||||
the basic scheme for a command is as follows:
|
||||
|
||||
from core import debug
|
||||
@@ -1289,7 +1289,7 @@ the basic scheme for a command is as follows:
|
||||
def setCallback(self, callback):
|
||||
pass
|
||||
|
||||
* [[https://github.com/chrys87/fenrir/blob/master/src/fenrir/commands/command_template.py|Template lives here]]
|
||||
* [[https://git.stormux.org/storm/fenrir/src/branch/master/src/fenrirscreenreader/commands/command_template.py|Template lives here]]
|
||||
* The class needs to have the name "command".
|
||||
* "initialize" is running once whilst loading the command.
|
||||
* "shutdown" is running on unload like the command (quit fenrir)
|
||||
@@ -1319,7 +1319,7 @@ the basic scheme for a command is as follows:
|
||||
- You can test if speech-dispatcher works by invoking it as root\\ ''sudo spd-say "hello world"''
|
||||
===== Bugreports and feature requests =====
|
||||
Please report Bugs and feature requests to:
|
||||
[[https://github.com/chrys87/fenrir/issues|https://github.com/chrys87/fenrir/issues]]
|
||||
[[https://git.stormux.org/storm/fenrir/issues|https://git.stormux.org/storm/fenrir/issues]]
|
||||
|
||||
for bugs please provide a [[#Howto create a debug file|debug]] file that shows the issue.
|
||||
==== How-to create a debug file ====
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ To test Fenrir:
|
||||
sudo fenrir
|
||||
|
||||
To have Fenrir start on system boot using systemd:
|
||||
download service file: https://raw.githubusercontent.com/chrys87/fenrir/master/autostart/systemd/Arch/fenrir.service
|
||||
download service file: https://git.stormux.org/storm/fenrir/raw/branch/master/autostart/systemd/Arch/fenrir.service
|
||||
move the service file to: /etc/systemd/system/fenrir.service
|
||||
sudo systemctl enable fenrir
|
||||
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
[pytest]
|
||||
# Pytest configuration for Fenrir screen reader
|
||||
|
||||
# Test discovery patterns
|
||||
python_files = test_*.py *_test.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
|
||||
# Test paths
|
||||
testpaths = tests
|
||||
|
||||
# Minimum Python version
|
||||
minversion = 3.7
|
||||
|
||||
# Output options
|
||||
addopts =
|
||||
# Verbose output with test names
|
||||
-v
|
||||
# Show extra test summary info
|
||||
-ra
|
||||
# Enable strict markers (only registered markers allowed)
|
||||
--strict-markers
|
||||
# Show local variables in tracebacks
|
||||
--showlocals
|
||||
# Warnings configuration
|
||||
-W ignore::DeprecationWarning
|
||||
# Optional plugins (uncomment if installed):
|
||||
# --timeout=30 # Requires pytest-timeout
|
||||
# --cov-report=term-missing # Requires pytest-cov
|
||||
# -x # Stop on first failure
|
||||
|
||||
# Register custom markers
|
||||
markers =
|
||||
unit: Unit tests (fast, no mocking)
|
||||
integration: Integration tests (require mocking)
|
||||
driver: Driver tests (require root access)
|
||||
slow: Tests that take more than 1 second
|
||||
remote: Tests for remote control functionality
|
||||
settings: Tests for settings and configuration
|
||||
commands: Tests for command system
|
||||
vmenu: Tests for VMenu system
|
||||
|
||||
# Coverage configuration
|
||||
[coverage:run]
|
||||
source = src/fenrirscreenreader
|
||||
omit =
|
||||
*/tests/*
|
||||
*/vmenu-profiles/*
|
||||
*/__pycache__/*
|
||||
*/site-packages/*
|
||||
|
||||
[coverage:report]
|
||||
# Fail if coverage falls below this percentage
|
||||
# fail_under = 70
|
||||
exclude_lines =
|
||||
# Standard pragma
|
||||
pragma: no cover
|
||||
# Don't complain about missing debug code
|
||||
def __repr__
|
||||
# Don't complain if tests don't hit defensive assertion code
|
||||
raise AssertionError
|
||||
raise NotImplementedError
|
||||
# Don't complain about abstract methods
|
||||
@abstractmethod
|
||||
# Don't complain about initialization
|
||||
if __name__ == .__main__.:
|
||||
|
||||
[coverage:html]
|
||||
directory = htmlcov
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
from fenrirscreenreader.core import debug
|
||||
|
||||
|
||||
class command:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def initialize(self, environment):
|
||||
self.env = environment
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
def get_description(self):
|
||||
return _(
|
||||
"TUI focus mode handler - suppresses screen update spam "
|
||||
"for interactive TUI applications"
|
||||
)
|
||||
|
||||
def run(self):
|
||||
# Check if TUI mode is enabled
|
||||
if not self.env["runtime"]["SettingsManager"].get_setting_as_bool(
|
||||
"focus", "tui"
|
||||
):
|
||||
return
|
||||
|
||||
# TUI mode is active - set suppression flag for incoming handler
|
||||
# This prevents the 70000-incoming.py command from announcing
|
||||
# screen updates
|
||||
self.env["commandBuffer"]["tuiSuppressIncoming"] = True
|
||||
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
"tui_focus_handler: TUI mode active, suppressing incoming text",
|
||||
debug.DebugLevel.INFO
|
||||
)
|
||||
|
||||
def set_callback(self, callback):
|
||||
pass
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
|
||||
# Load base configuration class
|
||||
_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py")
|
||||
_spec = importlib.util.spec_from_file_location("config_base", _base_path)
|
||||
_module = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_module)
|
||||
config_command = _module.config_command
|
||||
|
||||
|
||||
class command(config_command):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def get_description(self):
|
||||
return "Set keyboard layout to Desktop"
|
||||
|
||||
def run(self):
|
||||
current_layout = self.get_setting("keyboard", "keyboardLayout", "desktop")
|
||||
|
||||
if current_layout.lower() == "desktop":
|
||||
self.present_text("Keyboard layout already set to Desktop")
|
||||
return
|
||||
|
||||
success = self.set_setting("keyboard", "keyboardLayout", "desktop")
|
||||
|
||||
if success:
|
||||
self.present_text("Keyboard layout set to Desktop")
|
||||
self.present_text("Please restart Fenrir for this change to take effect")
|
||||
self.play_sound("Accept")
|
||||
else:
|
||||
self.present_text("Failed to change keyboard layout")
|
||||
self.play_sound("Error")
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
|
||||
# Load base configuration class
|
||||
_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py")
|
||||
_spec = importlib.util.spec_from_file_location("config_base", _base_path)
|
||||
_module = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_module)
|
||||
config_command = _module.config_command
|
||||
|
||||
|
||||
class command(config_command):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def get_description(self):
|
||||
return "Set keyboard layout to Laptop"
|
||||
|
||||
def run(self):
|
||||
current_layout = self.get_setting("keyboard", "keyboardLayout", "desktop")
|
||||
|
||||
if current_layout.lower() == "laptop":
|
||||
self.present_text("Keyboard layout already set to Laptop")
|
||||
return
|
||||
|
||||
success = self.set_setting("keyboard", "keyboardLayout", "laptop")
|
||||
|
||||
if success:
|
||||
self.present_text("Keyboard layout set to Laptop")
|
||||
self.present_text("Please restart Fenrir for this change to take effect")
|
||||
self.play_sound("Accept")
|
||||
else:
|
||||
self.present_text("Failed to change keyboard layout")
|
||||
self.play_sound("Error")
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
|
||||
# Load base configuration class
|
||||
_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py")
|
||||
_spec = importlib.util.spec_from_file_location("config_base", _base_path)
|
||||
_module = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_module)
|
||||
config_command = _module.config_command
|
||||
|
||||
|
||||
class command(config_command):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def get_description(self):
|
||||
return "Set keyboard layout to PTY (terminal emulation)"
|
||||
|
||||
def run(self):
|
||||
current_layout = self.get_setting("keyboard", "keyboardLayout", "desktop")
|
||||
|
||||
if current_layout.lower() == "pty":
|
||||
self.present_text("Keyboard layout already set to PTY")
|
||||
return
|
||||
|
||||
success = self.set_setting("keyboard", "keyboardLayout", "pty")
|
||||
|
||||
if success:
|
||||
self.present_text("Keyboard layout set to PTY for terminal emulation")
|
||||
self.present_text("Please restart Fenrir for this change to take effect")
|
||||
self.play_sound("Accept")
|
||||
else:
|
||||
self.present_text("Failed to change keyboard layout")
|
||||
self.play_sound("Error")
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
|
||||
# Load base configuration class
|
||||
_base_path = os.path.join(os.path.dirname(__file__), "..", "config_base.py")
|
||||
_spec = importlib.util.spec_from_file_location("config_base", _base_path)
|
||||
_module = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_module)
|
||||
config_command = _module.config_command
|
||||
|
||||
|
||||
class command(config_command):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def get_description(self):
|
||||
return "Set keyboard layout to PTY2 (alternative terminal layout)"
|
||||
|
||||
def run(self):
|
||||
current_layout = self.get_setting("keyboard", "keyboardLayout", "desktop")
|
||||
|
||||
if current_layout.lower() == "pty2":
|
||||
self.present_text("Keyboard layout already set to PTY2")
|
||||
return
|
||||
|
||||
success = self.set_setting("keyboard", "keyboardLayout", "pty2")
|
||||
|
||||
if success:
|
||||
self.present_text("Keyboard layout set to PTY2 alternative terminal layout")
|
||||
self.present_text("Please restart Fenrir for this change to take effect")
|
||||
self.play_sound("Accept")
|
||||
else:
|
||||
self.present_text("Failed to change keyboard layout")
|
||||
self.play_sound("Error")
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import glob
|
||||
import os
|
||||
|
||||
from fenrirscreenreader.core import debug
|
||||
|
||||
|
||||
class DynamicKeyboardLayoutCommand:
|
||||
"""Dynamic command class for keyboard layout selection"""
|
||||
|
||||
def __init__(self, layoutName, layoutPath, env):
|
||||
self.layoutName = layoutName
|
||||
self.layoutPath = layoutPath
|
||||
# Extract just the base name without extension for comparison
|
||||
self.layoutBaseName = layoutName
|
||||
self.env = env
|
||||
|
||||
def initialize(self, environment):
|
||||
self.env = environment
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
def get_description(self):
|
||||
return f"Set keyboard layout to {self.layoutName}"
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
settingsManager = self.env["runtime"]["SettingsManager"]
|
||||
currentLayout = settingsManager.get_setting(
|
||||
"keyboard", "keyboardLayout"
|
||||
)
|
||||
|
||||
# Check if already set (compare both full path and base name)
|
||||
currentBaseName = os.path.splitext(os.path.basename(currentLayout))[0] if currentLayout else ""
|
||||
if currentBaseName.lower() == self.layoutBaseName.lower() or currentLayout.lower() == self.layoutPath.lower():
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Keyboard layout already set to {self.layoutName}"
|
||||
)
|
||||
return
|
||||
|
||||
# Set the new layout in the config file using full path
|
||||
try:
|
||||
# Update the setting in memory
|
||||
settingsManager.set_setting(
|
||||
"keyboard", "keyboardLayout", self.layoutPath
|
||||
)
|
||||
|
||||
# Save to the actual config file
|
||||
configFilePath = settingsManager.get_settings_file()
|
||||
settingsManager.save_settings(configFilePath)
|
||||
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Keyboard layout set to {self.layoutName}. Please restart Fenrir for this change to take effect."
|
||||
)
|
||||
# Play accept sound
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"", sound_icon="Accept", interrupt=False
|
||||
)
|
||||
except Exception as e:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Failed to change keyboard layout to {self.layoutName}"
|
||||
)
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"DynamicKeyboardLayout: Error setting layout {self.layoutName}: {e}",
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
# Play error sound
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"", sound_icon="ErrorSound", interrupt=False
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"Keyboard layout change error: {str(e)}", interrupt=True
|
||||
)
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"DynamicKeyboardLayout: Unexpected error for {self.layoutName}: {e}",
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
|
||||
def set_callback(self, callback):
|
||||
pass
|
||||
|
||||
|
||||
def add_dynamic_keyboard_layout_menus(VmenuManager):
|
||||
"""Add dynamic keyboard layout menus to vmenu system"""
|
||||
try:
|
||||
env = VmenuManager.env
|
||||
|
||||
# Get keyboard layout files
|
||||
layouts = get_keyboard_layouts(env)
|
||||
if not layouts:
|
||||
return
|
||||
|
||||
# Create keyboard layouts submenu
|
||||
layoutMenu = {}
|
||||
|
||||
# Add layout commands
|
||||
for layoutName, layoutPath in layouts:
|
||||
layoutCommand = DynamicKeyboardLayoutCommand(
|
||||
layoutName, layoutPath, env
|
||||
)
|
||||
layoutMenu[f"{layoutName} Action"] = layoutCommand
|
||||
|
||||
# Find keyboard menu in existing vmenu structure
|
||||
# If fenrir menu exists, add layouts under it
|
||||
if "fenrir Menu" in VmenuManager.menuDict:
|
||||
fenrirMenu = VmenuManager.menuDict["fenrir Menu"]
|
||||
if "keyboard Menu" in fenrirMenu:
|
||||
# Add dynamic layouts to existing keyboard menu
|
||||
keyboardMenu = fenrirMenu["keyboard Menu"]
|
||||
keyboardMenu["Keyboard Layouts Menu"] = layoutMenu
|
||||
else:
|
||||
# Create keyboard menu with layouts
|
||||
fenrirMenu["keyboard Menu"] = {
|
||||
"Keyboard Layouts Menu": layoutMenu
|
||||
}
|
||||
else:
|
||||
# Create standalone keyboard layouts menu
|
||||
VmenuManager.menuDict["Keyboard Layouts Menu"] = layoutMenu
|
||||
|
||||
except Exception as e:
|
||||
# Use debug manager for error logging
|
||||
if "DebugManager" in env.get("runtime", {}):
|
||||
env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"Error creating dynamic keyboard layout menus: {e}",
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
else:
|
||||
print(f"Error creating dynamic keyboard layout menus: {e}")
|
||||
|
||||
|
||||
def get_keyboard_layouts(env):
|
||||
"""Get available keyboard layouts from keyboard directory"""
|
||||
layouts = []
|
||||
|
||||
try:
|
||||
# Get keyboard directory paths
|
||||
keyboardDirs = []
|
||||
|
||||
# Check system installation path
|
||||
systemKeyboardPath = "/etc/fenrirscreenreader/keyboard/"
|
||||
if os.path.exists(systemKeyboardPath):
|
||||
keyboardDirs.append(systemKeyboardPath)
|
||||
|
||||
# Check source/development path
|
||||
try:
|
||||
import fenrirscreenreader
|
||||
|
||||
fenrirPath = os.path.dirname(fenrirscreenreader.__file__)
|
||||
devKeyboardPath = os.path.join(
|
||||
fenrirPath, "..", "..", "config", "keyboard"
|
||||
)
|
||||
devKeyboardPath = os.path.abspath(devKeyboardPath)
|
||||
if os.path.exists(devKeyboardPath):
|
||||
keyboardDirs.append(devKeyboardPath)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get current layout setting path
|
||||
try:
|
||||
currentLayoutSetting = env["runtime"]["SettingsManager"].get_setting(
|
||||
"keyboard", "keyboardLayout"
|
||||
)
|
||||
if currentLayoutSetting and os.path.exists(currentLayoutSetting):
|
||||
currentLayoutDir = os.path.dirname(currentLayoutSetting)
|
||||
if currentLayoutDir not in keyboardDirs:
|
||||
keyboardDirs.append(currentLayoutDir)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Scan for .conf files
|
||||
seenLayouts = set()
|
||||
for keyboardDir in keyboardDirs:
|
||||
try:
|
||||
confFiles = glob.glob(os.path.join(keyboardDir, "*.conf"))
|
||||
for confFile in confFiles:
|
||||
layoutName = os.path.splitext(os.path.basename(confFile))[
|
||||
0
|
||||
]
|
||||
if layoutName not in seenLayouts:
|
||||
seenLayouts.add(layoutName)
|
||||
layouts.append((layoutName, confFile))
|
||||
except Exception as e:
|
||||
if "DebugManager" in env.get("runtime", {}):
|
||||
env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"Error scanning keyboard directory {keyboardDir}: {e}",
|
||||
debug.DebugLevel.WARNING,
|
||||
)
|
||||
|
||||
# Sort layouts alphabetically
|
||||
layouts.sort(key=lambda x: x[0].lower())
|
||||
|
||||
except Exception as e:
|
||||
if "DebugManager" in env.get("runtime", {}):
|
||||
env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"Error getting keyboard layouts: {e}",
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
|
||||
return layouts
|
||||
@@ -13,6 +13,76 @@ from fenrirscreenreader.core import debug
|
||||
from fenrirscreenreader.core.eventData import FenrirEventType
|
||||
|
||||
|
||||
# Standalone functions for multiprocessing (cannot be instance methods)
|
||||
def _heart_beat_timer(running):
|
||||
"""
|
||||
Standalone heartbeat timer function for multiprocessing.
|
||||
Returns current timestamp after a short sleep.
|
||||
"""
|
||||
try:
|
||||
time.sleep(0.5)
|
||||
except Exception as e:
|
||||
print(f"ProcessManager _heart_beat_timer: Error during sleep: {e}")
|
||||
return time.time()
|
||||
|
||||
|
||||
def _custom_event_worker_process(
|
||||
running, event_queue, function, pargs=None, run_once=False
|
||||
):
|
||||
"""
|
||||
Standalone worker function for custom events in multiprocessing.
|
||||
Cannot use instance methods due to pickle limitations with self.env.
|
||||
"""
|
||||
if not callable(function):
|
||||
return
|
||||
while running.value:
|
||||
try:
|
||||
if pargs:
|
||||
function(running, event_queue, pargs)
|
||||
else:
|
||||
function(running, event_queue)
|
||||
except Exception as e:
|
||||
# Cannot use DebugManager in multiprocess context
|
||||
print(
|
||||
f"ProcessManager:_custom_event_worker_process:function("
|
||||
f"{function}):{e}"
|
||||
)
|
||||
if run_once:
|
||||
break
|
||||
|
||||
|
||||
def _simple_event_worker_process(
|
||||
running, event_queue, event, function, pargs=None, run_once=False
|
||||
):
|
||||
"""
|
||||
Standalone worker function for simple events in multiprocessing.
|
||||
Cannot use instance methods due to pickle limitations with self.env.
|
||||
"""
|
||||
if not isinstance(event, FenrirEventType):
|
||||
return
|
||||
if not callable(function):
|
||||
return
|
||||
while running.value:
|
||||
data = None
|
||||
try:
|
||||
if pargs:
|
||||
data = function(running, pargs)
|
||||
else:
|
||||
data = function(running)
|
||||
except Exception as e:
|
||||
# Cannot use DebugManager in multiprocess context
|
||||
print(
|
||||
f"ProcessManager:_simple_event_worker_process:function("
|
||||
f"{function}):{e}"
|
||||
)
|
||||
try:
|
||||
event_queue.put({"Type": event, "data": data}, timeout=0.1)
|
||||
except Exception as e:
|
||||
print(f"ProcessManager: Failed to put event to queue: {e}")
|
||||
if run_once:
|
||||
break
|
||||
|
||||
|
||||
class ProcessManager:
|
||||
def __init__(self):
|
||||
self._Processes = []
|
||||
@@ -23,7 +93,7 @@ class ProcessManager:
|
||||
self.running = self.env["runtime"]["EventManager"].get_running()
|
||||
self.add_simple_event_thread(
|
||||
FenrirEventType.heart_beat,
|
||||
self.heart_beat_timer,
|
||||
_heart_beat_timer,
|
||||
multiprocess=True,
|
||||
)
|
||||
|
||||
@@ -60,8 +130,8 @@ class ProcessManager:
|
||||
original_sigint_handler = signal.signal(signal.SIGINT, signal.SIG_IGN)
|
||||
if multiprocess:
|
||||
t = Process(
|
||||
target=self.custom_event_worker_thread,
|
||||
args=(event_queue, function, pargs, run_once),
|
||||
target=_custom_event_worker_process,
|
||||
args=(self.running, event_queue, function, pargs, run_once),
|
||||
)
|
||||
self._Processes.append(t)
|
||||
else: # use thread instead of process
|
||||
@@ -78,9 +148,11 @@ class ProcessManager:
|
||||
):
|
||||
original_sigint_handler = signal.signal(signal.SIGINT, signal.SIG_IGN)
|
||||
if multiprocess:
|
||||
# Get event queue reference before creating process
|
||||
event_queue = self.env["runtime"]["EventManager"].get_event_queue()
|
||||
t = Process(
|
||||
target=self.simple_event_worker_thread,
|
||||
args=(event, function, pargs, run_once),
|
||||
target=_simple_event_worker_process,
|
||||
args=(self.running, event_queue, event, function, pargs, run_once),
|
||||
)
|
||||
self._Processes.append(t)
|
||||
else:
|
||||
@@ -106,12 +178,13 @@ class ProcessManager:
|
||||
else:
|
||||
function(self.running, event_queue)
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
# Cannot use DebugManager in multiprocess context due to
|
||||
# pickle limitations with file handles
|
||||
print(
|
||||
"ProcessManager:custom_event_worker_thread:function("
|
||||
+ str(function)
|
||||
+ "):"
|
||||
+ str(e),
|
||||
debug.DebugLevel.ERROR,
|
||||
+ str(e)
|
||||
)
|
||||
if run_once:
|
||||
break
|
||||
@@ -131,12 +204,13 @@ class ProcessManager:
|
||||
else:
|
||||
data = function(self.running)
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
# Cannot use DebugManager in multiprocess context due to
|
||||
# pickle limitations with file handles
|
||||
print(
|
||||
"ProcessManager:simple_event_worker_thread:function("
|
||||
+ str(function)
|
||||
+ "):"
|
||||
+ str(e),
|
||||
debug.DebugLevel.ERROR,
|
||||
+ str(e)
|
||||
)
|
||||
self.env["runtime"]["EventManager"].put_to_event_queue(event, data)
|
||||
if run_once:
|
||||
|
||||
@@ -7,25 +7,285 @@
|
||||
from fenrirscreenreader.core import debug
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
from fenrirscreenreader.core.settingsData import settings_data
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
|
||||
class QuickMenuManager:
|
||||
class SpeechHelperMixin:
|
||||
"""Helper methods for querying speech-dispatcher modules and voices.
|
||||
|
||||
Provides caching and query functionality for speech-dispatcher module
|
||||
and voice enumeration, reusing proven logic from voice_browser.py.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._modules_cache = None
|
||||
self._voices_cache = {} # {module_name: [voice_list]}
|
||||
self._cache_timestamp = 0
|
||||
self._cache_timeout = 300 # 5 minutes
|
||||
|
||||
def get_speechd_modules(self):
|
||||
"""Get available speech-dispatcher modules (cached).
|
||||
|
||||
Returns:
|
||||
list: Available module names (e.g., ['espeak-ng', 'festival'])
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
# Return cached if valid
|
||||
if (self._modules_cache and
|
||||
(now - self._cache_timestamp) < self._cache_timeout):
|
||||
return self._modules_cache
|
||||
|
||||
# Query spd-say
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["spd-say", "-O"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=8
|
||||
)
|
||||
if result.returncode == 0:
|
||||
lines = result.stdout.strip().split("\n")
|
||||
self._modules_cache = [
|
||||
line.strip() for line in lines[1:]
|
||||
if line.strip() and line.strip().lower() != "dummy"
|
||||
]
|
||||
self._cache_timestamp = now
|
||||
return self._modules_cache
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
(f"QuickMenuManager get_speechd_modules: "
|
||||
f"Error querying modules: {e}"),
|
||||
debug.DebugLevel.ERROR
|
||||
)
|
||||
|
||||
return []
|
||||
|
||||
def get_module_voices(self, module):
|
||||
"""Get voices for a specific module (cached per-module).
|
||||
|
||||
Args:
|
||||
module (str): Module name (e.g., 'espeak-ng')
|
||||
|
||||
Returns:
|
||||
list: Available voice names for this module
|
||||
"""
|
||||
# Return cached if available
|
||||
if module in self._voices_cache:
|
||||
return self._voices_cache[module]
|
||||
|
||||
# Query spd-say
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["spd-say", "-o", module, "-L"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=8
|
||||
)
|
||||
if result.returncode == 0:
|
||||
lines = result.stdout.strip().split("\n")
|
||||
voices = []
|
||||
for line in lines[1:]:
|
||||
if not line.strip():
|
||||
continue
|
||||
if module.lower() == "espeak-ng":
|
||||
voice = self._process_espeak_voice(line)
|
||||
if voice:
|
||||
voices.append(voice)
|
||||
elif module.lower() == "voxin":
|
||||
# For Voxin, store voice name with language
|
||||
voice_data = self._process_voxin_voice(line)
|
||||
if voice_data:
|
||||
voices.append(voice_data)
|
||||
else:
|
||||
# For other modules, extract first field (voice name)
|
||||
parts = line.strip().split()
|
||||
if parts:
|
||||
voices.append(parts[0])
|
||||
|
||||
self._voices_cache[module] = voices
|
||||
return voices
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
(f"QuickMenuManager get_module_voices: "
|
||||
f"Error querying voices for {module}: {e}"),
|
||||
debug.DebugLevel.ERROR
|
||||
)
|
||||
|
||||
return []
|
||||
|
||||
def _process_espeak_voice(self, voice_line):
|
||||
"""Process espeak-ng voice format into usable voice name.
|
||||
|
||||
Args:
|
||||
voice_line (str): Raw line from spd-say -L output
|
||||
|
||||
Returns:
|
||||
str: Processed voice name (e.g., 'en-us' or 'en-us+f3')
|
||||
"""
|
||||
parts = [p for p in voice_line.split() if p]
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
lang_code = parts[-2].lower()
|
||||
variant = parts[-1].lower()
|
||||
return (f"{lang_code}+{variant}"
|
||||
if variant and variant != "none" else lang_code)
|
||||
|
||||
def _process_voxin_voice(self, voice_line):
|
||||
"""Process Voxin voice format with language information.
|
||||
|
||||
Args:
|
||||
voice_line (str): Raw line from spd-say -o voxin -L output
|
||||
Format: NAME LANGUAGE VARIANT
|
||||
|
||||
Returns:
|
||||
str: Voice name with language encoded (e.g., 'daniel-embedded-high|en-GB')
|
||||
"""
|
||||
parts = [p for p in voice_line.split() if p]
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
voice_name = parts[0]
|
||||
language = parts[1]
|
||||
# Encode language with voice for later extraction
|
||||
return f"{voice_name}|{language}"
|
||||
|
||||
def _select_default_voice(self, voices):
|
||||
"""Select a sensible default voice from list, preferring user's
|
||||
language.
|
||||
|
||||
Args:
|
||||
voices (list): List of available voice names
|
||||
|
||||
Returns:
|
||||
str: Selected default voice (matches user language if possible)
|
||||
"""
|
||||
if not voices:
|
||||
return ""
|
||||
|
||||
# Get current voice to preserve language preference
|
||||
current_voice = self.env["runtime"]["SettingsManager"].get_setting(
|
||||
"speech", "voice"
|
||||
)
|
||||
|
||||
# Get configured language from settings
|
||||
configured_lang = self.env["runtime"]["SettingsManager"].get_setting(
|
||||
"speech", "language"
|
||||
)
|
||||
|
||||
# Extract language code from current voice if available
|
||||
current_lang = None
|
||||
if current_voice:
|
||||
# Extract language code (e.g., 'en-gb' from 'en-gb+male')
|
||||
current_lang = current_voice.split('+')[0].lower()
|
||||
|
||||
# Build preference list: current language, configured language, English
|
||||
preferences = []
|
||||
if current_lang:
|
||||
preferences.append(current_lang)
|
||||
if configured_lang:
|
||||
preferences.append(configured_lang.lower())
|
||||
preferences.extend(['en-gb', 'en-us', 'en'])
|
||||
|
||||
# Remove duplicates while preserving order
|
||||
seen = set()
|
||||
preferences = [x for x in preferences
|
||||
if not (x in seen or seen.add(x))]
|
||||
|
||||
# Try exact matches for preferred languages
|
||||
for pref in preferences:
|
||||
for voice in voices:
|
||||
# Extract language if voice is in "name|lang" format
|
||||
voice_to_check = voice
|
||||
if "|" in voice:
|
||||
_, voice_lang = voice.split("|", 1)
|
||||
voice_to_check = voice_lang
|
||||
if voice_to_check.lower() == pref:
|
||||
return voice
|
||||
|
||||
# Try voices starting with preferred language codes
|
||||
for pref in preferences:
|
||||
for voice in voices:
|
||||
# Extract language if voice is in "name|lang" format
|
||||
voice_to_check = voice
|
||||
if "|" in voice:
|
||||
_, voice_lang = voice.split("|", 1)
|
||||
voice_to_check = voice_lang
|
||||
if voice_to_check.lower().startswith(pref):
|
||||
return voice
|
||||
|
||||
# Fall back to first available voice
|
||||
return voices[0]
|
||||
|
||||
def invalidate_speech_cache(self):
|
||||
"""Clear cached module and voice data."""
|
||||
self._modules_cache = None
|
||||
self._voices_cache = {}
|
||||
self._cache_timestamp = 0
|
||||
|
||||
|
||||
class QuickMenuManager(SpeechHelperMixin):
|
||||
def __init__(self):
|
||||
SpeechHelperMixin.__init__(self)
|
||||
self.position = 0
|
||||
self.quickMenu = []
|
||||
self.settings = settings_data
|
||||
|
||||
def initialize(self, environment):
|
||||
self.env = environment
|
||||
self.load_menu(
|
||||
self.env["runtime"]["SettingsManager"].get_setting(
|
||||
"menu", "quickMenu"
|
||||
)
|
||||
|
||||
# Load base menu from config
|
||||
menu_string = self.env["runtime"]["SettingsManager"].get_setting(
|
||||
"menu", "quickMenu"
|
||||
)
|
||||
|
||||
# Dynamically add speech-dispatcher specific items
|
||||
if self._is_speechd_driver_active():
|
||||
menu_string = self._add_speechd_menu_items(menu_string)
|
||||
|
||||
self.load_menu(menu_string)
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
def _is_speechd_driver_active(self):
|
||||
"""Check if speechdDriver is currently active.
|
||||
|
||||
Returns:
|
||||
bool: True if speech driver is speechdDriver
|
||||
"""
|
||||
try:
|
||||
driver = self.env["runtime"]["SettingsManager"].get_setting(
|
||||
"speech", "driver"
|
||||
)
|
||||
return driver == "speechdDriver"
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
(f"QuickMenuManager _is_speechd_driver_active: "
|
||||
f"Error checking driver: {e}"),
|
||||
debug.DebugLevel.ERROR
|
||||
)
|
||||
return False
|
||||
|
||||
def _add_speechd_menu_items(self, menu_string):
|
||||
"""Add speech-dispatcher module and voice to quick menu.
|
||||
|
||||
Args:
|
||||
menu_string (str): Existing menu string from config
|
||||
|
||||
Returns:
|
||||
str: Updated menu string with module and voice added
|
||||
"""
|
||||
if not menu_string:
|
||||
return "speech#module;speech#voice"
|
||||
|
||||
# Check if already present (user manually added)
|
||||
if "speech#module" in menu_string or "speech#voice" in menu_string:
|
||||
return menu_string
|
||||
|
||||
# Add module and voice after existing items
|
||||
return f"{menu_string};speech#module;speech#voice"
|
||||
|
||||
def load_menu(self, menuString):
|
||||
self.position = 0
|
||||
self.quickMenu = []
|
||||
@@ -86,8 +346,15 @@ class QuickMenuManager:
|
||||
|
||||
try:
|
||||
if isinstance(self.settings[section][setting], str):
|
||||
value = str(value_string)
|
||||
return False
|
||||
# Check for special string cycling cases
|
||||
if section == "speech" and setting == "module":
|
||||
return self.cycle_speech_module("next")
|
||||
elif section == "speech" and setting == "voice":
|
||||
return self.cycle_speech_voice("next")
|
||||
else:
|
||||
# Generic strings not supported for cycling
|
||||
value = str(value_string)
|
||||
return False
|
||||
elif isinstance(self.settings[section][setting], bool):
|
||||
if value_string not in ["True", "False"]:
|
||||
return False
|
||||
@@ -132,8 +399,15 @@ class QuickMenuManager:
|
||||
return False
|
||||
try:
|
||||
if isinstance(self.settings[section][setting], str):
|
||||
value = str(value_string)
|
||||
return False
|
||||
# Check for special string cycling cases
|
||||
if section == "speech" and setting == "module":
|
||||
return self.cycle_speech_module("prev")
|
||||
elif section == "speech" and setting == "voice":
|
||||
return self.cycle_speech_voice("prev")
|
||||
else:
|
||||
# Generic strings not supported for cycling
|
||||
value = str(value_string)
|
||||
return False
|
||||
elif isinstance(self.settings[section][setting], bool):
|
||||
if value_string not in ["True", "False"]:
|
||||
return False
|
||||
@@ -161,6 +435,199 @@ class QuickMenuManager:
|
||||
return False
|
||||
return True
|
||||
|
||||
def cycle_speech_module(self, direction):
|
||||
"""Cycle to next/previous speech-dispatcher module.
|
||||
|
||||
Args:
|
||||
direction (str): 'next' or 'prev'
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Get available modules
|
||||
modules = self.get_speechd_modules()
|
||||
if not modules:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"No modules available", interrupt=True
|
||||
)
|
||||
return False
|
||||
|
||||
# Get current module
|
||||
current_module = self.env["runtime"]["SettingsManager"].get_setting(
|
||||
"speech", "module"
|
||||
)
|
||||
|
||||
# Find current index
|
||||
try:
|
||||
current_index = (modules.index(current_module)
|
||||
if current_module else 0)
|
||||
except ValueError:
|
||||
current_index = 0
|
||||
|
||||
# Cycle to next/previous
|
||||
if direction == "next":
|
||||
new_index = (current_index + 1) % len(modules)
|
||||
else: # prev
|
||||
new_index = (current_index - 1) % len(modules)
|
||||
|
||||
new_module = modules[new_index]
|
||||
|
||||
# Update setting (runtime only)
|
||||
self.env["runtime"]["SettingsManager"].set_setting(
|
||||
"speech", "module", new_module
|
||||
)
|
||||
|
||||
# Select sensible default voice for new module
|
||||
voices = self.get_module_voices(new_module)
|
||||
if voices:
|
||||
default_voice = self._select_default_voice(voices)
|
||||
|
||||
# Parse voice name and language for modules like Voxin
|
||||
voice_name = default_voice
|
||||
voice_lang = None
|
||||
if "|" in default_voice:
|
||||
voice_name, voice_lang = default_voice.split("|", 1)
|
||||
|
||||
self.env["runtime"]["SettingsManager"].set_setting(
|
||||
"speech", "voice", voice_name
|
||||
)
|
||||
|
||||
# Apply voice to speech driver immediately
|
||||
if "SpeechDriver" in self.env["runtime"]:
|
||||
try:
|
||||
self.env["runtime"]["SpeechDriver"].set_module(
|
||||
new_module
|
||||
)
|
||||
# Set language first if available
|
||||
if voice_lang:
|
||||
self.env["runtime"]["SpeechDriver"].set_language(
|
||||
voice_lang
|
||||
)
|
||||
# Then set voice
|
||||
self.env["runtime"]["SpeechDriver"].set_voice(
|
||||
voice_name
|
||||
)
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
(f"QuickMenuManager cycle_speech_module: "
|
||||
f"Error applying voice: {e}"),
|
||||
debug.DebugLevel.ERROR
|
||||
)
|
||||
|
||||
# Announce new module
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
new_module, interrupt=True
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"QuickMenuManager cycle_speech_module: Error: {e}",
|
||||
debug.DebugLevel.ERROR
|
||||
)
|
||||
return False
|
||||
|
||||
def cycle_speech_voice(self, direction):
|
||||
"""Cycle to next/previous voice for current module.
|
||||
|
||||
Args:
|
||||
direction (str): 'next' or 'prev'
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Get current module
|
||||
current_module = self.env["runtime"]["SettingsManager"].get_setting(
|
||||
"speech", "module"
|
||||
)
|
||||
|
||||
if not current_module:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
"No module selected", interrupt=True
|
||||
)
|
||||
return False
|
||||
|
||||
# Get available voices for this module
|
||||
voices = self.get_module_voices(current_module)
|
||||
if not voices:
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
f"No voices for module {current_module}", interrupt=True
|
||||
)
|
||||
return False
|
||||
|
||||
# Get current voice
|
||||
current_voice = self.env["runtime"]["SettingsManager"].get_setting(
|
||||
"speech", "voice"
|
||||
)
|
||||
|
||||
# Find current index (handle Voxin voice|language format)
|
||||
current_index = 0
|
||||
if current_voice:
|
||||
try:
|
||||
# Try exact match first
|
||||
current_index = voices.index(current_voice)
|
||||
except ValueError:
|
||||
# For Voxin, compare just the voice name part
|
||||
for i, voice in enumerate(voices):
|
||||
voice_name = voice.split("|")[0] if "|" in voice else voice
|
||||
if voice_name == current_voice:
|
||||
current_index = i
|
||||
break
|
||||
|
||||
# Cycle to next/previous
|
||||
if direction == "next":
|
||||
new_index = (current_index + 1) % len(voices)
|
||||
else: # prev
|
||||
new_index = (current_index - 1) % len(voices)
|
||||
|
||||
new_voice = voices[new_index]
|
||||
|
||||
# Parse voice name and language for modules like Voxin
|
||||
voice_name = new_voice
|
||||
voice_lang = None
|
||||
if "|" in new_voice:
|
||||
# Format: "voicename|language" (e.g., "daniel-embedded-high|en-GB")
|
||||
voice_name, voice_lang = new_voice.split("|", 1)
|
||||
|
||||
# Update setting (runtime only) - store the voice name only
|
||||
self.env["runtime"]["SettingsManager"].set_setting(
|
||||
"speech", "voice", voice_name
|
||||
)
|
||||
|
||||
# Apply voice to speech driver immediately
|
||||
if "SpeechDriver" in self.env["runtime"]:
|
||||
try:
|
||||
# Set language first if available
|
||||
if voice_lang:
|
||||
self.env["runtime"]["SpeechDriver"].set_language(
|
||||
voice_lang
|
||||
)
|
||||
# Then set voice
|
||||
self.env["runtime"]["SpeechDriver"].set_voice(voice_name)
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
(f"QuickMenuManager cycle_speech_voice: "
|
||||
f"Error applying voice: {e}"),
|
||||
debug.DebugLevel.ERROR
|
||||
)
|
||||
|
||||
# Announce new voice (voice name only, not language)
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
voice_name, interrupt=True
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
f"QuickMenuManager cycle_speech_voice: Error: {e}",
|
||||
debug.DebugLevel.ERROR
|
||||
)
|
||||
return False
|
||||
|
||||
def get_current_entry(self):
|
||||
if len(self.quickMenu) == 0:
|
||||
return ""
|
||||
|
||||
@@ -217,6 +217,16 @@ class VmenuManager:
|
||||
except Exception as e:
|
||||
print(f"Error adding dynamic voice menus: {e}")
|
||||
|
||||
# Add dynamic keyboard layout menus
|
||||
try:
|
||||
from fenrirscreenreader.core.dynamicKeyboardLayoutMenu import (
|
||||
add_dynamic_keyboard_layout_menus,
|
||||
)
|
||||
|
||||
add_dynamic_keyboard_layout_menus(self)
|
||||
except Exception as e:
|
||||
print(f"Error adding dynamic keyboard layout menus: {e}")
|
||||
|
||||
# index still valid?
|
||||
if self.curr_index is not None:
|
||||
try:
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
version = "2025.10.17"
|
||||
version = "2025.12.03"
|
||||
code_name = "master"
|
||||
|
||||
@@ -20,14 +20,17 @@ class driver(remoteDriver):
|
||||
|
||||
def initialize(self, environment):
|
||||
self.env = environment
|
||||
# Use threading instead of multiprocessing to avoid pickle issues
|
||||
# with self.env (which contains unpicklable file handles)
|
||||
self.env["runtime"]["ProcessManager"].add_custom_event_thread(
|
||||
self.watch_dog, multiprocess=True
|
||||
self.watch_dog, multiprocess=False
|
||||
)
|
||||
|
||||
def watch_dog(self, active, event_queue):
|
||||
# echo "command say this is a test" | nc localhost 22447
|
||||
self.fenrirSock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.fenrirSock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
self.fenrirSock.settimeout(5.0) # Prevent hanging on slow clients
|
||||
self.host = "127.0.0.1"
|
||||
self.port = self.env["runtime"]["SettingsManager"].get_setting_as_int(
|
||||
"remote", "port"
|
||||
@@ -43,33 +46,41 @@ class driver(remoteDriver):
|
||||
continue
|
||||
if self.fenrirSock in r:
|
||||
client_sock, client_addr = self.fenrirSock.accept()
|
||||
try:
|
||||
rawdata = client_sock.recv(8129)
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
"tcpDriver watch_dog: Error receiving data from client: "
|
||||
+ str(e),
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
try:
|
||||
data = rawdata.decode("utf-8").rstrip().lstrip()
|
||||
event_queue.put(
|
||||
{"Type": FenrirEventType.remote_incomming, "data": data}
|
||||
)
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
"tcpDriver watch_dog: Error decoding/queuing data: "
|
||||
+ str(e),
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
try:
|
||||
client_sock.close()
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
"tcpDriver watch_dog: Error closing client socket: "
|
||||
+ str(e),
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
# Ensure client socket is always closed to prevent resource
|
||||
# leaks
|
||||
try:
|
||||
try:
|
||||
rawdata = client_sock.recv(8129)
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
"tcpDriver watch_dog: Error receiving data from "
|
||||
"client: "
|
||||
+ str(e),
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
rawdata = b"" # Set default empty data if recv fails
|
||||
|
||||
try:
|
||||
data = rawdata.decode("utf-8").rstrip().lstrip()
|
||||
event_queue.put(
|
||||
{"Type": FenrirEventType.remote_incomming, "data": data}
|
||||
)
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
"tcpDriver watch_dog: Error decoding/queuing data: "
|
||||
+ str(e),
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
finally:
|
||||
# Always close client socket, even if data processing fails
|
||||
try:
|
||||
client_sock.close()
|
||||
except Exception as e:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
"tcpDriver watch_dog: Error closing client socket: "
|
||||
+ str(e),
|
||||
debug.DebugLevel.ERROR,
|
||||
)
|
||||
if self.fenrirSock:
|
||||
self.fenrirSock.close()
|
||||
self.fenrirSock = None
|
||||
|
||||
@@ -20,8 +20,10 @@ class driver(remoteDriver):
|
||||
|
||||
def initialize(self, environment):
|
||||
self.env = environment
|
||||
# Use threading instead of multiprocessing to avoid pickle issues
|
||||
# with self.env (which contains unpicklable file handles)
|
||||
self.env["runtime"]["ProcessManager"].add_custom_event_thread(
|
||||
self.watch_dog, multiprocess=True
|
||||
self.watch_dog, multiprocess=False
|
||||
)
|
||||
|
||||
def watch_dog(self, active, event_queue):
|
||||
|
||||
@@ -126,8 +126,10 @@ class driver(screenDriver):
|
||||
"default", # fontfamily
|
||||
]
|
||||
) # end attribute )
|
||||
# Use threading instead of multiprocessing to avoid pickle issues
|
||||
# with self.env (which contains unpicklable file handles)
|
||||
self.env["runtime"]["ProcessManager"].add_custom_event_thread(
|
||||
self.update_watchdog, multiprocess=True
|
||||
self.update_watchdog, multiprocess=False
|
||||
)
|
||||
|
||||
def get_curr_screen(self):
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
# Pre-Commit Test Integration
|
||||
|
||||
## Overview
|
||||
|
||||
The test suite is now automatically executed as part of the pre-commit hook, ensuring all commits maintain code quality and passing tests.
|
||||
|
||||
## What Happens on Commit
|
||||
|
||||
When you run `git commit`, the pre-commit hook now performs **5 validation steps**:
|
||||
|
||||
```
|
||||
1. Python syntax validation (all files)
|
||||
2. Common issue detection (modified files)
|
||||
3. Core module import testing
|
||||
4. Test suite execution (37 tests) ← NEW!
|
||||
5. Secret/credential detection
|
||||
```
|
||||
|
||||
## Test Execution
|
||||
|
||||
```bash
|
||||
4. Running test suite...
|
||||
============================= test session starts ==============================
|
||||
platform linux -- Python 3.13.7, pytest-8.4.2, pluggy-1.6.0
|
||||
rootdir: /home/storm/git/fenrir
|
||||
configfile: pytest.ini
|
||||
collected 37 items
|
||||
|
||||
tests/integration/test_remote_control.py .................... [ 54%]
|
||||
tests/unit/test_settings_validation.py ................. [100%]
|
||||
|
||||
============================== 37 passed in 0.44s ==============================
|
||||
✓ All tests passed
|
||||
```
|
||||
|
||||
**Performance:** Tests complete in **< 1 second**, adding minimal overhead to the commit process.
|
||||
|
||||
## Behavior
|
||||
|
||||
### ✅ Tests Pass - Commit Allowed
|
||||
```bash
|
||||
$ git commit -m "Add new feature"
|
||||
|
||||
Fenrir Pre-commit Validation
|
||||
==================================
|
||||
1. Validating Python syntax...
|
||||
✓ Syntax validation passed
|
||||
|
||||
2. Checking modified files...
|
||||
✓ No common issues found
|
||||
|
||||
3. Testing core module imports...
|
||||
✓ Core module imports successful
|
||||
|
||||
4. Running test suite...
|
||||
✓ All tests passed (37 passed in 0.44s)
|
||||
|
||||
5. Checking for potential secrets...
|
||||
✓ No potential secrets found
|
||||
|
||||
==================================================
|
||||
✓ All pre-commit validations passed
|
||||
Commit allowed to proceed
|
||||
```
|
||||
|
||||
### ❌ Tests Fail - Commit Blocked
|
||||
```bash
|
||||
$ git commit -m "Broken feature"
|
||||
|
||||
Fenrir Pre-commit Validation
|
||||
==================================
|
||||
[... earlier checks pass ...]
|
||||
|
||||
4. Running test suite...
|
||||
✗ Test suite failed
|
||||
Run: pytest tests/ -v (to see details)
|
||||
|
||||
==================================================
|
||||
✗ Pre-commit validation failed
|
||||
Commit blocked - please fix issues above
|
||||
|
||||
Quick fixes:
|
||||
• Python syntax: python3 tools/validate_syntax.py --fix
|
||||
• Run tests: pytest tests/ -v
|
||||
• Review flagged files manually
|
||||
• Re-run commit after fixes
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
The pre-commit hook is installed via:
|
||||
|
||||
```bash
|
||||
# One-time setup
|
||||
./tools/install_validation_hook.sh
|
||||
|
||||
# Or manually
|
||||
ln -sf ../../tools/pre-commit-hook .git/hooks/pre-commit
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
### Required
|
||||
- Python 3.7+
|
||||
- Git repository
|
||||
|
||||
### Optional but Recommended
|
||||
- `pytest` - For running tests (gracefully skipped if not installed)
|
||||
|
||||
If pytest is not installed, you'll see:
|
||||
```
|
||||
4. Running test suite...
|
||||
⚠ pytest not installed - skipping tests
|
||||
Install with: pip install pytest
|
||||
Or full test suite: pip install -r tests/requirements.txt
|
||||
```
|
||||
|
||||
The commit will still proceed, but tests won't run.
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Catch bugs early** - Tests run before code reaches the repository
|
||||
2. **Maintain quality** - Broken code can't be committed
|
||||
3. **Fast feedback** - Tests complete in < 1 second
|
||||
4. **Zero overhead** - Gracefully degrades if pytest isn't installed
|
||||
5. **Confidence** - Know that all commits pass tests
|
||||
|
||||
## Bypassing the Hook
|
||||
|
||||
**Not recommended**, but if you need to commit without running tests:
|
||||
|
||||
```bash
|
||||
# Skip all pre-commit checks (use with caution!)
|
||||
git commit --no-verify -m "Emergency hotfix"
|
||||
```
|
||||
|
||||
**Warning:** Only use `--no-verify` for legitimate emergencies. Bypassing tests defeats their purpose.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tests fail on commit but pass manually
|
||||
|
||||
```bash
|
||||
# Check environment matches
|
||||
cd /home/storm/git/fenrir
|
||||
pytest tests/ -v
|
||||
|
||||
# Verify PYTHONPATH
|
||||
echo $PYTHONPATH
|
||||
```
|
||||
|
||||
### Hook doesn't run tests
|
||||
|
||||
```bash
|
||||
# Check pytest is installed
|
||||
pytest --version
|
||||
|
||||
# Install if missing
|
||||
pip install pytest
|
||||
|
||||
# Or full test dependencies
|
||||
pip install -r tests/requirements.txt
|
||||
```
|
||||
|
||||
### Hook takes too long
|
||||
|
||||
The test suite is designed to run in < 1 second. If it's slower:
|
||||
|
||||
```bash
|
||||
# Check test timing
|
||||
pytest tests/ --durations=10
|
||||
|
||||
# Look for slow tests (should all be < 100ms)
|
||||
```
|
||||
|
||||
## Statistics
|
||||
|
||||
- **Tests Executed:** 37
|
||||
- **Execution Time:** 0.44 seconds
|
||||
- **Pass Rate:** 100%
|
||||
- **Coverage:** Unit tests (17) + Integration tests (20)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Possible additions to pre-commit validation:
|
||||
|
||||
- **Coverage threshold** - Require minimum test coverage percentage
|
||||
- **Performance regression** - Warn if tests get slower
|
||||
- **Incremental testing** - Only run tests for modified code
|
||||
- **Parallel execution** - Use `pytest -n auto` (requires pytest-xdist)
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `tests/README.md` - Test strategy overview
|
||||
- `tests/TESTING_GUIDE.md` - Comprehensive testing guide
|
||||
- `TESTING_SUMMARY.md` - Test implementation summary
|
||||
- `tools/pre-commit-hook` - Pre-commit hook source code
|
||||
|
||||
## Summary
|
||||
|
||||
Adding tests to the pre-commit hook ensures:
|
||||
- ✅ All commits have passing tests
|
||||
- ✅ Regressions are caught immediately
|
||||
- ✅ Code quality is maintained
|
||||
- ✅ Minimal performance impact (< 1 second)
|
||||
- ✅ Graceful degradation without pytest
|
||||
|
||||
**Result:** Higher code quality with virtually zero developer friction.
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
# Fenrir Test Suite
|
||||
|
||||
This directory contains automated tests for the Fenrir screen reader. Testing a screen reader that requires root access and hardware interaction presents unique challenges, so we use a multi-layered testing strategy.
|
||||
|
||||
## Test Strategy
|
||||
|
||||
### 1. Unit Tests (No Root Required)
|
||||
Test individual components in isolation without requiring hardware access:
|
||||
- **Core Managers**: Logic testing without driver dependencies
|
||||
- **Utility Functions**: String manipulation, cursor calculations, text processing
|
||||
- **Settings Validation**: Configuration parsing and validation
|
||||
- **Remote Command Parsing**: Command/setting string processing
|
||||
|
||||
### 2. Integration Tests (No Root Required)
|
||||
Test component interactions using mocked drivers:
|
||||
- **Remote Control**: Unix socket and TCP communication
|
||||
- **Command System**: Command loading and execution flow
|
||||
- **Event Processing**: Event queue and dispatching
|
||||
- **Settings Manager**: Configuration loading and runtime changes
|
||||
|
||||
### 3. Driver Tests (Root Required, Optional)
|
||||
Test actual hardware interaction (only run in CI or explicitly by developers):
|
||||
- **VCSA Driver**: Screen reading on real TTY
|
||||
- **Evdev Driver**: Keyboard input capture
|
||||
- **Speech Drivers**: TTS output validation
|
||||
- **Sound Drivers**: Audio playback testing
|
||||
|
||||
### 4. End-to-End Tests (Root Required, Manual)
|
||||
Real-world usage scenarios run manually by developers:
|
||||
- Full Fenrir startup/shutdown cycle
|
||||
- Remote control from external scripts
|
||||
- VMenu navigation and command execution
|
||||
- Speech output for screen changes
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Install test dependencies
|
||||
pip install pytest pytest-cov pytest-mock pytest-timeout
|
||||
|
||||
# Run all unit and integration tests (no root required)
|
||||
pytest tests/
|
||||
|
||||
# Run with coverage report
|
||||
pytest tests/ --cov=src/fenrirscreenreader --cov-report=html
|
||||
|
||||
# Run only unit tests
|
||||
pytest tests/unit/
|
||||
|
||||
# Run only integration tests
|
||||
pytest tests/integration/
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/unit/test_settings_manager.py
|
||||
|
||||
# Run with verbose output
|
||||
pytest tests/ -v
|
||||
|
||||
# Run driver tests (requires root)
|
||||
sudo pytest tests/drivers/ -v
|
||||
```
|
||||
|
||||
## Test Organization
|
||||
|
||||
```
|
||||
tests/
|
||||
├── README.md # This file
|
||||
├── conftest.py # Shared pytest fixtures
|
||||
├── unit/ # Unit tests (fast, no mocking needed)
|
||||
│ ├── test_settings_validation.py
|
||||
│ ├── test_cursor_utils.py
|
||||
│ ├── test_text_utils.py
|
||||
│ └── test_remote_parsing.py
|
||||
├── integration/ # Integration tests (require mocking)
|
||||
│ ├── test_remote_control.py
|
||||
│ ├── test_command_manager.py
|
||||
│ ├── test_event_manager.py
|
||||
│ └── test_settings_manager.py
|
||||
└── drivers/ # Driver tests (require root)
|
||||
├── test_vcsa_driver.py
|
||||
├── test_evdev_driver.py
|
||||
└── test_speech_drivers.py
|
||||
```
|
||||
|
||||
## Writing Tests
|
||||
|
||||
### Example Unit Test
|
||||
```python
|
||||
def test_speech_rate_validation():
|
||||
"""Test that speech rate validation rejects out-of-range values."""
|
||||
manager = SettingsManager()
|
||||
|
||||
# Valid values should pass
|
||||
manager._validate_setting_value('speech', 'rate', 0.5)
|
||||
manager._validate_setting_value('speech', 'rate', 3.0)
|
||||
|
||||
# Invalid values should raise ValueError
|
||||
with pytest.raises(ValueError):
|
||||
manager._validate_setting_value('speech', 'rate', -1.0)
|
||||
with pytest.raises(ValueError):
|
||||
manager._validate_setting_value('speech', 'rate', 10.0)
|
||||
```
|
||||
|
||||
### Example Integration Test
|
||||
```python
|
||||
def test_remote_control_unix_socket(tmp_path):
|
||||
"""Test Unix socket remote control accepts commands."""
|
||||
socket_path = tmp_path / "test.sock"
|
||||
|
||||
# Start mock remote driver
|
||||
driver = MockUnixDriver(socket_path)
|
||||
|
||||
# Send command
|
||||
send_remote_command(socket_path, "command say Hello")
|
||||
|
||||
# Verify command was received
|
||||
assert driver.received_commands[-1] == "command say Hello"
|
||||
```
|
||||
|
||||
## Test Coverage Goals
|
||||
|
||||
- **Unit Tests**: 80%+ coverage on utility functions and validation logic
|
||||
- **Integration Tests**: 60%+ coverage on core managers and command system
|
||||
- **Overall**: 70%+ coverage on non-driver code
|
||||
|
||||
Driver code is excluded from coverage metrics as it requires hardware interaction.
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
Tests are designed to run in CI environments without root access:
|
||||
- Unit and integration tests run on every commit
|
||||
- Driver tests are skipped in CI (require actual hardware)
|
||||
- Coverage reports are generated and tracked over time
|
||||
|
||||
## Test Principles
|
||||
|
||||
1. **No Root by Default**: Most tests should run without elevated privileges
|
||||
2. **Fast Execution**: Unit tests complete in <1 second each
|
||||
3. **Isolated**: Tests don't depend on each other or external state
|
||||
4. **Deterministic**: Tests produce same results every run
|
||||
5. **Documented**: Each test has a clear docstring explaining what it tests
|
||||
6. **Realistic Mocks**: Mocked components behave like real ones
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- **Performance Tests**: Measure input-to-speech latency
|
||||
- **Stress Tests**: Rapid event processing, memory leak detection
|
||||
- **Accessibility Tests**: Verify all features work without vision
|
||||
- **Compatibility Tests**: Test across different Linux distributions
|
||||
@@ -0,0 +1,430 @@
|
||||
# Fenrir Testing Guide
|
||||
|
||||
Complete guide to running and writing tests for the Fenrir screen reader.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install Test Dependencies
|
||||
|
||||
```bash
|
||||
# Install test requirements
|
||||
pip install -r tests/requirements.txt
|
||||
|
||||
# Or install individually
|
||||
pip install pytest pytest-cov pytest-mock pytest-timeout
|
||||
```
|
||||
|
||||
### 2. Run Tests
|
||||
|
||||
```bash
|
||||
# Run all tests (unit + integration)
|
||||
pytest tests/
|
||||
|
||||
# Run only unit tests (fastest)
|
||||
pytest tests/unit/ -v
|
||||
|
||||
# Run only integration tests
|
||||
pytest tests/integration/ -v
|
||||
|
||||
# Run with coverage report
|
||||
pytest tests/ --cov=src/fenrirscreenreader --cov-report=html
|
||||
# Then open htmlcov/index.html in a browser
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/unit/test_settings_validation.py -v
|
||||
|
||||
# Run specific test class
|
||||
pytest tests/unit/test_settings_validation.py::TestSpeechSettingsValidation -v
|
||||
|
||||
# Run specific test
|
||||
pytest tests/unit/test_settings_validation.py::TestSpeechSettingsValidation::test_speech_rate_valid_range -v
|
||||
```
|
||||
|
||||
### 3. Useful Test Options
|
||||
|
||||
```bash
|
||||
# Stop on first failure
|
||||
pytest tests/ -x
|
||||
|
||||
# Show test output (print statements, logging)
|
||||
pytest tests/ -s
|
||||
|
||||
# Run tests in parallel (faster, requires: pip install pytest-xdist)
|
||||
pytest tests/ -n auto
|
||||
|
||||
# Show slowest 10 tests
|
||||
pytest tests/ --durations=10
|
||||
|
||||
# Run only tests matching a keyword
|
||||
pytest tests/ -k "remote"
|
||||
|
||||
# Run tests with specific markers
|
||||
pytest tests/ -m unit # Only unit tests
|
||||
pytest tests/ -m integration # Only integration tests
|
||||
pytest tests/ -m "not slow" # Skip slow tests
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── README.md # Test overview and strategy
|
||||
├── TESTING_GUIDE.md # This file - detailed usage guide
|
||||
├── requirements.txt # Test dependencies
|
||||
├── conftest.py # Shared fixtures and pytest config
|
||||
├── unit/ # Unit tests (fast, isolated)
|
||||
│ ├── __init__.py
|
||||
│ ├── test_settings_validation.py # Settings validation tests
|
||||
│ ├── test_cursor_utils.py # Cursor calculation tests
|
||||
│ └── test_text_utils.py # Text processing tests
|
||||
├── integration/ # Integration tests (require mocking)
|
||||
│ ├── __init__.py
|
||||
│ ├── test_remote_control.py # Remote control functionality
|
||||
│ ├── test_command_manager.py # Command loading/execution
|
||||
│ └── test_event_manager.py # Event queue processing
|
||||
└── drivers/ # Driver tests (require root)
|
||||
├── __init__.py
|
||||
├── test_vcsa_driver.py # TTY screen reading
|
||||
└── test_evdev_driver.py # Keyboard input capture
|
||||
```
|
||||
|
||||
## Writing New Tests
|
||||
|
||||
### Unit Test Example
|
||||
|
||||
```python
|
||||
"""tests/unit/test_my_feature.py"""
|
||||
import pytest
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_speech_rate_calculation():
|
||||
"""Test that speech rate is calculated correctly."""
|
||||
rate = calculate_speech_rate(0.5)
|
||||
assert 0.0 <= rate <= 1.0
|
||||
assert rate == 0.5
|
||||
```
|
||||
|
||||
### Integration Test Example
|
||||
|
||||
```python
|
||||
"""tests/integration/test_my_integration.py"""
|
||||
import pytest
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_remote_command_execution(mock_environment):
|
||||
"""Test remote command execution flow."""
|
||||
manager = RemoteManager()
|
||||
manager.initialize(mock_environment)
|
||||
|
||||
result = manager.handle_command_execution_with_response("say test")
|
||||
|
||||
assert result["success"] is True
|
||||
mock_environment["runtime"]["OutputManager"].speak_text.assert_called_once()
|
||||
```
|
||||
|
||||
### Using Fixtures
|
||||
|
||||
Common fixtures are defined in `conftest.py`:
|
||||
|
||||
```python
|
||||
def test_with_mock_environment(mock_environment):
|
||||
"""Use the shared mock environment fixture."""
|
||||
# mock_environment provides mocked runtime managers
|
||||
assert "runtime" in mock_environment
|
||||
assert "DebugManager" in mock_environment["runtime"]
|
||||
|
||||
def test_with_temp_config(temp_config_file):
|
||||
"""Use a temporary config file."""
|
||||
# temp_config_file is a Path object to a valid test config
|
||||
assert temp_config_file.exists()
|
||||
content = temp_config_file.read_text()
|
||||
assert "[speech]" in content
|
||||
```
|
||||
|
||||
## Test Markers
|
||||
|
||||
Tests can be marked to categorize them:
|
||||
|
||||
```python
|
||||
@pytest.mark.unit # Fast, isolated unit test
|
||||
@pytest.mark.integration # Integration test with mocking
|
||||
@pytest.mark.driver # Requires root access (skipped by default)
|
||||
@pytest.mark.slow # Takes > 1 second
|
||||
@pytest.mark.remote # Tests remote control functionality
|
||||
@pytest.mark.settings # Tests settings/configuration
|
||||
@pytest.mark.commands # Tests command system
|
||||
@pytest.mark.vmenu # Tests VMenu system
|
||||
```
|
||||
|
||||
Run tests by marker:
|
||||
```bash
|
||||
pytest tests/ -m unit # Only unit tests
|
||||
pytest tests/ -m "unit or integration" # Unit and integration
|
||||
pytest tests/ -m "not slow" # Skip slow tests
|
||||
```
|
||||
|
||||
## Code Coverage
|
||||
|
||||
### View Coverage Report
|
||||
|
||||
```bash
|
||||
# Generate HTML coverage report
|
||||
pytest tests/ --cov=src/fenrirscreenreader --cov-report=html
|
||||
|
||||
# Open report in browser
|
||||
firefox htmlcov/index.html # Or your preferred browser
|
||||
|
||||
# Terminal coverage report
|
||||
pytest tests/ --cov=src/fenrirscreenreader --cov-report=term-missing
|
||||
```
|
||||
|
||||
### Coverage Goals
|
||||
|
||||
- **Unit Tests**: 80%+ coverage on utility functions and validation logic
|
||||
- **Integration Tests**: 60%+ coverage on core managers
|
||||
- **Overall**: 70%+ coverage on non-driver code
|
||||
|
||||
Driver code is excluded from coverage as it requires hardware interaction.
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
### 1. Test One Thing
|
||||
|
||||
```python
|
||||
# Good - tests one specific behavior
|
||||
def test_speech_rate_rejects_negative():
|
||||
with pytest.raises(ValueError):
|
||||
validate_rate(-1.0)
|
||||
|
||||
# Bad - tests multiple unrelated things
|
||||
def test_speech_settings():
|
||||
validate_rate(0.5) # Rate validation
|
||||
validate_pitch(1.0) # Pitch validation
|
||||
validate_volume(0.8) # Volume validation
|
||||
```
|
||||
|
||||
### 2. Use Descriptive Names
|
||||
|
||||
```python
|
||||
# Good - clear what's being tested
|
||||
def test_speech_rate_rejects_values_above_three():
|
||||
...
|
||||
|
||||
# Bad - unclear purpose
|
||||
def test_rate():
|
||||
...
|
||||
```
|
||||
|
||||
### 3. Arrange-Act-Assert Pattern
|
||||
|
||||
```python
|
||||
def test_remote_command_parsing():
|
||||
# Arrange - set up test data
|
||||
manager = RemoteManager()
|
||||
command = "say Hello World"
|
||||
|
||||
# Act - execute the code being tested
|
||||
result = manager.parse_command(command)
|
||||
|
||||
# Assert - verify the result
|
||||
assert result["action"] == "say"
|
||||
assert result["text"] == "Hello World"
|
||||
```
|
||||
|
||||
### 4. Mock External Dependencies
|
||||
|
||||
```python
|
||||
def test_clipboard_export(mock_environment, tmp_path):
|
||||
"""Test clipboard export without real file operations."""
|
||||
# Use mock environment instead of real Fenrir runtime
|
||||
manager = RemoteManager()
|
||||
manager.initialize(mock_environment)
|
||||
|
||||
# Use temporary path instead of /tmp
|
||||
clipboard_path = tmp_path / "clipboard"
|
||||
mock_environment["runtime"]["SettingsManager"].get_setting = Mock(
|
||||
return_value=str(clipboard_path)
|
||||
)
|
||||
|
||||
manager.export_clipboard()
|
||||
|
||||
assert clipboard_path.exists()
|
||||
```
|
||||
|
||||
### 5. Test Error Paths
|
||||
|
||||
```python
|
||||
def test_remote_command_handles_invalid_input():
|
||||
"""Test that invalid commands are handled gracefully."""
|
||||
manager = RemoteManager()
|
||||
|
||||
# Test with various invalid inputs
|
||||
result1 = manager.handle_command_execution_with_response("")
|
||||
result2 = manager.handle_command_execution_with_response("invalid")
|
||||
result3 = manager.handle_command_execution_with_response("command unknown")
|
||||
|
||||
# All should return error results, not crash
|
||||
assert all(not r["success"] for r in [result1, result2, result3])
|
||||
```
|
||||
|
||||
## Debugging Tests
|
||||
|
||||
### Run with More Verbosity
|
||||
|
||||
```bash
|
||||
# Show test names and outcomes
|
||||
pytest tests/ -v
|
||||
|
||||
# Show test names, outcomes, and print statements
|
||||
pytest tests/ -v -s
|
||||
|
||||
# Show local variables on failure
|
||||
pytest tests/ --showlocals
|
||||
|
||||
# Show full diff on assertion failures
|
||||
pytest tests/ -vv
|
||||
```
|
||||
|
||||
### Use pytest.set_trace() for Debugging
|
||||
|
||||
```python
|
||||
def test_complex_logic():
|
||||
result = complex_function()
|
||||
pytest.set_trace() # Drop into debugger here
|
||||
assert result == expected
|
||||
```
|
||||
|
||||
### Run Single Test Repeatedly
|
||||
|
||||
```bash
|
||||
# Useful for debugging flaky tests
|
||||
pytest tests/unit/test_my_test.py::test_specific_test --count=100
|
||||
```
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
### GitHub Actions Example
|
||||
|
||||
```yaml
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.9'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r requirements.txt
|
||||
pip install -r tests/requirements.txt
|
||||
- name: Run tests
|
||||
run: pytest tests/ --cov=src/fenrirscreenreader --cov-report=xml
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### ImportError: No module named 'fenrirscreenreader'
|
||||
|
||||
**Solution**: Make sure you're running pytest from the project root, or set PYTHONPATH:
|
||||
```bash
|
||||
export PYTHONPATH="${PYTHONPATH}:$(pwd)/src"
|
||||
pytest tests/
|
||||
```
|
||||
|
||||
### Tests hang or timeout
|
||||
|
||||
**Solution**: Use the timeout decorator or pytest-timeout:
|
||||
```bash
|
||||
pytest tests/ --timeout=30 # Global 30s timeout
|
||||
```
|
||||
|
||||
Or mark specific tests:
|
||||
```python
|
||||
@pytest.mark.timeout(5)
|
||||
def test_that_might_hang():
|
||||
...
|
||||
```
|
||||
|
||||
### Mocks not working as expected
|
||||
|
||||
**Solution**: Check that you're patching the right location:
|
||||
```python
|
||||
# Good - patch where it's used
|
||||
@patch('fenrirscreenreader.core.remoteManager.OutputManager')
|
||||
|
||||
# Bad - patch where it's defined
|
||||
@patch('fenrirscreenreader.core.outputManager.OutputManager')
|
||||
```
|
||||
|
||||
## Advanced Topics
|
||||
|
||||
### Parametrized Tests
|
||||
|
||||
Test multiple inputs with one test:
|
||||
|
||||
```python
|
||||
@pytest.mark.parametrize("rate,expected", [
|
||||
(0.0, True),
|
||||
(1.5, True),
|
||||
(3.0, True),
|
||||
(-1.0, False),
|
||||
(10.0, False),
|
||||
])
|
||||
def test_rate_validation(rate, expected):
|
||||
try:
|
||||
validate_rate(rate)
|
||||
assert expected is True
|
||||
except ValueError:
|
||||
assert expected is False
|
||||
```
|
||||
|
||||
### Test Fixtures with Cleanup
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def temp_fenrir_instance():
|
||||
"""Start a test Fenrir instance."""
|
||||
fenrir = FenrirTestInstance()
|
||||
fenrir.start()
|
||||
|
||||
yield fenrir # Test runs here
|
||||
|
||||
# Cleanup after test
|
||||
fenrir.stop()
|
||||
fenrir.cleanup()
|
||||
```
|
||||
|
||||
### Testing Async Code
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_speech():
|
||||
result = await async_speak("test")
|
||||
assert result.success
|
||||
```
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **Pytest Documentation**: https://docs.pytest.org/
|
||||
- **Fenrir Issues**: https://github.com/chrys87/fenrir/issues
|
||||
- **Test Coverage**: Run with `--cov-report=html` and inspect `htmlcov/index.html`
|
||||
|
||||
## Contributing Tests
|
||||
|
||||
When contributing tests:
|
||||
|
||||
1. **Follow naming conventions**: `test_*.py` for files, `test_*` for functions
|
||||
2. **Add docstrings**: Explain what each test verifies
|
||||
3. **Use appropriate markers**: `@pytest.mark.unit`, `@pytest.mark.integration`, etc.
|
||||
4. **Keep tests fast**: Unit tests should complete in <100ms
|
||||
5. **Test edge cases**: Empty strings, None, negative numbers, etc.
|
||||
6. **Update this guide**: If you add new test patterns or fixtures
|
||||
|
||||
Happy testing! 🧪
|
||||
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
Shared pytest fixtures for Fenrir tests.
|
||||
|
||||
This file contains fixtures and configuration used across all test modules.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
# Add src directory to Python path for imports
|
||||
fenrir_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(fenrir_root / "src"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_environment():
|
||||
"""Create a minimal mock environment for testing.
|
||||
|
||||
Returns a mock environment dict with required runtime managers mocked.
|
||||
This allows testing components without initializing the full Fenrir stack.
|
||||
"""
|
||||
env = {
|
||||
"runtime": {
|
||||
"DebugManager": Mock(write_debug_out=Mock()),
|
||||
"OutputManager": Mock(
|
||||
present_text=Mock(),
|
||||
speak_text=Mock(),
|
||||
interrupt_output=Mock(),
|
||||
),
|
||||
"SettingsManager": Mock(
|
||||
get_setting=Mock(return_value="default"),
|
||||
get_setting_as_int=Mock(return_value=0),
|
||||
get_setting_as_float=Mock(return_value=0.0),
|
||||
get_setting_as_bool=Mock(return_value=True),
|
||||
),
|
||||
"InputManager": Mock(
|
||||
sendKeys=Mock(),
|
||||
handle_device_grab=Mock(),
|
||||
),
|
||||
"ScreenManager": Mock(update_screen_ignored=Mock()),
|
||||
"EventManager": Mock(stop_main_event_loop=Mock()),
|
||||
"MemoryManager": Mock(
|
||||
add_value_to_first_index=Mock(),
|
||||
get_index_list_element=Mock(return_value="test clipboard"),
|
||||
is_index_list_empty=Mock(return_value=False),
|
||||
),
|
||||
"VmenuManager": Mock(
|
||||
set_curr_menu=Mock(),
|
||||
),
|
||||
"CursorManager": Mock(
|
||||
set_window_for_application=Mock(),
|
||||
clear_window_for_application=Mock(),
|
||||
),
|
||||
},
|
||||
"settings": Mock(),
|
||||
"general": {
|
||||
"curr_user": "testuser",
|
||||
},
|
||||
}
|
||||
return env
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_config_file(tmp_path):
|
||||
"""Create a temporary configuration file for testing.
|
||||
|
||||
Returns path to a valid test configuration file.
|
||||
"""
|
||||
config_path = tmp_path / "test_settings.conf"
|
||||
config_content = """[sound]
|
||||
enabled=True
|
||||
driver=gstreamerDriver
|
||||
theme=default
|
||||
volume=0.7
|
||||
|
||||
[speech]
|
||||
enabled=True
|
||||
driver=speechdDriver
|
||||
rate=0.5
|
||||
pitch=0.5
|
||||
volume=1.0
|
||||
autoReadIncoming=True
|
||||
|
||||
[screen]
|
||||
driver=vcsaDriver
|
||||
encoding=auto
|
||||
screenUpdateDelay=0.05
|
||||
|
||||
[keyboard]
|
||||
driver=evdevDriver
|
||||
device=ALL
|
||||
grabDevices=True
|
||||
keyboardLayout=desktop
|
||||
|
||||
[general]
|
||||
debugLevel=2
|
||||
debugMode=File
|
||||
|
||||
[remote]
|
||||
enable=True
|
||||
driver=unixDriver
|
||||
port=22447
|
||||
enableSettingsRemote=True
|
||||
enableCommandRemote=True
|
||||
"""
|
||||
config_path.write_text(config_content)
|
||||
return config_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_socket_path(tmp_path):
|
||||
"""Create a temporary Unix socket path for testing.
|
||||
|
||||
Returns path that can be used for Unix socket testing.
|
||||
"""
|
||||
return tmp_path / "test_fenrir.sock"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_clipboard_file(tmp_path):
|
||||
"""Create a temporary clipboard file for testing.
|
||||
|
||||
Returns path to a temporary file for clipboard operations.
|
||||
"""
|
||||
clipboard_path = tmp_path / "fenrirClipboard"
|
||||
clipboard_path.write_text("")
|
||||
return clipboard_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_screen_data():
|
||||
"""Return sample screen data for testing screen-related functionality.
|
||||
|
||||
Returns dict with screen dimensions and content.
|
||||
"""
|
||||
return {
|
||||
"columns": 80,
|
||||
"lines": 24,
|
||||
"delta": "Hello World",
|
||||
"cursor": {"x": 0, "y": 0},
|
||||
"content": "Sample screen content\nSecond line\nThird line",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_remote_commands():
|
||||
"""Return sample remote control commands for testing.
|
||||
|
||||
Returns list of valid remote commands.
|
||||
"""
|
||||
return [
|
||||
"command say Hello World",
|
||||
"command interrupt",
|
||||
"setting set speech#rate=0.8",
|
||||
"setting set speech#pitch=0.6",
|
||||
"setting set sound#volume=0.5",
|
||||
"setting reset",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def invalid_remote_commands():
|
||||
"""Return invalid remote control commands for testing validation.
|
||||
|
||||
Returns list of commands that should be rejected.
|
||||
"""
|
||||
return [
|
||||
"setting set speech#rate=999", # Out of range
|
||||
"setting set speech#rate=-1", # Negative value
|
||||
"setting set speech#pitch=10", # Out of range
|
||||
"setting set speech#volume=-0.5", # Negative volume
|
||||
"setting set invalid#setting=value", # Invalid section
|
||||
"command unknown_command", # Unknown command
|
||||
]
|
||||
|
||||
|
||||
# Pytest hooks for test session customization
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""Configure pytest with custom settings."""
|
||||
# Add custom markers
|
||||
config.addinivalue_line(
|
||||
"markers", "unit: Unit tests (fast, no mocking required)"
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers", "integration: Integration tests (require mocking)"
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers", "driver: Driver tests (require root access)"
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers", "slow: Tests that take more than 1 second"
|
||||
)
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
"""Modify test collection to skip driver tests unless explicitly run.
|
||||
|
||||
Driver tests require root access and hardware, so skip by default.
|
||||
Run with: pytest --run-driver-tests
|
||||
"""
|
||||
skip_driver = pytest.mark.skip(
|
||||
reason="Driver tests require root access (use --run-driver-tests)"
|
||||
)
|
||||
run_driver_tests = config.getoption("--run-driver-tests", default=False)
|
||||
|
||||
for item in items:
|
||||
if "driver" in item.keywords and not run_driver_tests:
|
||||
item.add_marker(skip_driver)
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
"""Add custom command line options."""
|
||||
parser.addoption(
|
||||
"--run-driver-tests",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Run driver tests that require root access",
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Integration tests for Fenrir screen reader components."""
|
||||
@@ -0,0 +1,342 @@
|
||||
"""
|
||||
Integration tests for remote control functionality.
|
||||
|
||||
Tests the remote control system including Unix socket and TCP communication,
|
||||
command parsing, and settings management.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import socket
|
||||
import time
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from fenrirscreenreader.core.remoteManager import RemoteManager
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.remote
|
||||
class TestRemoteCommandParsing:
|
||||
"""Test remote control command parsing and execution."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Create RemoteManager instance for each test."""
|
||||
self.manager = RemoteManager()
|
||||
|
||||
def test_say_command_parsing(self, mock_environment):
|
||||
"""Test parsing of 'command say' messages."""
|
||||
self.manager.initialize(mock_environment)
|
||||
|
||||
result = self.manager.handle_command_execution_with_response(
|
||||
"say Hello World"
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert "Speaking" in result["message"]
|
||||
mock_environment["runtime"]["OutputManager"].speak_text.assert_called_once_with(
|
||||
"Hello World"
|
||||
)
|
||||
|
||||
def test_interrupt_command(self, mock_environment):
|
||||
"""Test speech interruption command."""
|
||||
self.manager.initialize(mock_environment)
|
||||
|
||||
result = self.manager.handle_command_execution_with_response("interrupt")
|
||||
|
||||
assert result["success"] is True
|
||||
mock_environment["runtime"][
|
||||
"OutputManager"
|
||||
].interrupt_output.assert_called_once()
|
||||
|
||||
def test_setting_change_parsing(self, mock_environment):
|
||||
"""Test parsing of 'setting set' commands."""
|
||||
self.manager.initialize(mock_environment)
|
||||
|
||||
# Mock parse_setting_args to verify it gets called
|
||||
with patch.object(
|
||||
mock_environment["runtime"]["SettingsManager"], "parse_setting_args"
|
||||
) as mock_parse:
|
||||
result = self.manager.handle_settings_change_with_response(
|
||||
"set speech#rate=0.8"
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
mock_parse.assert_called_once_with("speech#rate=0.8")
|
||||
|
||||
def test_clipboard_command(self, mock_environment):
|
||||
"""Test clipboard setting via remote control."""
|
||||
self.manager.initialize(mock_environment)
|
||||
|
||||
result = self.manager.handle_command_execution_with_response(
|
||||
"clipboard Test clipboard content"
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
mock_environment["runtime"][
|
||||
"MemoryManager"
|
||||
].add_value_to_first_index.assert_called_once_with(
|
||||
"clipboardHistory", "Test clipboard content"
|
||||
)
|
||||
|
||||
def test_quit_command(self, mock_environment):
|
||||
"""Test Fenrir shutdown command."""
|
||||
self.manager.initialize(mock_environment)
|
||||
|
||||
result = self.manager.handle_command_execution_with_response(
|
||||
"quitapplication"
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
mock_environment["runtime"][
|
||||
"EventManager"
|
||||
].stop_main_event_loop.assert_called_once()
|
||||
|
||||
def test_unknown_command_rejection(self, mock_environment):
|
||||
"""Test that unknown commands are rejected."""
|
||||
self.manager.initialize(mock_environment)
|
||||
|
||||
result = self.manager.handle_command_execution_with_response(
|
||||
"unknown_command"
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Unknown command" in result["message"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.remote
|
||||
class TestRemoteSettingsControl:
|
||||
"""Test remote control of settings."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Create RemoteManager instance for each test."""
|
||||
self.manager = RemoteManager()
|
||||
|
||||
def test_setting_reset(self, mock_environment):
|
||||
"""Test resetting settings to defaults."""
|
||||
self.manager.initialize(mock_environment)
|
||||
|
||||
with patch.object(
|
||||
mock_environment["runtime"]["SettingsManager"], "reset_setting_arg_dict"
|
||||
) as mock_reset:
|
||||
result = self.manager.handle_settings_change_with_response("reset")
|
||||
|
||||
assert result["success"] is True
|
||||
mock_reset.assert_called_once()
|
||||
|
||||
def test_setting_save(self, mock_environment):
|
||||
"""Test saving settings to file."""
|
||||
self.manager.initialize(mock_environment)
|
||||
|
||||
mock_environment["runtime"]["SettingsManager"].get_settings_file = Mock(
|
||||
return_value="/tmp/test.conf"
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
mock_environment["runtime"]["SettingsManager"], "save_settings"
|
||||
) as mock_save:
|
||||
result = self.manager.handle_settings_change_with_response("save")
|
||||
|
||||
assert result["success"] is True
|
||||
mock_save.assert_called_once()
|
||||
|
||||
def test_settings_remote_disabled(self, mock_environment):
|
||||
"""Test that settings commands are blocked when disabled."""
|
||||
mock_environment["runtime"]["SettingsManager"].get_setting_as_bool = Mock(
|
||||
return_value=False
|
||||
)
|
||||
self.manager.initialize(mock_environment)
|
||||
|
||||
result = self.manager.handle_settings_change_with_response(
|
||||
"set speech#rate=0.5"
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "disabled" in result["message"].lower()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.remote
|
||||
class TestRemoteDataFormat:
|
||||
"""Test remote control data format handling."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Create RemoteManager instance for each test."""
|
||||
self.manager = RemoteManager()
|
||||
|
||||
def test_command_prefix_case_insensitive(self, mock_environment):
|
||||
"""Test that command prefixes are case-insensitive."""
|
||||
self.manager.initialize(mock_environment)
|
||||
|
||||
# All of these should work
|
||||
result1 = self.manager.handle_remote_incomming_with_response(
|
||||
"COMMAND say test"
|
||||
)
|
||||
result2 = self.manager.handle_remote_incomming_with_response(
|
||||
"command say test"
|
||||
)
|
||||
result3 = self.manager.handle_remote_incomming_with_response(
|
||||
"CoMmAnD say test"
|
||||
)
|
||||
|
||||
assert all(r["success"] for r in [result1, result2, result3])
|
||||
|
||||
def test_setting_prefix_case_insensitive(self, mock_environment):
|
||||
"""Test that setting prefixes are case-insensitive."""
|
||||
self.manager.initialize(mock_environment)
|
||||
|
||||
with patch.object(
|
||||
mock_environment["runtime"]["SettingsManager"], "parse_setting_args"
|
||||
):
|
||||
result1 = self.manager.handle_remote_incomming_with_response(
|
||||
"SETTING set speech#rate=0.5"
|
||||
)
|
||||
result2 = self.manager.handle_remote_incomming_with_response(
|
||||
"setting set speech#rate=0.5"
|
||||
)
|
||||
|
||||
assert all(r["success"] for r in [result1, result2])
|
||||
|
||||
def test_empty_data_handling(self, mock_environment):
|
||||
"""Test handling of empty remote data."""
|
||||
self.manager.initialize(mock_environment)
|
||||
|
||||
result = self.manager.handle_remote_incomming_with_response("")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "No data" in result["message"]
|
||||
|
||||
def test_invalid_format_rejection(self, mock_environment):
|
||||
"""Test rejection of invalid command format."""
|
||||
self.manager.initialize(mock_environment)
|
||||
|
||||
result = self.manager.handle_remote_incomming_with_response(
|
||||
"invalid format without prefix"
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Unknown command format" in result["message"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.remote
|
||||
class TestWindowDefinition:
|
||||
"""Test window definition via remote control."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Create RemoteManager instance for each test."""
|
||||
self.manager = RemoteManager()
|
||||
|
||||
def test_define_window_valid_coordinates(self, mock_environment):
|
||||
"""Test defining a window with valid coordinates."""
|
||||
self.manager.initialize(mock_environment)
|
||||
|
||||
result = self.manager.handle_command_execution_with_response(
|
||||
"window 10 5 70 20"
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
mock_environment["runtime"][
|
||||
"CursorManager"
|
||||
].set_window_for_application.assert_called_once()
|
||||
|
||||
# Verify the coordinates were parsed correctly
|
||||
call_args = mock_environment["runtime"][
|
||||
"CursorManager"
|
||||
].set_window_for_application.call_args
|
||||
start, end = call_args[0]
|
||||
assert start == {"x": 10, "y": 5}
|
||||
assert end == {"x": 70, "y": 20}
|
||||
|
||||
def test_define_window_insufficient_coordinates(self, mock_environment):
|
||||
"""Test that window definition with < 4 coordinates is ignored."""
|
||||
self.manager.initialize(mock_environment)
|
||||
|
||||
# Should succeed but not call set_window_for_application
|
||||
result = self.manager.handle_command_execution_with_response("window 10 20")
|
||||
|
||||
assert result["success"] is True
|
||||
mock_environment["runtime"][
|
||||
"CursorManager"
|
||||
].set_window_for_application.assert_not_called()
|
||||
|
||||
def test_reset_window(self, mock_environment):
|
||||
"""Test resetting window to full screen."""
|
||||
self.manager.initialize(mock_environment)
|
||||
|
||||
result = self.manager.handle_command_execution_with_response("resetwindow")
|
||||
|
||||
assert result["success"] is True
|
||||
mock_environment["runtime"][
|
||||
"CursorManager"
|
||||
].clear_window_for_application.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.remote
|
||||
class TestVMenuControl:
|
||||
"""Test VMenu control via remote."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Create RemoteManager instance for each test."""
|
||||
self.manager = RemoteManager()
|
||||
|
||||
def test_set_vmenu(self, mock_environment):
|
||||
"""Test setting VMenu to specific menu."""
|
||||
self.manager.initialize(mock_environment)
|
||||
|
||||
result = self.manager.handle_command_execution_with_response(
|
||||
"vmenu /vim/file/save"
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
mock_environment["runtime"]["VmenuManager"].set_curr_menu.assert_called_once_with(
|
||||
"/vim/file/save"
|
||||
)
|
||||
|
||||
def test_reset_vmenu(self, mock_environment):
|
||||
"""Test resetting VMenu to default."""
|
||||
self.manager.initialize(mock_environment)
|
||||
|
||||
result = self.manager.handle_command_execution_with_response("resetvmenu")
|
||||
|
||||
assert result["success"] is True
|
||||
mock_environment["runtime"]["VmenuManager"].set_curr_menu.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.remote
|
||||
@pytest.mark.slow
|
||||
class TestRemoteControlThroughput:
|
||||
"""Test remote control performance characteristics."""
|
||||
|
||||
def test_rapid_say_commands(self, mock_environment):
|
||||
"""Test handling of rapid successive say commands."""
|
||||
manager = RemoteManager()
|
||||
manager.initialize(mock_environment)
|
||||
|
||||
# Send 100 rapid commands
|
||||
for i in range(100):
|
||||
result = manager.handle_command_execution_with_response(f"say test {i}")
|
||||
assert result["success"] is True
|
||||
|
||||
# Verify all were queued
|
||||
assert (
|
||||
mock_environment["runtime"]["OutputManager"].speak_text.call_count == 100
|
||||
)
|
||||
|
||||
def test_rapid_setting_changes(self, mock_environment):
|
||||
"""Test handling of rapid setting changes."""
|
||||
manager = RemoteManager()
|
||||
manager.initialize(mock_environment)
|
||||
|
||||
# Rapidly change speech rate
|
||||
with patch.object(
|
||||
mock_environment["runtime"]["SettingsManager"], "parse_setting_args"
|
||||
) as mock_parse:
|
||||
for rate in [0.5, 0.6, 0.7, 0.8, 0.9, 1.0]:
|
||||
result = manager.handle_settings_change_with_response(
|
||||
f"set speech#rate={rate}"
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
assert mock_parse.call_count == 6
|
||||
@@ -0,0 +1,21 @@
|
||||
# Test dependencies for Fenrir screen reader
|
||||
# Install with: pip install -r tests/requirements.txt
|
||||
|
||||
# Core testing framework (required)
|
||||
pytest>=7.0.0
|
||||
|
||||
# Optional but recommended plugins
|
||||
pytest-cov>=4.0.0 # Coverage reporting (pytest --cov)
|
||||
pytest-mock>=3.10.0 # Enhanced mocking utilities
|
||||
pytest-timeout>=2.1.0 # Timeout for hanging tests (pytest --timeout)
|
||||
pytest-xdist>=3.0.0 # Parallel test execution (pytest -n auto)
|
||||
|
||||
# Additional testing utilities (optional)
|
||||
freezegun>=1.2.0 # Time mocking
|
||||
responses>=0.22.0 # HTTP mocking (for future web features)
|
||||
|
||||
# Minimal install (just pytest):
|
||||
# pip install pytest
|
||||
#
|
||||
# Full install (all features):
|
||||
# pip install -r tests/requirements.txt
|
||||
@@ -0,0 +1 @@
|
||||
"""Unit tests for Fenrir screen reader components."""
|
||||
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
Unit tests for settings validation in SettingsManager.
|
||||
|
||||
Tests the _validate_setting_value method to ensure proper input validation
|
||||
for all configurable settings that could cause crashes or accessibility issues.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Import the settings manager
|
||||
from fenrirscreenreader.core.settingsManager import SettingsManager
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.settings
|
||||
class TestSpeechSettingsValidation:
|
||||
"""Test validation of speech-related settings."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Create a SettingsManager instance for each test."""
|
||||
self.manager = SettingsManager()
|
||||
|
||||
def test_speech_rate_valid_range(self):
|
||||
"""Speech rate should accept values between 0.0 and 3.0."""
|
||||
# Valid boundary values
|
||||
self.manager._validate_setting_value("speech", "rate", 0.0)
|
||||
self.manager._validate_setting_value("speech", "rate", 1.5)
|
||||
self.manager._validate_setting_value("speech", "rate", 3.0)
|
||||
|
||||
def test_speech_rate_rejects_negative(self):
|
||||
"""Speech rate should reject negative values."""
|
||||
with pytest.raises(ValueError, match="must be between 0.0 and 3.0"):
|
||||
self.manager._validate_setting_value("speech", "rate", -0.1)
|
||||
|
||||
def test_speech_rate_rejects_too_high(self):
|
||||
"""Speech rate should reject values above 3.0."""
|
||||
with pytest.raises(ValueError, match="must be between 0.0 and 3.0"):
|
||||
self.manager._validate_setting_value("speech", "rate", 10.0)
|
||||
|
||||
def test_speech_pitch_valid_range(self):
|
||||
"""Speech pitch should accept values between 0.0 and 2.0."""
|
||||
self.manager._validate_setting_value("speech", "pitch", 0.0)
|
||||
self.manager._validate_setting_value("speech", "pitch", 1.0)
|
||||
self.manager._validate_setting_value("speech", "pitch", 2.0)
|
||||
|
||||
def test_speech_pitch_rejects_invalid(self):
|
||||
"""Speech pitch should reject out-of-range values."""
|
||||
with pytest.raises(ValueError, match="must be between 0.0 and 2.0"):
|
||||
self.manager._validate_setting_value("speech", "pitch", -1.0)
|
||||
with pytest.raises(ValueError, match="must be between 0.0 and 2.0"):
|
||||
self.manager._validate_setting_value("speech", "pitch", 5.0)
|
||||
|
||||
def test_speech_volume_valid_range(self):
|
||||
"""Speech volume should accept values between 0.0 and 1.5."""
|
||||
self.manager._validate_setting_value("speech", "volume", 0.0)
|
||||
self.manager._validate_setting_value("speech", "volume", 1.0)
|
||||
self.manager._validate_setting_value("speech", "volume", 1.5)
|
||||
|
||||
def test_speech_volume_rejects_negative(self):
|
||||
"""Speech volume should reject negative values."""
|
||||
with pytest.raises(ValueError, match="must be between 0.0 and 1.5"):
|
||||
self.manager._validate_setting_value("speech", "volume", -0.5)
|
||||
|
||||
def test_speech_driver_whitelisted(self):
|
||||
"""Speech driver should only accept whitelisted values."""
|
||||
# Valid drivers
|
||||
self.manager._validate_setting_value("speech", "driver", "speechdDriver")
|
||||
self.manager._validate_setting_value("speech", "driver", "genericDriver")
|
||||
self.manager._validate_setting_value("speech", "driver", "dummyDriver")
|
||||
|
||||
# Invalid driver
|
||||
with pytest.raises(ValueError, match="Invalid speech driver"):
|
||||
self.manager._validate_setting_value(
|
||||
"speech", "driver", "nonexistentDriver"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.settings
|
||||
class TestSoundSettingsValidation:
|
||||
"""Test validation of sound-related settings."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Create a SettingsManager instance for each test."""
|
||||
self.manager = SettingsManager()
|
||||
|
||||
def test_sound_volume_valid_range(self):
|
||||
"""Sound volume should accept values between 0.0 and 1.5."""
|
||||
self.manager._validate_setting_value("sound", "volume", 0.0)
|
||||
self.manager._validate_setting_value("sound", "volume", 0.7)
|
||||
self.manager._validate_setting_value("sound", "volume", 1.5)
|
||||
|
||||
def test_sound_volume_rejects_invalid(self):
|
||||
"""Sound volume should reject out-of-range values."""
|
||||
with pytest.raises(ValueError, match="must be between 0.0 and 1.5"):
|
||||
self.manager._validate_setting_value("sound", "volume", -0.1)
|
||||
with pytest.raises(ValueError, match="must be between 0.0 and 1.5"):
|
||||
self.manager._validate_setting_value("sound", "volume", 2.0)
|
||||
|
||||
def test_sound_driver_whitelisted(self):
|
||||
"""Sound driver should only accept whitelisted values."""
|
||||
# Valid drivers
|
||||
self.manager._validate_setting_value("sound", "driver", "genericDriver")
|
||||
self.manager._validate_setting_value("sound", "driver", "gstreamerDriver")
|
||||
self.manager._validate_setting_value("sound", "driver", "dummyDriver")
|
||||
|
||||
# Invalid driver
|
||||
with pytest.raises(ValueError, match="Invalid sound driver"):
|
||||
self.manager._validate_setting_value("sound", "driver", "invalidDriver")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.settings
|
||||
class TestDriverValidation:
|
||||
"""Test validation of driver selection settings."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Create a SettingsManager instance for each test."""
|
||||
self.manager = SettingsManager()
|
||||
|
||||
def test_screen_driver_whitelisted(self):
|
||||
"""Screen driver should only accept whitelisted values."""
|
||||
# Valid drivers
|
||||
self.manager._validate_setting_value("screen", "driver", "vcsaDriver")
|
||||
self.manager._validate_setting_value("screen", "driver", "ptyDriver")
|
||||
self.manager._validate_setting_value("screen", "driver", "dummyDriver")
|
||||
|
||||
# Invalid driver
|
||||
with pytest.raises(ValueError, match="Invalid screen driver"):
|
||||
self.manager._validate_setting_value("screen", "driver", "unknownDriver")
|
||||
|
||||
def test_keyboard_driver_whitelisted(self):
|
||||
"""Keyboard driver should only accept whitelisted values."""
|
||||
# Valid drivers
|
||||
self.manager._validate_setting_value("keyboard", "driver", "evdevDriver")
|
||||
self.manager._validate_setting_value("keyboard", "driver", "ptyDriver")
|
||||
self.manager._validate_setting_value("keyboard", "driver", "atspiDriver")
|
||||
self.manager._validate_setting_value("keyboard", "driver", "dummyDriver")
|
||||
|
||||
# Invalid driver
|
||||
with pytest.raises(ValueError, match="Invalid input driver"):
|
||||
self.manager._validate_setting_value("keyboard", "driver", "badDriver")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.settings
|
||||
class TestGeneralSettingsValidation:
|
||||
"""Test validation of general settings."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Create a SettingsManager instance for each test."""
|
||||
self.manager = SettingsManager()
|
||||
|
||||
def test_debug_level_valid_range(self):
|
||||
"""Debug level should accept values 0-3."""
|
||||
self.manager._validate_setting_value("general", "debug_level", 0)
|
||||
self.manager._validate_setting_value("general", "debug_level", 1)
|
||||
self.manager._validate_setting_value("general", "debug_level", 2)
|
||||
self.manager._validate_setting_value("general", "debug_level", 3)
|
||||
|
||||
def test_debug_level_rejects_invalid(self):
|
||||
"""Debug level should reject values outside 0-3."""
|
||||
with pytest.raises(ValueError, match="must be between 0 and 3"):
|
||||
self.manager._validate_setting_value("general", "debug_level", -1)
|
||||
with pytest.raises(ValueError, match="must be between 0 and 3"):
|
||||
self.manager._validate_setting_value("general", "debug_level", 10)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.settings
|
||||
class TestValidationSkipsUnknownSettings:
|
||||
"""Test that validation doesn't error on unknown settings."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Create a SettingsManager instance for each test."""
|
||||
self.manager = SettingsManager()
|
||||
|
||||
def test_unknown_section_no_error(self):
|
||||
"""Unknown sections should not raise errors during validation."""
|
||||
# Should not raise - validation only applies to known critical settings
|
||||
self.manager._validate_setting_value("unknown_section", "setting", "value")
|
||||
|
||||
def test_unknown_setting_no_error(self):
|
||||
"""Unknown settings in known sections should not raise errors."""
|
||||
# Should not raise - only specific critical settings are validated
|
||||
self.manager._validate_setting_value("speech", "unknown_setting", "value")
|
||||
@@ -1,306 +0,0 @@
|
||||
#!/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)
|
||||
+154
-23
@@ -1,21 +1,61 @@
|
||||
#!/bin/bash
|
||||
|
||||
# This script configures pipewire to work both in the graphical environment and in the console with root apps.
|
||||
# Supports both legacy WirePlumber (Lua) and modern WirePlumber (0.5+ config format)
|
||||
|
||||
# Detect WirePlumber version
|
||||
detect_wireplumber_version() {
|
||||
if command -v wireplumber >/dev/null 2>&1; then
|
||||
local version
|
||||
version=$(wireplumber --version 2>/dev/null | grep -oP 'wireplumber \K[0-9]+\.[0-9]+' | head -n1)
|
||||
if [ -n "$version" ]; then
|
||||
echo "$version"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
# Default to legacy if detection fails
|
||||
echo "0.4"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Compare versions (returns 0 if version1 >= version2)
|
||||
version_ge() {
|
||||
[ "$(printf '%s\n' "$1" "$2" | sort -V | head -n1)" = "$2" ]
|
||||
}
|
||||
|
||||
if [[ $(whoami) != "root" ]]; then
|
||||
# Track overall success
|
||||
CONFIG_SUCCESS=true
|
||||
|
||||
# Get the current user's XDG_HOME
|
||||
xdgPath="${XDG_CONFIG_HOME:-$HOME/.config}"
|
||||
mkdir -p "$xdgPath/pipewire"
|
||||
mkdir -p "$xdgPath/wireplumber/main.lua.d"
|
||||
mkdir -p "$xdgPath/wireplumber/bluetooth.lua.d"
|
||||
mkdir -p "$xdgPath/pipewire" || CONFIG_SUCCESS=false
|
||||
|
||||
# Detect WirePlumber version
|
||||
WP_VERSION=$(detect_wireplumber_version)
|
||||
echo "Detected WirePlumber version: $WP_VERSION"
|
||||
|
||||
# Determine configuration format
|
||||
if version_ge "$WP_VERSION" "0.5"; then
|
||||
echo "Using modern WirePlumber configuration format (0.5+)"
|
||||
USE_MODERN_CONFIG=true
|
||||
mkdir -p "$xdgPath/wireplumber/wireplumber.conf.d"
|
||||
mkdir -p "$xdgPath/wireplumber/main.conf.d"
|
||||
mkdir -p "$xdgPath/wireplumber/bluetooth.conf.d"
|
||||
else
|
||||
echo "Using legacy WirePlumber Lua configuration format"
|
||||
USE_MODERN_CONFIG=false
|
||||
mkdir -p "$xdgPath/wireplumber/main.lua.d"
|
||||
mkdir -p "$xdgPath/wireplumber/bluetooth.lua.d"
|
||||
fi
|
||||
|
||||
# Create drop-in configuration for PipeWire-Pulse console access
|
||||
mkdir -p "$xdgPath/pipewire/pipewire-pulse.conf.d"
|
||||
# Warn user if we are going to overwrite an existing fenrir console config
|
||||
if [ -f "$xdgPath/pipewire/pipewire-pulse.conf.d/50-fenrir-console.conf" ]; then
|
||||
read -p "This will replace the current file located at $xdgPath/pipewire/pipewire-pulse.conf.d/50-fenrir-console.conf, press enter to continue or control+c to abort. " continue
|
||||
read -r -p "This will replace the current file located at $xdgPath/pipewire/pipewire-pulse.conf.d/50-fenrir-console.conf, press enter to continue or control+c to abort. "
|
||||
fi
|
||||
cat << "EOF" > "$xdgPath/pipewire/pipewire-pulse.conf.d/50-fenrir-console.conf"
|
||||
cat << "EOF" > "$xdgPath/pipewire/pipewire-pulse.conf.d/50-fenrir-console.conf" || CONFIG_SUCCESS=false
|
||||
# Fenrir console audio support
|
||||
# Adds secondary socket for console applications like Fenrir
|
||||
|
||||
@@ -43,11 +83,56 @@ pulse.rules = [
|
||||
EOF
|
||||
|
||||
# Create WirePlumber configuration to prevent audio device suspension on console switch
|
||||
# Warn user if we are going to overwrite an existing 50-fenrir-no-suspend.lua
|
||||
if [ -f "$xdgPath/wireplumber/main.lua.d/50-fenrir-no-suspend.lua" ]; then
|
||||
read -p "This will replace the current file located at $xdgPath/wireplumber/main.lua.d/50-fenrir-no-suspend.lua, press enter to continue or control+c to abort. " continue
|
||||
fi
|
||||
cat << "EOF" > "$xdgPath/wireplumber/main.lua.d/50-fenrir-no-suspend.lua"
|
||||
if [ "$USE_MODERN_CONFIG" = true ]; then
|
||||
# Modern WirePlumber 0.5+ configuration format
|
||||
CONFIG_FILE="$xdgPath/wireplumber/main.conf.d/50-fenrir-no-suspend.conf"
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
read -r -p "This will replace the current file located at $CONFIG_FILE, press enter to continue or control+c to abort. "
|
||||
fi
|
||||
cat << "EOF" > "$CONFIG_FILE" || CONFIG_SUCCESS=false
|
||||
# Fenrir console audio support
|
||||
# Prevents audio device suspension when switching to TTY console
|
||||
|
||||
monitor.alsa.rules = [
|
||||
{
|
||||
matches = [
|
||||
{
|
||||
device.name = "~alsa_card.*"
|
||||
}
|
||||
]
|
||||
actions = {
|
||||
update-props = {
|
||||
api.alsa.use-acp = true
|
||||
api.acp.auto-profile = false
|
||||
api.acp.auto-port = false
|
||||
session.suspend-timeout-seconds = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
matches = [
|
||||
{
|
||||
node.name = "~alsa_input.*"
|
||||
}
|
||||
{
|
||||
node.name = "~alsa_output.*"
|
||||
}
|
||||
]
|
||||
actions = {
|
||||
update-props = {
|
||||
session.suspend-timeout-seconds = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
EOF
|
||||
else
|
||||
# Legacy Lua configuration format
|
||||
CONFIG_FILE="$xdgPath/wireplumber/main.lua.d/50-fenrir-no-suspend.lua"
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
read -r -p "This will replace the current file located at $CONFIG_FILE, press enter to continue or control+c to abort. "
|
||||
fi
|
||||
cat << "EOF" > "$CONFIG_FILE" || CONFIG_SUCCESS=false
|
||||
-- Fenrir console audio support
|
||||
-- Prevents audio device suspension when switching to TTY console
|
||||
|
||||
@@ -67,7 +152,7 @@ alsa_monitor.rules = {
|
||||
},
|
||||
{
|
||||
matches = {
|
||||
{
|
||||
{
|
||||
{ "node.name", "matches", "alsa_input.*" },
|
||||
},
|
||||
{
|
||||
@@ -80,13 +165,48 @@ alsa_monitor.rules = {
|
||||
},
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Create WirePlumber bluetooth configuration to prevent disconnection on TTY switch
|
||||
# Warn user if we are going to overwrite an existing 30-fenrir-bluez.lua
|
||||
if [ -f "$xdgPath/wireplumber/bluetooth.lua.d/30-fenrir-bluez.lua" ]; then
|
||||
read -p "This will replace the current file located at $xdgPath/wireplumber/bluetooth.lua.d/30-fenrir-bluez.lua, press enter to continue or control+c to abort. " continue
|
||||
fi
|
||||
cat << "EOF" > "$xdgPath/wireplumber/bluetooth.lua.d/30-fenrir-bluez.lua"
|
||||
if [ "$USE_MODERN_CONFIG" = true ]; then
|
||||
# Modern WirePlumber 0.5+ configuration format
|
||||
CONFIG_FILE="$xdgPath/wireplumber/bluetooth.conf.d/50-fenrir-bluez.conf"
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
read -r -p "This will replace the current file located at $CONFIG_FILE, press enter to continue or control+c to abort. "
|
||||
fi
|
||||
cat << "EOF" > "$CONFIG_FILE" || CONFIG_SUCCESS=false
|
||||
# Fenrir console audio support
|
||||
# Prevents bluetooth disconnection when switching TTY
|
||||
|
||||
monitor.bluez.properties = {
|
||||
# Disable logind integration to prevent bluetooth device suspension on TTY switch
|
||||
bluez5.enable-sbc-xq = true
|
||||
bluez5.enable-msbc = true
|
||||
bluez5.enable-hw-volume = true
|
||||
}
|
||||
|
||||
monitor.bluez.rules = [
|
||||
{
|
||||
matches = [
|
||||
{
|
||||
device.name = "~bluez_card.*"
|
||||
}
|
||||
]
|
||||
actions = {
|
||||
update-props = {
|
||||
session.suspend-timeout-seconds = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
EOF
|
||||
else
|
||||
# Legacy Lua configuration format
|
||||
CONFIG_FILE="$xdgPath/wireplumber/bluetooth.lua.d/30-fenrir-bluez.lua"
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
read -r -p "This will replace the current file located at $CONFIG_FILE, press enter to continue or control+c to abort. "
|
||||
fi
|
||||
cat << "EOF" > "$CONFIG_FILE" || CONFIG_SUCCESS=false
|
||||
-- Fenrir console audio support
|
||||
-- Disables logind module to prevent bluetooth disconnection when switching TTY
|
||||
|
||||
@@ -101,20 +221,30 @@ function bluez_monitor.enable()
|
||||
})
|
||||
end
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Configuration files created successfully."
|
||||
if [ "$USE_MODERN_CONFIG" = true ]; then
|
||||
echo "Using modern WirePlumber 0.5+ configuration format (.conf files)"
|
||||
else
|
||||
echo "Using legacy WirePlumber configuration format (.lua files)"
|
||||
fi
|
||||
echo ""
|
||||
echo "Please ensure that your user is added to the audio group."
|
||||
echo "If you have not yet done so, please run this script as root to write the client.conf file."
|
||||
else
|
||||
# This section does the root part:
|
||||
CONFIG_SUCCESS=true
|
||||
xdgPath="/root/.config"
|
||||
mkdir -p "$xdgPath/pulse"
|
||||
mkdir -p "$xdgPath/pulse" || CONFIG_SUCCESS=false
|
||||
|
||||
# Warn user if we are going to overwrite an existing client.conf
|
||||
if [ -f "$xdgPath/pulse/client.conf" ]; then
|
||||
read -p "This will replace the current file located at $xdgPath/pulse/client.conf, press enter to continue or control+c to abort. " continue
|
||||
read -r -p "This will replace the current file located at $xdgPath/pulse/client.conf, press enter to continue or control+c to abort. "
|
||||
fi
|
||||
|
||||
cat << "EOF" > "$xdgPath/pulse/client.conf"
|
||||
cat << "EOF" > "$xdgPath/pulse/client.conf" || CONFIG_SUCCESS=false
|
||||
# This file is part of PulseAudio.
|
||||
#
|
||||
# PulseAudio is free software; you can redistribute it and/or modify
|
||||
@@ -156,9 +286,10 @@ echo "If you have not yet done so, run this script as your normal user to write
|
||||
fi
|
||||
|
||||
# If there were no errors tell user to restart, else warn them errors happened.
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Configuration created successfully, please restart both Pipewire-pulseaudio and Wireplumber or your system, for changes to take affect."
|
||||
if [ "$CONFIG_SUCCESS" = true ]; then
|
||||
echo "Configuration created successfully, please restart both Pipewire-pulseaudio and Wireplumber or your system, for changes to take affect."
|
||||
exit 0
|
||||
else
|
||||
echo "Errors were encountered whilst writing the configuration, please correct them manually."
|
||||
echo "Errors were encountered whilst writing the configuration, please correct them manually."
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
sinks=(`pacmd list-sinks | sed -n -e 's/\**[[:space:]]index:[[:space:]]\([[:digit:]]\)/\1/p'`)
|
||||
sinks_count=${#sinks[@]}
|
||||
active_sink_index=`pacmd list-sinks | sed -n -e 's/\*[[:space:]]index:[[:space:]]\([[:digit:]]\)/\1/p'`
|
||||
newSink=${sinks[0]}
|
||||
ord=0
|
||||
|
||||
while [ $ord -lt $sinks_count ];
|
||||
do
|
||||
echo ${sinks[$ord]}
|
||||
if [ ${sinks[$ord]} -gt $active_sink_index ] ; then
|
||||
newSink=${sinks[$ord]}
|
||||
break
|
||||
fi
|
||||
let ord++
|
||||
done
|
||||
|
||||
pactl list short sink-inputs|while read stream; do
|
||||
streamId=$(echo $stream|cut '-d ' -f1)
|
||||
echo "moving stream $streamId"
|
||||
pactl move-sink-input "$streamId" "$newSink"
|
||||
done
|
||||
pacmd set-default-sink "$newSink"
|
||||
|
||||
#https://unix.stackexchange.com/questions/65246/change-pulseaudio-input-output-from-shell
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
from pyudev import Context
|
||||
context = Context()
|
||||
for device in context.list_devices(subsystem='input'):
|
||||
'{0} - {1}'.format(device.sys_name, device.device_type)
|
||||
print('{0} - {1}'.format(device.sys_name, device.device_type))
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Make sure this script is ran as root
|
||||
if [[ "$(whoami)" != "root" ]]; then
|
||||
echo "Please run $0 with oot privileges."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# This script checks for, and creates if needed, the fenrirscreenreader user.
|
||||
|
||||
# Find out which group to use for uinput
|
||||
uinput="$(stat -c '%G' /dev/uinput | grep -v root)"
|
||||
if ! [[ "$uinput" =~ ^[a-zA-Z]+$ ]]; then
|
||||
groupadd -r uinput
|
||||
chown root:uinput /dev/uinput
|
||||
fi
|
||||
|
||||
# find out which group to use for /dev/input.
|
||||
input="$(stat -c '%G' /dev/input/* | grep -v root | head -1)"
|
||||
if ! [[ "$input" =~ ^[a-zA-Z]+$ ]]; then
|
||||
# Create the input group
|
||||
groupadd --system input
|
||||
echo 'KERNEL=="event*", NAME="input/%k", MODE="660", GROUP="input"' >> /etc/udev/rules.d/99-input.rules
|
||||
input="input"
|
||||
fi
|
||||
|
||||
# find out which group to use for /dev/tty.
|
||||
tty="$(stat -c '%G' /dev/tty | grep -v root)"
|
||||
if ! [[ "$tty" =~ ^[a-zA-Z]+$ ]]; then
|
||||
# Create the tty group
|
||||
groupadd --system tty
|
||||
echo 'KERNEL=="event*", NAME="tty/%k", MODE="660", GROUP="tty"' >> /etc/udev/rules.d/99-tty.rules
|
||||
tty="tty"
|
||||
fi
|
||||
|
||||
# Add fenrirscreenreader
|
||||
id fenrirscreenreader &> /dev/null ||
|
||||
useradd -m -d /var/fenrirscreenreader -r -G $input,$tty,$uinput -s /bin/nologin -U fenrirscreenreader
|
||||
|
||||
#configure directory structure.
|
||||
mkdir -p /var/log/fenrirscreenreader /etc/fenrirscreenreader
|
||||
|
||||
# Set directory ownership
|
||||
chown -R fenrirscreenreader:fenrirscreenreader /var/log/fenrirscreenreader
|
||||
chmod -R 755 /var/log/fenrirscreenreader
|
||||
chown -R root:fenrirscreenreader /etc/fenrirscreenreader
|
||||
|
||||
# Fix permissions on tty#s
|
||||
for i in /dev/tty[0-9]* ; do
|
||||
chmod 660 "$i"
|
||||
done
|
||||
|
||||
sudo -Hu fenrirscreenreader mkdir /var/fenrirscreenreader/.config/pulse
|
||||
|
||||
# Set up sound
|
||||
cat << EOF > /var/fenrirscreenreader/.config/pulse/client.conf
|
||||
# This file is part of PulseAudio.
|
||||
#
|
||||
# PulseAudio is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# PulseAudio is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with PulseAudio; if not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
## Configuration file for PulseAudio clients. See pulse-client.conf(5) for
|
||||
## more information. Default values are commented out. Use either ; or # for
|
||||
## commenting.
|
||||
|
||||
; default-sink =
|
||||
; default-source =
|
||||
default-server = unix:/tmp/pulse.sock
|
||||
; default-dbus-server =
|
||||
|
||||
autospawn = no
|
||||
; autospawn = yes
|
||||
; daemon-binary = /usr/bin/pulseaudio
|
||||
; extra-arguments = --log-target=syslog
|
||||
|
||||
; cookie-file =
|
||||
|
||||
; enable-shm = yes
|
||||
; shm-size-bytes = 0 # setting this 0 will use the system-default, usually 64 MiB
|
||||
|
||||
; auto-connect-localhost = no
|
||||
; auto-connect-display = no
|
||||
EOF
|
||||
|
||||
|
||||
exit 0
|
||||
+20
-2
@@ -109,8 +109,25 @@ else
|
||||
echo -e "${GREEN}✓ Core module imports successful${NC}"
|
||||
fi
|
||||
|
||||
# 4. Check for secrets or sensitive data
|
||||
echo -e "\n${YELLOW}4. Checking for potential secrets...${NC}"
|
||||
# 4. Run test suite
|
||||
echo -e "\n${YELLOW}4. Running test suite...${NC}"
|
||||
if command -v pytest >/dev/null 2>&1; then
|
||||
# Run tests quietly, show summary at end
|
||||
if pytest tests/ -q --tb=short 2>&1 | tail -20; then
|
||||
echo -e "${GREEN}✓ All tests passed${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Test suite failed${NC}"
|
||||
echo "Run: pytest tests/ -v (to see details)"
|
||||
VALIDATION_FAILED=1
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}⚠ pytest not installed - skipping tests${NC}"
|
||||
echo " Install with: pip install pytest"
|
||||
echo " Or full test suite: pip install -r tests/requirements.txt"
|
||||
fi
|
||||
|
||||
# 5. Check for secrets or sensitive data
|
||||
echo -e "\n${YELLOW}5. Checking for potential secrets...${NC}"
|
||||
SECRETS_FOUND=0
|
||||
|
||||
if [ -n "$STAGED_PYTHON_FILES" ]; then
|
||||
@@ -144,6 +161,7 @@ else
|
||||
echo ""
|
||||
echo "Quick fixes:"
|
||||
echo " • Python syntax: python3 tools/validate_syntax.py --fix"
|
||||
echo " • Run tests: pytest tests/ -v"
|
||||
echo " • Review flagged files manually"
|
||||
echo " • Re-run commit after fixes"
|
||||
exit 1
|
||||
|
||||
Reference in New Issue
Block a user