20 Commits

Author SHA1 Message Date
Storm Dragon f462ca7990 Merge branch 'testing' minor settings file update. 2025-12-03 16:25:47 -05:00
Storm Dragon f0bbcb8a38 Updated settings file to document that capslock as fenrir key and echo mode 2 are incompatible. 2025-12-03 16:25:22 -05:00
Storm Dragon aed627ec2a Discovered through much pain that echo mode 2 and capslock as fenrir key are incompatible. Documented in settings file. 2025-12-03 16:18:02 -05:00
Storm Dragon e62b887e9c Some socket improvements for remote manager I thought should make it into this release. 2025-12-03 12:20:14 -05:00
Storm Dragon bf0d134187 Tests added, see the documentation in the tests directory for details. Improved the socket code. 2025-12-03 02:51:49 -05:00
Storm Dragon c66a9ba9c2 Problems with voice selection fixed.: 2025-12-02 18:38:06 -05:00
Storm Dragon 2092a3e257 Fixed voice selection. 2025-12-02 18:36:46 -05:00
Storm Dragon d46d8de3ee Updated sound driver to gstreamer by default. 2025-12-02 16:25:25 -05:00
Storm Dragon 75a8447759 One more feature addition before hopefully releasing the new version. 2025-12-02 16:13:15 -05:00
Storm Dragon 1650eec768 Add ability to switch speech-dispatcher module and voice to the speeach keys. 2025-12-02 16:11:47 -05:00
Storm Dragon 5bb786ef4c Bug fix in vmenu for keyboard layouts. 2025-11-27 22:44:51 -05:00
Storm Dragon 7f7faa17d3 keyboard layout fixed in vmenu. 2025-11-27 22:42:36 -05:00
Storm Dragon 2766f70c5d Some cleanup and minor fixes in docs. 2025-11-24 09:07:45 -05:00
Storm Dragon 8d781643bc Remove group and user comments from the Arch service file, not being used, so just extra stuff. 2025-11-24 08:49:31 -05:00
Storm Dragon c184cf023a Code cleanups, fixes to systemd files, url corrections. 2025-11-24 08:44:49 -05:00
Storm Dragon 841c221c7b Preparing for new tagged release. 2025-11-23 18:51:02 -05:00
Storm Dragon 87553bdc38 more fixes for the pickle error. 2025-11-23 18:37:21 -05:00
Storm Dragon 77a3aae5a4 Attempt to fix cannot pickle 'TextIOWrapper' instances error for some distros. 2025-11-23 18:29:38 -05:00
Storm Dragon aabc202d83 Latest version of configure_pipewire.sh tested and appears to work. 2025-10-17 22:19:58 -04:00
Storm Dragon 2f3a114790 Pipewire configuration tool updated. In a bout of pure insanity I tested this on my production system and it worked without a hitch, so should be good to go. 2025-10-17 22:19:30 -04:00
37 changed files with 2702 additions and 683 deletions
-3
View File
@@ -1,3 +0,0 @@
V2.0
Cleanup folders and config files.
+1 -3
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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:
+11 -4
View 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
@@ -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")
@@ -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
+85 -11
View File
@@ -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:
+476 -9
View File
@@ -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:
+1 -1
View File
@@ -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):
+208
View File
@@ -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
View File
@@ -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
+430
View File
@@ -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! 🧪
+225
View File
@@ -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",
)
+1
View File
@@ -0,0 +1 @@
"""Integration tests for Fenrir screen reader components."""
+342
View File
@@ -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
+21
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Unit tests for Fenrir screen reader components."""
+188
View File
@@ -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")
-306
View File
@@ -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
View File
@@ -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
-26
View File
@@ -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
View File
@@ -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))
-96
View File
@@ -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
View File
@@ -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