diff --git a/README.md b/README.md index fbb75339..b15b7135 100644 --- a/README.md +++ b/README.md @@ -81,13 +81,16 @@ Fenrir is a Linux screen reader. Linux is the only officially supported platform - espeak or espeak-ng (or any TTS command) 3. **dectalkDriver** - Serial DECtalk-compatible hardware speech driver - RPITalk gadget mode or a DECtalk-compatible serial device -4. **litetalkDriver** - Serial LiteTalk-compatible hardware speech driver +4. **dectalkUsbDriver** - Native Access Solutions DECtalk USB driver + - USB ID `0dd0:2001` + - PyUSB and a libusb backend +5. **litetalkDriver** - Serial LiteTalk-compatible hardware speech driver - RPITalk gadget mode or a LiteTalk-compatible serial device -5. **doubletalkDriver** - Serial DoubleTalk LT-compatible hardware speech driver +6. **doubletalkDriver** - Serial DoubleTalk LT-compatible hardware speech driver - DoubleTalk LT; does not support the internal DoubleTalk PC card -6. **tripletalkDriver** - Serial TripleTalk-compatible hardware speech driver +7. **tripletalkDriver** - Serial TripleTalk-compatible hardware speech driver - External DB9 serial TripleTalk devices, or USB models that expose a tty serial device -7. **debugDriver** - Debug speech driver for testing +8. **debugDriver** - Debug speech driver for testing - No dependencies @@ -459,15 +462,22 @@ setting [parameters] - `speech#volume=0.1-1.0` - Speech volume - `speech#voice=voice_name` - Voice selection (e.g., "en-us+f3") - `speech#module=module_name` - TTS module (e.g., "espeak-ng") -- `speech#driver=driver_name` - Speech driver (speechdDriver/genericDriver/dectalkDriver/litetalkDriver/doubletalkDriver/tripletalkDriver) +- `speech#driver=driver_name` - Speech driver (speechdDriver/genericDriver/dectalkDriver/dectalkUsbDriver/litetalkDriver/doubletalkDriver/tripletalkDriver) - `speech#hardware_device=/dev/ttyS0` - Hardware synth serial device for dectalkDriver/litetalkDriver - `speech#hardware_baud_rate=9600` - Hardware synth serial baud rate - `speech#history_size=50` - Number of spoken items kept in runtime speech history +The native Access Solutions DECtalk USB, USB ID `0dd0:2001`, uses +`dectalkUsbDriver`. It is autodetected and does not use +`speech#hardware_device` or `speech#hardware_baud_rate`. Install PyUSB and a +libusb backend first. From a source checkout, use +`python3 -m pip install '.[dectalk_usb]'`; distributions may package PyUSB as +`python3-usb` or `python-pyusb`. Root-run Fenrir can access the device directly. +A non-root Fenrir process needs a local udev rule granting its account +read/write access to `0dd0:2001`; do not make the device world-writable. Other USB hardware synths are supported only when Linux exposes them as a serial tty -such as `/dev/ttyACM0` or `/dev/ttyUSB0`. A USB-only TripleTalk with no tty -device would require a separate USB protocol driver. Use an explicit -`speech#hardware_device` path for hardware speech. +such as `/dev/ttyACM0` or `/dev/ttyUSB0`. Use an explicit +`speech#hardware_device` path for serial hardware speech. - `speech#auto_read_incoming=True/False` - Auto-read new text *Sound Settings:* diff --git a/config/settings/settings.conf b/config/settings/settings.conf index beab040b..9881c934 100644 --- a/config/settings/settings.conf +++ b/config/settings/settings.conf @@ -36,10 +36,12 @@ progress_monitoring=True enabled=True # Select speech driver, options are speechdDriver, genericDriver, -# dectalkDriver, litetalkDriver, doubletalkDriver, or tripletalkDriver: +# dectalkDriver, dectalkUsbDriver, litetalkDriver, doubletalkDriver, +# or tripletalkDriver: driver=speechdDriver #driver=genericDriver #driver=dectalkDriver +#driver=dectalkUsbDriver #driver=litetalkDriver #driver=doubletalkDriver #driver=tripletalkDriver @@ -77,6 +79,7 @@ volume=1.0 # Hardware speech synthesizer serial device. # Used by dectalkDriver, litetalkDriver, doubletalkDriver, and tripletalkDriver. +# dectalkUsbDriver autodetects the Access Solutions device and ignores this. # USB serial devices are supported if Linux exposes them as /dev/ttyACM* # or /dev/ttyUSB*. USB-only synths with no tty device need a separate driver. # Set an explicit device for hardware speech. diff --git a/docs/fenrir.1 b/docs/fenrir.1 index a80735e9..142d1d85 100644 --- a/docs/fenrir.1 +++ b/docs/fenrir.1 @@ -242,6 +242,8 @@ genericDriver - Command-line TTS (espeak, etc.) .IP \[bu] 4 dectalkDriver - DECtalk-compatible serial hardware speech .IP \[bu] 4 +dectalkUsbDriver - Native Access Solutions DECtalk USB hardware speech +.IP \[bu] 4 litetalkDriver - LiteTalk-compatible serial hardware speech .IP \[bu] 4 doubletalkDriver - DoubleTalk LT-compatible serial hardware speech diff --git a/docs/fenrir.adoc b/docs/fenrir.adoc index 4c98b81c..921e6d24 100644 --- a/docs/fenrir.adoc +++ b/docs/fenrir.adoc @@ -1551,9 +1551,11 @@ enabled=True Values: on=`+True+`, off=`+False+` # Select speech driver, options are speechdDriver (default), -genericDriver, dectalkDriver, litetalkDriver, doubletalkDriver or tripletalkDriver: driver=speechdDriver +genericDriver, dectalkDriver, dectalkUsbDriver, litetalkDriver, +doubletalkDriver or tripletalkDriver: driver=speechdDriver #driver=genericDriver #driver=dectalkDriver +#driver=dectalkUsbDriver #driver=litetalkDriver #driver=doubletalkDriver #driver=tripletalkDriver @@ -1684,7 +1686,7 @@ the pico module: language=de-DE .... -Hardware speech drivers use a serial device. Set an explicit path. +Serial hardware speech drivers use a serial device. Set an explicit path. .... hardware_device=/dev/ttyACM0 @@ -1700,9 +1702,14 @@ hardware_baud_rate=9600 The `+doubletalkDriver+` targets DoubleTalk LT-style serial devices. It does not support the internal DoubleTalk PC ISA card. -USB hardware speech synthesizers are supported only when Linux exposes them as -a serial tty such as `+/dev/ttyACM0+` or `+/dev/ttyUSB0+`. USB-only TripleTalk -models with no tty device need a separate driver. +The `+dectalkUsbDriver+` autodetects the native Access Solutions DECtalk USB, +USB ID `+0dd0:2001+`. It requires PyUSB and a libusb backend and ignores the +serial device and baud-rate settings. From a source checkout, install the USB +dependencies with `+python3 -m pip install '.[dectalk_usb]'+`. A non-root +Fenrir process needs a local udev rule granting its account read/write access +to `+0dd0:2001+`; do not make the device world-writable. Other USB hardware +speech synthesizers are supported only when Linux exposes them as a serial tty +such as `+/dev/ttyACM0+` or `+/dev/ttyUSB0+`. Read new text as it occurs auto_read_incoming=True Values: on=`+True+`, off=`+False+` diff --git a/docs/user.md b/docs/user.md index bece798e..a5534b6e 100644 --- a/docs/user.md +++ b/docs/user.md @@ -340,18 +340,24 @@ status formats, which change too frequently to support reliably. - **speechdDriver** - Speech-dispatcher (recommended) - **genericDriver** - Command-line TTS (espeak, etc.) - **dectalkDriver** - Serial DECtalk-compatible hardware speech +- **dectalkUsbDriver** - Native Access Solutions DECtalk USB (`0dd0:2001`) - **litetalkDriver** - Serial LiteTalk-compatible hardware speech - **doubletalkDriver** - Serial DoubleTalk LT-compatible hardware speech - **tripletalkDriver** - Serial TripleTalk-compatible hardware speech -For hardware speech, set `speech#hardware_device` to an explicit serial path. +For serial hardware speech, set `speech#hardware_device` to an explicit path. RPITalk gadget mode usually appears as `/dev/ttyACM0`; USB serial adapters usually appear as `/dev/ttyUSB0`; built-in serial ports may be `/dev/ttyS0`. The default baud rate is `9600`. `doubletalkDriver` targets DoubleTalk LT-style serial devices, not the internal DoubleTalk PC ISA card. -USB TripleTalk devices work only if Linux exposes them as a serial tty such as -`/dev/ttyACM0` or `/dev/ttyUSB0`; USB-only models with no tty device need a -separate driver. +The `dectalkUsbDriver` autodetects the native Access Solutions DECtalk USB and +ignores both serial settings. It requires PyUSB and a libusb backend. From a +source checkout, install them with +`python3 -m pip install '.[dectalk_usb]'`. A non-root Fenrir process also needs +a local udev rule granting its account read/write access to USB ID +`0dd0:2001`; do not make the device world-writable. USB TripleTalk devices work +only if Linux exposes them as a serial tty such as `/dev/ttyACM0` or +`/dev/ttyUSB0`. ### Sound Drivers - **genericDriver** - Sox-based (default) diff --git a/docs/user.txt b/docs/user.txt index 04ab0c9c..d8c0c771 100644 --- a/docs/user.txt +++ b/docs/user.txt @@ -878,10 +878,11 @@ Turn speech on or off: enabled=True Values: on=''True'', off=''False'' -# Select speech driver, options are speechdDriver (default), genericDriver, dectalkDriver, litetalkDriver, doubletalkDriver or tripletalkDriver: +# Select speech driver, options are speechdDriver (default), genericDriver, dectalkDriver, dectalkUsbDriver, litetalkDriver, doubletalkDriver or tripletalkDriver: driver=speechdDriver #driver=genericDriver #driver=dectalkDriver +#driver=dectalkUsbDriver #driver=litetalkDriver #driver=doubletalkDriver #driver=tripletalkDriver @@ -894,6 +895,7 @@ Available Drivers: * ''genericDriver'' using the generic driver, for Fenrir <1.5 this is not available * ''speechdDriver'' using speech-dispatcher, for Fenrir <1.5 just use ''speechd'' * ''dectalkDriver'' using DECtalk-compatible serial hardware or RPITalk + * ''dectalkUsbDriver'' using the native Access Solutions DECtalk USB * ''litetalkDriver'' using LiteTalk-compatible serial hardware or RPITalk * ''doubletalkDriver'' using DoubleTalk LT-compatible serial hardware * ''tripletalkDriver'' using TripleTalk-compatible serial hardware @@ -927,7 +929,7 @@ Select the language you want Fenrir to use. language=english-us Values: Text, see your TTS synths documentation what is available. -Hardware speech drivers use a serial device. Set an explicit path. +Serial hardware speech drivers use a serial device. Set an explicit path. hardware_device=/dev/ttyACM0 hardware_device=/dev/ttyUSB0 hardware_device=/dev/ttyS0 @@ -936,7 +938,7 @@ Hardware speech drivers use 9600 baud by default. hardware_baud_rate=9600 The doubletalkDriver targets DoubleTalk LT-style serial devices. It does not support the internal DoubleTalk PC ISA card. -USB hardware speech synthesizers are supported only when Linux exposes them as a serial tty such as /dev/ttyACM0 or /dev/ttyUSB0. USB-only TripleTalk models with no tty device need a separate driver. +The dectalkUsbDriver autodetects the native Access Solutions DECtalk USB, USB ID 0dd0:2001. It requires PyUSB and a libusb backend and ignores the serial device and baud-rate settings. Other USB hardware speech synthesizers are supported only when Linux exposes them as a serial tty such as /dev/ttyACM0 or /dev/ttyUSB0. Read new text as it occurs auto_read_incoming=True diff --git a/setup.py b/setup.py index 859afa19..5a9ca6a8 100755 --- a/setup.py +++ b/setup.py @@ -106,6 +106,7 @@ setup( "pyte>=0.7.0", ], extras_require={ + "dectalk_usb": ["pyusb>=1.2.1"], "x11": ["python-xlib>=0.33"], }, ) diff --git a/src/fenrirscreenreader/core/settingsManager.py b/src/fenrirscreenreader/core/settingsManager.py index fdd89c85..5a613bde 100644 --- a/src/fenrirscreenreader/core/settingsManager.py +++ b/src/fenrirscreenreader/core/settingsManager.py @@ -521,6 +521,7 @@ class SettingsManager: "speechdDriver", "genericDriver", "dectalkDriver", + "dectalkUsbDriver", "doubletalkDriver", "litetalkDriver", "tripletalkDriver", diff --git a/src/fenrirscreenreader/speechDriver/dectalkUsbDriver.py b/src/fenrirscreenreader/speechDriver/dectalkUsbDriver.py new file mode 100644 index 00000000..db38d59b --- /dev/null +++ b/src/fenrirscreenreader/speechDriver/dectalkUsbDriver.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributors. + +import threading + +from fenrirscreenreader.core import debug +from fenrirscreenreader.speechDriver import dectalkDriver + + +class driver(dectalkDriver.driver): + vendor_id = 0x0DD0 + product_id = 0x2001 + interface = 0 + speech_endpoint = 0x02 + immediate_endpoint = 0x04 + packet_size = 64 + write_timeout = 1000 + + def initialize(self, environment): + self.env = environment + self._is_initialized = False + self.usb_device = None + self.usb_core = None + self.usb_util = None + self.immediate_lock = threading.Lock() + self._debug( + "DECtalk USB initialize: requested_device=0dd0:2001", + debug.DebugLevel.INFO, + on_any_level=True, + ) + + try: + self.usb_core, self.usb_util = self._load_usb_modules() + except ImportError as error: + self._debug( + f"DECtalk USB requires PyUSB: {error}", + debug.DebugLevel.ERROR, + on_any_level=True, + ) + raise RuntimeError("DECtalk USB requires PyUSB") from error + + try: + usb_device = self.usb_core.find( + idVendor=self.vendor_id, + idProduct=self.product_id, + ) + except ( + self.usb_core.NoBackendError, + self.usb_core.USBError, + OSError, + ) as error: + self._debug( + f"DECtalk USB discovery failed: {error}", + debug.DebugLevel.ERROR, + on_any_level=True, + ) + raise RuntimeError( + f"DECtalk USB discovery failed: {error}" + ) from error + if usb_device is None: + self._debug( + "DECtalk USB device 0dd0:2001 was not found", + debug.DebugLevel.ERROR, + on_any_level=True, + ) + raise RuntimeError("DECtalk USB device 0dd0:2001 was not found") + + try: + try: + usb_device.get_active_configuration() + except self.usb_core.USBError: + usb_device.set_configuration() + self.usb_util.claim_interface(usb_device, self.interface) + except (self.usb_core.USBError, OSError) as error: + self._dispose_usb_device(usb_device) + self._debug( + f"DECtalk USB open failed: {error}", + debug.DebugLevel.ERROR, + on_any_level=True, + ) + raise RuntimeError(f"DECtalk USB open failed: {error}") from error + + self.usb_device = usb_device + self._is_initialized = True + self._stop_worker = False + self.worker_thread = threading.Thread( + target=self._worker, + daemon=True, + ) + self.worker_thread.start() + self._debug( + "DECtalk USB device opened: 0dd0:2001", + debug.DebugLevel.INFO, + on_any_level=True, + ) + + def shutdown(self): + if not self._is_initialized: + return + self._stop_worker = True + self.clear_buffer() + self.text_queue.put(None) + if self.worker_thread: + self.worker_thread.join(timeout=0.5) + + try: + with self.lock, self.immediate_lock: + usb_device = self.usb_device + self.usb_device = None + if usb_device is None: + return + try: + self.usb_util.release_interface( + usb_device, + self.interface, + ) + except (self.usb_core.USBError, OSError) as error: + self._debug( + f"DECtalk USB interface release failed: {error}", + debug.DebugLevel.WARNING, + ) + self._dispose_usb_device(usb_device) + finally: + self._is_initialized = False + + def cancel(self): + if not self._is_initialized: + return + self.clear_buffer() + self._write_endpoint( + self.immediate_endpoint, + b"\x06", + "cancel", + write_lock=self.immediate_lock, + ) + + def _write_bytes(self, data, description="data"): + self._write_endpoint(self.speech_endpoint, data, description) + + def _write_endpoint( + self, + endpoint, + data, + description, + write_lock=None, + ): + if not data: + return + if write_lock is None: + write_lock = self.lock + with write_lock: + if self.usb_device is None: + return + try: + total_written = 0 + while total_written < len(data): + chunk = data[ + total_written : total_written + self.packet_size + ] + bytes_written = self.usb_device.write( + endpoint, + chunk, + timeout=self.write_timeout, + ) + if bytes_written != len(chunk): + raise OSError( + "USB write returned " + f"{bytes_written} of {len(chunk)} bytes" + ) + total_written += bytes_written + preview = self._format_bytes_preview(data) + self._debug( + "DECtalk USB wrote " + f"{total_written} {description} bytes " + f"to endpoint 0x{endpoint:02x}: {preview}", + debug.DebugLevel.INFO, + on_any_level=True, + ) + except (self.usb_core.USBError, OSError) as error: + self._debug( + f"DECtalk USB write failed: {error}", + debug.DebugLevel.ERROR, + on_any_level=True, + ) + + def _dispose_usb_device(self, usb_device): + try: + self.usb_util.dispose_resources(usb_device) + except Exception as error: + self._debug( + f"DECtalk USB resource disposal failed: {error}", + debug.DebugLevel.WARNING, + ) + + def _load_usb_modules(self): + import usb.core + import usb.util + + return usb.core, usb.util diff --git a/tests/unit/test_dectalk_usb_driver.py b/tests/unit/test_dectalk_usb_driver.py new file mode 100644 index 00000000..84ffe2ce --- /dev/null +++ b/tests/unit/test_dectalk_usb_driver.py @@ -0,0 +1,273 @@ +import threading +import time +from unittest.mock import Mock + +import pytest + +from fenrirscreenreader.speechDriver import dectalkUsbDriver + + +class FakeUsbDevice: + def __init__(self): + self.writes = [] + + def get_active_configuration(self): + return object() + + def write(self, endpoint, data, timeout=None): + payload = bytes(data) + self.writes.append((endpoint, payload, timeout)) + return len(payload) + + +class FakeUsbCore: + USBError = OSError + + class NoBackendError(ValueError): + pass + + def __init__(self, device, find_error=None): + self.device = device + self.find_error = find_error + self.find_calls = [] + + def find(self, **kwargs): + self.find_calls.append(kwargs) + if self.find_error: + raise self.find_error + return self.device + + +class FakeUsbUtil: + def __init__( + self, + claim_error=None, + release_error=None, + dispose_error=None, + ): + self.claim_error = claim_error + self.release_error = release_error + self.dispose_error = dispose_error + self.claimed = [] + self.released = [] + self.disposed = [] + + def claim_interface(self, device, interface): + if self.claim_error: + raise self.claim_error + self.claimed.append((device, interface)) + + def release_interface(self, device, interface): + if self.release_error: + raise self.release_error + self.released.append((device, interface)) + + def dispose_resources(self, device): + if self.dispose_error: + raise self.dispose_error + self.disposed.append(device) + + +class BlockingUsbDevice(FakeUsbDevice): + def __init__(self): + super().__init__() + self.normal_write_started = threading.Event() + self.release_normal_write = threading.Event() + self.immediate_write_finished = threading.Event() + + def write(self, endpoint, data, timeout=None): + if endpoint == 0x02: + self.normal_write_started.set() + self.release_normal_write.wait(timeout=1.0) + result = super().write(endpoint, data, timeout=timeout) + if endpoint == 0x04: + self.immediate_write_finished.set() + return result + + +def build_environment(): + return { + "runtime": { + "SettingsManager": Mock(), + "DebugManager": Mock(), + } + } + + +def initialized_driver(monkeypatch): + device = FakeUsbDevice() + usb_core = FakeUsbCore(device) + usb_util = FakeUsbUtil() + speech_driver = dectalkUsbDriver.driver() + monkeypatch.setattr( + speech_driver, + "_load_usb_modules", + lambda: (usb_core, usb_util), + ) + speech_driver.initialize(build_environment()) + return speech_driver, device, usb_core, usb_util + + +def wait_for_writes(device, count, timeout=1.0): + deadline = time.monotonic() + timeout + while len(device.writes) < count and time.monotonic() < deadline: + time.sleep(0.01) + + +def test_dectalk_usb_driver_finds_and_claims_native_device(monkeypatch): + speech_driver, device, usb_core, usb_util = initialized_driver(monkeypatch) + try: + assert usb_core.find_calls == [ + {"idVendor": 0x0DD0, "idProduct": 0x2001} + ] + assert usb_util.claimed == [(device, 0)] + assert speech_driver._is_initialized + finally: + speech_driver.shutdown() + + assert usb_util.released == [(device, 0)] + assert usb_util.disposed == [device] + + +def test_dectalk_usb_driver_writes_commands_and_speech(monkeypatch): + speech_driver, device, _, _ = initialized_driver(monkeypatch) + try: + speech_driver.set_rate(1.0) + speech_driver.set_pitch(1.0) + speech_driver.set_volume(0.5) + speech_driver.speak("Hello\nworld") + wait_for_writes(device, 4) + + assert device.writes == [ + (0x02, b"[:ra 650]", 1000), + (0x02, b"[:dv ap 350]", 1000), + (0x02, b"[:vo 50]", 1000), + (0x02, b"Hello world\x0b", 1000), + ] + finally: + speech_driver.shutdown() + + +def test_dectalk_usb_driver_chunks_normal_writes(monkeypatch): + speech_driver, device, _, _ = initialized_driver(monkeypatch) + try: + speech_driver._write_bytes(b"x" * 70, "speech") + assert device.writes == [ + (0x02, b"x" * 64, 1000), + (0x02, b"x" * 6, 1000), + ] + finally: + speech_driver.shutdown() + + +def test_dectalk_usb_driver_uses_immediate_cancel_endpoint(monkeypatch): + speech_driver, device, _, _ = initialized_driver(monkeypatch) + try: + speech_driver.cancel() + assert device.writes == [(0x04, b"\x06", 1000)] + finally: + speech_driver.shutdown() + + +def test_dectalk_usb_cancel_bypasses_blocked_normal_write(monkeypatch): + device = BlockingUsbDevice() + usb_core = FakeUsbCore(device) + usb_util = FakeUsbUtil() + speech_driver = dectalkUsbDriver.driver() + monkeypatch.setattr( + speech_driver, + "_load_usb_modules", + lambda: (usb_core, usb_util), + ) + speech_driver.initialize(build_environment()) + normal_thread = threading.Thread( + target=speech_driver._write_bytes, + args=(b"normal speech", "speech"), + ) + cancel_thread = threading.Thread(target=speech_driver.cancel) + try: + normal_thread.start() + assert device.normal_write_started.wait(timeout=0.2) + cancel_thread.start() + assert device.immediate_write_finished.wait(timeout=0.2) + finally: + device.release_normal_write.set() + normal_thread.join(timeout=1.0) + cancel_thread.join(timeout=1.0) + speech_driver.shutdown() + + +def test_dectalk_usb_driver_reports_missing_pyusb(monkeypatch): + speech_driver = dectalkUsbDriver.driver() + + def missing_pyusb(): + raise ImportError("No module named usb") + + monkeypatch.setattr(speech_driver, "_load_usb_modules", missing_pyusb) + + with pytest.raises(RuntimeError, match="PyUSB"): + speech_driver.initialize(build_environment()) + + +def test_dectalk_usb_driver_reports_missing_libusb_backend(monkeypatch): + speech_driver = dectalkUsbDriver.driver() + backend_error = FakeUsbCore.NoBackendError("No backend available") + usb_core = FakeUsbCore(None, find_error=backend_error) + monkeypatch.setattr( + speech_driver, + "_load_usb_modules", + lambda: (usb_core, FakeUsbUtil()), + ) + + with pytest.raises(RuntimeError, match="discovery failed.*backend"): + speech_driver.initialize(build_environment()) + + +def test_dectalk_usb_driver_reports_missing_device(monkeypatch): + speech_driver = dectalkUsbDriver.driver() + usb_core = FakeUsbCore(None) + usb_util = FakeUsbUtil() + monkeypatch.setattr( + speech_driver, + "_load_usb_modules", + lambda: (usb_core, usb_util), + ) + + with pytest.raises(RuntimeError, match="0dd0:2001"): + speech_driver.initialize(build_environment()) + + +def test_dectalk_usb_driver_reports_interface_permission_error(monkeypatch): + speech_driver = dectalkUsbDriver.driver() + device = FakeUsbDevice() + usb_core = FakeUsbCore(device) + usb_util = FakeUsbUtil(claim_error=PermissionError("access denied")) + monkeypatch.setattr( + speech_driver, + "_load_usb_modules", + lambda: (usb_core, usb_util), + ) + + with pytest.raises(RuntimeError, match="open failed.*access denied"): + speech_driver.initialize(build_environment()) + + +def test_dectalk_usb_shutdown_survives_cleanup_errors(monkeypatch): + device = FakeUsbDevice() + usb_core = FakeUsbCore(device) + usb_util = FakeUsbUtil( + release_error=OSError("release failed"), + dispose_error=OSError("dispose failed"), + ) + speech_driver = dectalkUsbDriver.driver() + monkeypatch.setattr( + speech_driver, + "_load_usb_modules", + lambda: (usb_core, usb_util), + ) + speech_driver.initialize(build_environment()) + + speech_driver.shutdown() + + assert not speech_driver._is_initialized + assert speech_driver.usb_device is None diff --git a/tests/unit/test_settings_validation.py b/tests/unit/test_settings_validation.py index 45c7fc2c..a7a64c4e 100644 --- a/tests/unit/test_settings_validation.py +++ b/tests/unit/test_settings_validation.py @@ -74,6 +74,9 @@ class TestSpeechSettingsValidation: self.manager._validate_setting_value("speech", "driver", "speechdDriver") self.manager._validate_setting_value("speech", "driver", "genericDriver") self.manager._validate_setting_value("speech", "driver", "dectalkDriver") + self.manager._validate_setting_value( + "speech", "driver", "dectalkUsbDriver" + ) self.manager._validate_setting_value("speech", "driver", "doubletalkDriver") self.manager._validate_setting_value("speech", "driver", "litetalkDriver") self.manager._validate_setting_value("speech", "driver", "tripletalkDriver")