From 61f4a76bba9955c222c811e490d234f527e2b33d Mon Sep 17 00:00:00 2001 From: Claudia Pellegrino Date: Mon, 5 Aug 2024 15:46:33 +0200 Subject: [PATCH 01/14] Make error handling more robust --- itchcraft/api.py | 8 +++++++- itchcraft/backend.py | 11 ++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/itchcraft/api.py b/itchcraft/api.py index ca7702b..cd5fa8a 100644 --- a/itchcraft/api.py +++ b/itchcraft/api.py @@ -2,7 +2,11 @@ from . import prefs from .devices import find_bite_healers -from .errors import CliError, BiteHealerError +from .errors import ( + BackendInitializationError, + BiteHealerError, + CliError, +) from .format import format_table from .logging import get_logger from .prefs import ( @@ -66,5 +70,7 @@ def start( ) try: start_with_preferences(preferences) + except BackendInitializationError as e: + raise CliError(e) from e except BiteHealerError as e: raise CliError(e) from e diff --git a/itchcraft/backend.py b/itchcraft/backend.py index 3631b9b..051459e 100644 --- a/itchcraft/backend.py +++ b/itchcraft/backend.py @@ -48,7 +48,12 @@ class UsbBulkTransferDevice(BulkTransferDevice): endpoint_in: usb.core.Endpoint def __init__(self, device: usb.core.Device) -> None: - device.set_configuration() + try: + device.set_configuration() + except usb.core.USBError as ex: + raise BackendInitializationError( + f'Unable to connect to {device.product}: {ex}' + ) from ex config = cast( usb.core.Configuration, device.get_active_configuration(), @@ -60,13 +65,13 @@ def __init__(self, device: usb.core.Device) -> None: self.endpoint_out = _find_endpoint(interface, _match_out) except EndpointNotFound as ex: raise BackendInitializationError( - f'Outbound endpoint not found for {device}', + f'Outbound endpoint not found for {device.product}', ) from ex try: self.endpoint_in = _find_endpoint(interface, _match_in) except EndpointNotFound as ex: raise BackendInitializationError( - f'Inbound endpoint not found for {device}', + f'Inbound endpoint not found for {device.product}', ) from ex def bulk_transfer( From f01f308d6acfabfc4ff6ad0487babfd01385903e Mon Sep 17 00:00:00 2001 From: Claudia Pellegrino Date: Mon, 5 Aug 2024 22:52:17 +0200 Subject: [PATCH 02/14] Fix name and declared type --- itchcraft/backend.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/itchcraft/backend.py b/itchcraft/backend.py index 051459e..91398d2 100644 --- a/itchcraft/backend.py +++ b/itchcraft/backend.py @@ -108,8 +108,8 @@ def _find_endpoint( return cast(usb.core.Endpoint, endpoint) -def _match_in(endpoint: usb.core.Endpoint) -> bool: - address = endpoint.bEndpointAddress # pyright: ignore +def _match_in(device: usb.core.Device) -> bool: + address = device.bEndpointAddress # pyright: ignore return bool( usb.util.endpoint_direction(address) == usb.util.ENDPOINT_IN ) From f24f2d920864ba3151ca0dba6671062c8a18adce Mon Sep 17 00:00:00 2001 From: Claudia Pellegrino Date: Mon, 5 Aug 2024 22:53:16 +0200 Subject: [PATCH 03/14] Fix style, formatting --- itchcraft/cli.py | 4 +++- itchcraft/heat_it.py | 2 +- itchcraft/logging.py | 7 +++++-- itchcraft/support.py | 6 ++++++ 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/itchcraft/cli.py b/itchcraft/cli.py index 73472b0..bc0add7 100644 --- a/itchcraft/cli.py +++ b/itchcraft/cli.py @@ -19,8 +19,10 @@ def _version_text() -> str: if __version__ is None: return 'Itchcraft (unknown version)' if os.path.exists(PYPROJECT_TOML): - return f'Itchcraft v{__version__}' \ + return ( + f'Itchcraft v{__version__}' + f' (in development at {PROJECT_ROOT})' + ) return f'Itchcraft v{__version__}' diff --git a/itchcraft/heat_it.py b/itchcraft/heat_it.py index 3be5787..7bcb995 100644 --- a/itchcraft/heat_it.py +++ b/itchcraft/heat_it.py @@ -22,7 +22,7 @@ class HeatItDevice(BiteHealer): - """A heat-it bite healer, configured over USB.""" + """A “heat it” bite healer, configured over USB.""" device: BulkTransferDevice diff --git a/itchcraft/logging.py b/itchcraft/logging.py index b2a563c..2a741a0 100644 --- a/itchcraft/logging.py +++ b/itchcraft/logging.py @@ -16,14 +16,17 @@ def get_logger(name: str) -> python_logging.Logger: class _CustomFormatter(python_logging.Formatter): - _format = "[%(levelname)s] %(message)s" + _format = '[%(levelname)s] %(message)s' FORMATS = { python_logging.DEBUG: Style.DIM + _format + Style.RESET_ALL, python_logging.INFO: Style.DIM + _format + Style.RESET_ALL, python_logging.WARNING: Fore.YELLOW + _format + Style.RESET_ALL, python_logging.ERROR: Fore.RED + _format + Style.RESET_ALL, - python_logging.CRITICAL: Style.BRIGHT + Fore.RED + _format + Style.RESET_ALL + python_logging.CRITICAL: Style.BRIGHT + + Fore.RED + + _format + + Style.RESET_ALL, } def format(self, record: python_logging.LogRecord) -> str: diff --git a/itchcraft/support.py b/itchcraft/support.py index eb1e41f..5bee30e 100644 --- a/itchcraft/support.py +++ b/itchcraft/support.py @@ -42,6 +42,12 @@ class VidPid(NamedTuple): vid: int pid: int + def __str__(self) -> str: + return ( + f'{self.vid:04x}:{self.pid:04x}' + + f' (VID {self.vid}, PID {self.pid})' + ) + @contextmanager def _heat_it_device( From 71e98e2e7ad6dd895f83f49522b24f6dc88ef8c3 Mon Sep 17 00:00:00 2001 From: Claudia Pellegrino Date: Mon, 5 Aug 2024 22:57:02 +0200 Subject: [PATCH 04/14] Introduce debug mode, add log statements --- USAGE.md | 9 +++++++++ itchcraft/backend.py | 4 ++++ itchcraft/cli.py | 4 +++- itchcraft/devices.py | 5 +++++ itchcraft/heat_it.py | 3 ++- itchcraft/logging.py | 6 +++++- itchcraft/settings.py | 3 +++ 7 files changed, 31 insertions(+), 3 deletions(-) diff --git a/USAGE.md b/USAGE.md index ba611eb..c8ee427 100644 --- a/USAGE.md +++ b/USAGE.md @@ -55,6 +55,15 @@ One of the values `regular` or `sensitive`. The default is `sensitive`, the safer setting of the two. +# Environment + +Itchcraft supports the following environment variable: + +`DEBUG` +: If set to a non-zero value, causes Itchcraft to enable debug-level +: logging. Also decreases some retry counters and prints stack traces +: for errors where it normally wouldn’t. + # Monitoring the bite healer’s state once activated ## Monitoring the state by observing the LED color (recommended) diff --git a/itchcraft/backend.py b/itchcraft/backend.py index 91398d2..951ad4e 100644 --- a/itchcraft/backend.py +++ b/itchcraft/backend.py @@ -8,6 +8,9 @@ import usb.core # type: ignore from .errors import BackendInitializationError, EndpointNotFound +from .logging import get_logger + +logger = get_logger(__name__) class BulkTransferDevice(ABC): @@ -54,6 +57,7 @@ def __init__(self, device: usb.core.Device) -> None: raise BackendInitializationError( f'Unable to connect to {device.product}: {ex}' ) from ex + logger.debug('Configuration successful') config = cast( usb.core.Configuration, device.get_active_configuration(), diff --git a/itchcraft/cli.py b/itchcraft/cli.py index bc0add7..ff9e72c 100644 --- a/itchcraft/cli.py +++ b/itchcraft/cli.py @@ -9,7 +9,7 @@ from . import __version__, api, fire_workarounds from .errors import CliError from .logging import get_logger -from .settings import PROJECT_ROOT, PYPROJECT_TOML +from .settings import debugMode, PROJECT_ROOT, PYPROJECT_TOML logger = get_logger(__name__) @@ -38,6 +38,8 @@ def run(*args: str) -> NoReturn: try: fire.Fire(api.Api, command=list(args) + sys.argv[1:]) except CliError as e: + if debugMode: + raise e logger.error(e) sys.exit(1) sys.exit(0) diff --git a/itchcraft/devices.py b/itchcraft/devices.py index 63e251d..14a9f02 100644 --- a/itchcraft/devices.py +++ b/itchcraft/devices.py @@ -6,8 +6,11 @@ import usb.core # type: ignore from .device import from_usb_device, BiteHealerMetadata +from .logging import get_logger from .support import SUPPORT_STATEMENTS, SupportStatement, VidPid +logger = get_logger(__name__) + def find_bite_healers() -> Iterator[BiteHealerMetadata]: """Finds available bite healers.""" @@ -24,8 +27,10 @@ def find_bite_healers() -> Iterator[BiteHealerMetadata]: vid_pid = VidPid(vid=device.idVendor, pid=device.idProduct) if (statement := vid_pid_dict.get(vid_pid)) is None: + logger.debug('Ignoring USB device %s', vid_pid) continue + logger.debug('Detected bite healer %s', vid_pid) yield from_usb_device( usb_device=device, support_statement=statement, diff --git a/itchcraft/heat_it.py b/itchcraft/heat_it.py index 7bcb995..49d9760 100644 --- a/itchcraft/heat_it.py +++ b/itchcraft/heat_it.py @@ -13,6 +13,7 @@ from .backend import BulkTransferDevice from .logging import get_logger from .prefs import Preferences +from .settings import debugMode from .types import BiteHealer @@ -86,7 +87,7 @@ def _command( @retry( reraise=True, retry=retry_if_exception_type(usb.core.USBError), # type: ignore - stop=stop_after_attempt(10), # type: ignore + stop=stop_after_attempt(3 if debugMode else 10), # type: ignore wait=wait_fixed(1), # type: ignore ) def self_test(self) -> None: diff --git a/itchcraft/logging.py b/itchcraft/logging.py index 2a741a0..43884e3 100644 --- a/itchcraft/logging.py +++ b/itchcraft/logging.py @@ -4,11 +4,15 @@ from colorama import Fore, Style +from .settings import debugMode + def get_logger(name: str) -> python_logging.Logger: """Instantiate a custom logger with color support.""" logger = python_logging.getLogger(name) - logger.setLevel(python_logging.DEBUG) + logger.setLevel( + python_logging.DEBUG if debugMode else python_logging.INFO + ) handler = python_logging.StreamHandler() handler.setFormatter(_CustomFormatter()) logger.addHandler(handler) diff --git a/itchcraft/settings.py b/itchcraft/settings.py index c882614..41990bd 100644 --- a/itchcraft/settings.py +++ b/itchcraft/settings.py @@ -1,7 +1,10 @@ """A place for shared paths and settings.""" +import os from pathlib import Path PROJECT_ROOT = Path(__file__).parent.parent.absolute() PACKAGE_ROOT = Path(__file__).parent.absolute() PYPROJECT_TOML = PROJECT_ROOT / 'pyproject.toml' + +debugMode = bool(os.getenv('DEBUG')) From c006ea562a15ba71b2eb7932235ae3b52f7d6930 Mon Sep 17 00:00:00 2001 From: Claudia Pellegrino Date: Mon, 5 Aug 2024 23:01:19 +0200 Subject: [PATCH 05/14] Skip configuring USB device if already configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some devices don’t allow re-configuration if they’re already configured (example: bite away pro). Add a check whether the device is already configured, and if so, skip re-configuration. --- itchcraft/backend.py | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/itchcraft/backend.py b/itchcraft/backend.py index 951ad4e..87cb87c 100644 --- a/itchcraft/backend.py +++ b/itchcraft/backend.py @@ -52,16 +52,29 @@ class UsbBulkTransferDevice(BulkTransferDevice): def __init__(self, device: usb.core.Device) -> None: try: - device.set_configuration() - except usb.core.USBError as ex: - raise BackendInitializationError( - f'Unable to connect to {device.product}: {ex}' - ) from ex - logger.debug('Configuration successful') - config = cast( - usb.core.Configuration, - device.get_active_configuration(), - ) + config = cast( + usb.core.Configuration, + device.get_active_configuration(), + ) + except usb.core.USBError: + logger.debug('Device has no active configuration') + config = None + else: + logger.debug('Device already configured') + logger.debug('Active configuration: %s', config) + + if config is None: + try: + device.set_configuration() + except usb.core.USBError as ex: + raise BackendInitializationError( + f'Unable to connect to {device.product}: {ex}' + ) from ex + logger.debug('Configuration successful') + config = cast( + usb.core.Configuration, + device.get_active_configuration(), + ) interface = config[(0, 0)] self.device = device From 83d7541cf9f73f0624a6d6b4c24920502626cda0 Mon Sep 17 00:00:00 2001 From: Claudia Pellegrino Date: Tue, 6 Aug 2024 00:47:02 +0200 Subject: [PATCH 06/14] pylint: ignore imports for similarities --- .pylintrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pylintrc b/.pylintrc index 1505387..c7ce122 100644 --- a/.pylintrc +++ b/.pylintrc @@ -410,7 +410,7 @@ ignore-comments=yes ignore-docstrings=yes # Ignore imports when computing similarities. -ignore-imports=no +ignore-imports=yes # Minimum lines number of a similarity. min-similarity-lines=4 From 61a9350c2f5f7164ef052436bb431b7afb27651c Mon Sep 17 00:00:00 2001 From: Claudia Pellegrino Date: Tue, 6 Aug 2024 08:11:07 +0200 Subject: [PATCH 07/14] Reduce complexity of __init__ --- itchcraft/backend.py | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/itchcraft/backend.py b/itchcraft/backend.py index 87cb87c..d65b696 100644 --- a/itchcraft/backend.py +++ b/itchcraft/backend.py @@ -51,19 +51,7 @@ class UsbBulkTransferDevice(BulkTransferDevice): endpoint_in: usb.core.Endpoint def __init__(self, device: usb.core.Device) -> None: - try: - config = cast( - usb.core.Configuration, - device.get_active_configuration(), - ) - except usb.core.USBError: - logger.debug('Device has no active configuration') - config = None - else: - logger.debug('Device already configured') - logger.debug('Active configuration: %s', config) - - if config is None: + if (config := _get_config_if_exists(device)) is None: try: device.set_configuration() except usb.core.USBError as ex: @@ -125,6 +113,23 @@ def _find_endpoint( return cast(usb.core.Endpoint, endpoint) +def _get_config_if_exists( + device: usb.core.Device, +) -> Optional[usb.core.Configuration]: + try: + config = cast( + usb.core.Configuration, + device.get_active_configuration(), + ) + except usb.core.USBError: + logger.debug('Device has no active configuration') + config = None + else: + logger.debug('Device already configured') + logger.debug('Active configuration: %s', config) + return config + + def _match_in(device: usb.core.Device) -> bool: address = device.bEndpointAddress # pyright: ignore return bool( From ad8962dbe76f7ff0d493f53c549f6c1892d60a34 Mon Sep 17 00:00:00 2001 From: Claudia Pellegrino Date: Tue, 6 Aug 2024 08:12:15 +0200 Subject: [PATCH 08/14] Fix style, add logging --- itchcraft/backend.py | 14 ++++++++++---- itchcraft/heat_it.py | 4 +--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/itchcraft/backend.py b/itchcraft/backend.py index d65b696..8443f8b 100644 --- a/itchcraft/backend.py +++ b/itchcraft/backend.py @@ -63,8 +63,8 @@ def __init__(self, device: usb.core.Device) -> None: usb.core.Configuration, device.get_active_configuration(), ) - interface = config[(0, 0)] + interface = config[(0, 0)] self.device = device try: self.endpoint_out = _find_endpoint(interface, _match_out) @@ -72,23 +72,29 @@ def __init__(self, device: usb.core.Device) -> None: raise BackendInitializationError( f'Outbound endpoint not found for {device.product}', ) from ex + logger.debug('Found outbound endpoint: %s', self.endpoint_out) try: self.endpoint_in = _find_endpoint(interface, _match_in) except EndpointNotFound as ex: raise BackendInitializationError( f'Inbound endpoint not found for {device.product}', ) from ex + logger.debug('Found inbound endpoint: %s', self.endpoint_in) def bulk_transfer( self, request: Union[list[int], bytes, bytearray], ) -> bytes: - response = array.array('B', bytearray(self.MAX_RESPONSE_LENGTH)) + buffer = array.array('B', bytearray(self.MAX_RESPONSE_LENGTH)) assert self.device.write(self.endpoint_out, request) == len( request ) - bytes_received = self.device.read(self.endpoint_in, response) - return response[:bytes_received].tobytes() + num_bytes_received = self.device.read(self.endpoint_in, buffer) + response = buffer[:num_bytes_received].tobytes() + logger.debug( + 'Got response: %s (%s)', response.hex(' '), response + ) + return response @property def product_name(self) -> Optional[str]: diff --git a/itchcraft/heat_it.py b/itchcraft/heat_it.py index 49d9760..0ae3c04 100644 --- a/itchcraft/heat_it.py +++ b/itchcraft/heat_it.py @@ -91,9 +91,7 @@ def _command( wait=wait_fixed(1), # type: ignore ) def self_test(self) -> None: - """Tries up to five times to test the bootloader and obtain - the device status. - """ + """Tests the bootloader and obtains the device status.""" logger.debug('Response: %s', self.test_bootloader().hex(' ')) logger.debug('Response: %s', self.get_status().hex(' ')) From 9570b00335012be27eaeaa75bbb6fa324b864edd Mon Sep 17 00:00:00 2001 From: Claudia Pellegrino Date: Tue, 6 Aug 2024 08:16:28 +0200 Subject: [PATCH 09/14] Detach driver if interface busy --- itchcraft/backend.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/itchcraft/backend.py b/itchcraft/backend.py index 8443f8b..ad743d9 100644 --- a/itchcraft/backend.py +++ b/itchcraft/backend.py @@ -66,6 +66,9 @@ def __init__(self, device: usb.core.Device) -> None: interface = config[(0, 0)] self.device = device + + _detach_driver_if_needed(device, interface.index) + try: self.endpoint_out = _find_endpoint(interface, _match_out) except EndpointNotFound as ex: @@ -105,6 +108,18 @@ def serial_number(self) -> Optional[str]: return cast(Optional[str], self.device.serial_number) +def _detach_driver_if_needed( + device: usb.core.Device, interface_index: int +) -> None: + if device.is_kernel_driver_active(interface_index): + logger.debug( + 'Detaching driver from interface #%d', + interface_index, + ) + device.detach_kernel_driver(interface_index) + logger.debug('Driver successfully detached') + + def _find_endpoint( interface: usb.core.Interface, custom_match: Callable[[usb.core.Endpoint], bool], From 0265256c4e819e3c8b782891fbfe865a7ac3e064 Mon Sep 17 00:00:00 2001 From: Claudia Pellegrino Date: Fri, 9 Aug 2024 14:59:43 +0200 Subject: [PATCH 10/14] Add typing stubs for pyusb --- itchcraft/backend.py | 42 ++++------ itchcraft/device.py | 2 +- itchcraft/devices.py | 2 +- itchcraft/heat_it.py | 10 +-- itchcraft/stubs/usb/backend.pyi | 4 + itchcraft/stubs/usb/core.pyi | 95 +++++++++++++++++++++++ itchcraft/stubs/usb/util.pyi | 37 +++++++++ itchcraft/support.py | 2 +- itchcraft/{types.py => types/__init__.py} | 7 +- itchcraft/types/usb.py | 16 ++++ pyproject.toml | 3 +- tests/test_heat_it.py | 8 +- 12 files changed, 186 insertions(+), 42 deletions(-) create mode 100644 itchcraft/stubs/usb/backend.pyi create mode 100644 itchcraft/stubs/usb/core.pyi create mode 100644 itchcraft/stubs/usb/util.pyi rename itchcraft/{types.py => types/__init__.py} (74%) create mode 100644 itchcraft/types/usb.py diff --git a/itchcraft/backend.py b/itchcraft/backend.py index ad743d9..8944a1b 100644 --- a/itchcraft/backend.py +++ b/itchcraft/backend.py @@ -3,12 +3,14 @@ from abc import ABC, abstractmethod import array from collections.abc import Callable -from typing import Optional, Union, cast +from typing import Optional -import usb.core # type: ignore +import usb.core +import usb.util from .errors import BackendInitializationError, EndpointNotFound from .logging import get_logger +from .types import SizedPayload, usb as usb_types logger = get_logger(__name__) @@ -18,10 +20,7 @@ class BulkTransferDevice(ABC): endpoints.""" @abstractmethod - def bulk_transfer( - self, - request: Union[list[int], bytes, bytearray], - ) -> bytes: + def bulk_transfer(self, request: SizedPayload) -> bytes: """Sends a payload via USB bulk transfer and waits for a response from the device. @@ -59,10 +58,7 @@ def __init__(self, device: usb.core.Device) -> None: f'Unable to connect to {device.product}: {ex}' ) from ex logger.debug('Configuration successful') - config = cast( - usb.core.Configuration, - device.get_active_configuration(), - ) + config = device.get_active_configuration() interface = config[(0, 0)] self.device = device @@ -84,10 +80,7 @@ def __init__(self, device: usb.core.Device) -> None: ) from ex logger.debug('Found inbound endpoint: %s', self.endpoint_in) - def bulk_transfer( - self, - request: Union[list[int], bytes, bytearray], - ) -> bytes: + def bulk_transfer(self, request: SizedPayload) -> bytes: buffer = array.array('B', bytearray(self.MAX_RESPONSE_LENGTH)) assert self.device.write(self.endpoint_out, request) == len( request @@ -101,15 +94,15 @@ def bulk_transfer( @property def product_name(self) -> Optional[str]: - return cast(Optional[str], self.device.product) + return self.device.product @property def serial_number(self) -> Optional[str]: - return cast(Optional[str], self.device.serial_number) + return self.device.serial_number def _detach_driver_if_needed( - device: usb.core.Device, interface_index: int + device: usb.core.Device, interface_index: usb_types.InterfaceIndex ) -> None: if device.is_kernel_driver_active(interface_index): logger.debug( @@ -131,17 +124,14 @@ def _find_endpoint( ) ) is None: raise EndpointNotFound('find_descriptor returned None') - return cast(usb.core.Endpoint, endpoint) + return endpoint def _get_config_if_exists( device: usb.core.Device, ) -> Optional[usb.core.Configuration]: try: - config = cast( - usb.core.Configuration, - device.get_active_configuration(), - ) + config = device.get_active_configuration() except usb.core.USBError: logger.debug('Device has no active configuration') config = None @@ -151,15 +141,15 @@ def _get_config_if_exists( return config -def _match_in(device: usb.core.Device) -> bool: - address = device.bEndpointAddress # pyright: ignore +def _match_in(endpoint: usb.core.Endpoint) -> bool: + address = endpoint.bEndpointAddress return bool( usb.util.endpoint_direction(address) == usb.util.ENDPOINT_IN ) -def _match_out(device: usb.core.Device) -> bool: - address = device.bEndpointAddress # pyright: ignore +def _match_out(endpoint: usb.core.Endpoint) -> bool: + address = endpoint.bEndpointAddress return bool( usb.util.endpoint_direction(address) == usb.util.ENDPOINT_OUT ) diff --git a/itchcraft/device.py b/itchcraft/device.py index 3d7980c..f9bf7d0 100644 --- a/itchcraft/device.py +++ b/itchcraft/device.py @@ -6,7 +6,7 @@ import functools from typing import cast, Literal, Optional, Union -import usb.core # type: ignore +import usb.core from .logging import get_logger from .support import SupportStatement diff --git a/itchcraft/devices.py b/itchcraft/devices.py index 14a9f02..d8c3ce5 100644 --- a/itchcraft/devices.py +++ b/itchcraft/devices.py @@ -3,7 +3,7 @@ from collections.abc import Iterator, Generator from typing import Any, cast -import usb.core # type: ignore +import usb.core from .device import from_usb_device, BiteHealerMetadata from .logging import get_logger diff --git a/itchcraft/heat_it.py b/itchcraft/heat_it.py index 0ae3c04..2ac4c16 100644 --- a/itchcraft/heat_it.py +++ b/itchcraft/heat_it.py @@ -2,19 +2,19 @@ from collections.abc import Iterable from functools import reduce -from typing import Optional, Union +from typing import Optional from tenacity import retry from tenacity.retry import retry_if_exception_type from tenacity.stop import stop_after_attempt from tenacity.wait import wait_fixed -import usb.core # type: ignore +import usb.core from .backend import BulkTransferDevice from .logging import get_logger from .prefs import Preferences from .settings import debugMode -from .types import BiteHealer +from .types import BiteHealer, SizedPayload RESPONSE_LENGTH = 12 @@ -74,9 +74,7 @@ def checksum(payload: Iterable[int]) -> int: ) def _command( - self, - request: Union[list[int], bytes, bytearray], - command_name: Optional[str] = None, + self, request: SizedPayload, command_name: Optional[str] = None ) -> bytes: if command_name is not None: logger.info('Sending command: %s', command_name) diff --git a/itchcraft/stubs/usb/backend.pyi b/itchcraft/stubs/usb/backend.pyi new file mode 100644 index 0000000..bd90c9d --- /dev/null +++ b/itchcraft/stubs/usb/backend.pyi @@ -0,0 +1,4 @@ +# pylint: disable=missing-class-docstring, missing-module-docstring, too-few-public-methods + +class IBackend: + pass diff --git a/itchcraft/stubs/usb/core.pyi b/itchcraft/stubs/usb/core.pyi new file mode 100644 index 0000000..15ae1db --- /dev/null +++ b/itchcraft/stubs/usb/core.pyi @@ -0,0 +1,95 @@ +# pylint: disable=invalid-name, missing-class-docstring, missing-function-docstring, missing-module-docstring, no-self-use, too-few-public-methods, too-many-arguments, unused-argument + +from collections.abc import Iterator +import array +from typing import ( + Any, + Callable, + Literal, + Optional, + overload, + TYPE_CHECKING, +) + +import usb.backend + +from itchcraft.types.usb import ( + EndpointAddress, + EndpointIndex, + InterfaceIndex, + Payload, + ProductId, + VendorId, +) + +class Configuration: + def __getitem__(self, index: tuple[int, int]) -> 'Interface': ... + +@overload +def find( + find_all: Literal[True], + backend: Optional[usb.backend.IBackend] = ..., + custom_match: Optional[Callable[[Device], bool]] = ..., + **args: dict[Any, Any], +) -> Iterator[Device]: ... +@overload +def find( + find_all: Literal[False] = ..., + backend: Optional[usb.backend.IBackend] = ..., + custom_match: Optional[Callable[[Device], bool]] = ..., + **args: dict[Any, Any], +) -> Optional[Device]: ... + +# https://github.com/python/mypy/issues/5264#issuecomment-399407428 +if TYPE_CHECKING: # pylint: disable=consider-ternary-expression + _ArrayOfInts = array.array[int] +else: + _ArrayOfInts = array.array + +class Device: + idVendor: VendorId + idProduct: ProductId + product: Optional[str] + serial_number: Optional[str] + + def get_active_configuration(self) -> Configuration: ... + def set_configuration( + self, configuration: Optional[Configuration] = ... + ) -> None: ... + def is_kernel_driver_active( + self, interface_index: InterfaceIndex + ) -> bool: ... + def detach_kernel_driver( + self, interface: InterfaceIndex + ) -> None: ... + @overload + def read( + self, + endpoint: 'Endpoint', + size_or_buffer: int, + timeout: Optional[int] = ..., + ) -> _ArrayOfInts: ... + @overload + def read( + self, + endpoint: 'Endpoint', + size_or_buffer: Payload, + timeout: Optional[int] = ..., + ) -> int: ... + def write( + self, + endpoint: 'Endpoint', + data: Payload, + timeout: Optional[int] = ..., + ) -> int: ... + +class Endpoint: + bEndpointAddress: EndpointAddress + +class Interface: + index: InterfaceIndex + def __iter__(self) -> Iterator[Endpoint]: ... + def __getitem__(self, index: EndpointIndex) -> Endpoint: ... + +class USBError(IOError): + pass diff --git a/itchcraft/stubs/usb/util.pyi b/itchcraft/stubs/usb/util.pyi new file mode 100644 index 0000000..7ba6d79 --- /dev/null +++ b/itchcraft/stubs/usb/util.pyi @@ -0,0 +1,37 @@ +# pylint: disable=missing-class-docstring, missing-function-docstring, missing-module-docstring, redefined-builtin, unused-argument + +from collections.abc import Iterable, Iterator +from typing import ( + Any, + Callable, + Literal, + Optional, + overload, + TypeVar, +) + +from itchcraft.types.usb import EndpointAddress + +# endpoint direction +ENDPOINT_IN: int +ENDPOINT_OUT: int + +D = TypeVar('D') + +def endpoint_direction( + address: EndpointAddress, +) -> Literal[0x00, 0x80]: ... +@overload +def find_descriptor( + desc: Iterable[D], + find_all: Literal[True], + custom_match: Optional[Callable[[D], bool]] = ..., + **args: dict[Any, Any], +) -> Iterator[D]: ... +@overload +def find_descriptor( + desc: Iterable[D], + find_all: Literal[False] = False, + custom_match: Optional[Callable[[D], bool]] = ..., + **args: dict[Any, Any], +) -> Optional[D]: ... diff --git a/itchcraft/support.py b/itchcraft/support.py index 5bee30e..3745d20 100644 --- a/itchcraft/support.py +++ b/itchcraft/support.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Callable, NamedTuple, Optional -import usb.core # type: ignore +import usb.core from .backend import UsbBulkTransferDevice from .heat_it import HeatItDevice diff --git a/itchcraft/types.py b/itchcraft/types/__init__.py similarity index 74% rename from itchcraft/types.py rename to itchcraft/types/__init__.py index b0e1ccf..044bf2b 100644 --- a/itchcraft/types.py +++ b/itchcraft/types/__init__.py @@ -1,8 +1,13 @@ """Types used in several places""" from abc import ABC, abstractmethod +from collections.abc import Collection +from typing import Union -from .prefs import Preferences +from ..prefs import Preferences + + +SizedPayload = Union[bytes, Collection[int]] class BiteHealer(ABC): diff --git a/itchcraft/types/usb.py b/itchcraft/types/usb.py new file mode 100644 index 0000000..3323f08 --- /dev/null +++ b/itchcraft/types/usb.py @@ -0,0 +1,16 @@ +# pylint: disable=missing-class-docstring + +"""Auxiliary types used by both Itchcraft’s USB typing stubs and +by Itchcraft itself. +""" + +from collections.abc import Iterable +from typing import NewType, Union + +EndpointIndex = NewType('EndpointIndex', int) +EndpointAddress = NewType('EndpointAddress', int) +InterfaceIndex = NewType('InterfaceIndex', int) +Payload = Union[bytes, Iterable[int]] + +VendorId = NewType('VendorId', int) +ProductId = NewType('ProductId', int) diff --git a/pyproject.toml b/pyproject.toml index a69e587..8ebf08c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ disallow_untyped_decorators = true disallow_untyped_defs = true files = "itchcraft/**/*.py,tests/**/*.py" implicit_reexport = false +mypy_path = "itchcraft/stubs" no_implicit_optional = true strict_equality = true warn_redundant_casts = true @@ -68,7 +69,7 @@ hello.script = "itchcraft.cli:run('hello')" hello.help = "Run hello" html.cmd = "pdoc itchcraft !itchcraft.settings" html.help = "Browse HTML documentation" -linter.cmd = "pylint --enable-all-extensions itchcraft" +linter.cmd = "pylint --enable-all-extensions itchcraft tests" linter.help = "Check for style violations" man.cmd = "man build/man/itchcraft.1" man.help = "Open manual page" diff --git a/tests/test_heat_it.py b/tests/test_heat_it.py index f7ca13e..79adcfa 100644 --- a/tests/test_heat_it.py +++ b/tests/test_heat_it.py @@ -5,7 +5,7 @@ AbstractContextManager, nullcontext, ) -from typing import Optional, TypedDict, Union +from typing import Optional, TypedDict from unittest.mock import ANY import pytest @@ -22,7 +22,7 @@ SkinSensitivity, ) from itchcraft.support import SupportStatement -from itchcraft.types import BiteHealer +from itchcraft.types import BiteHealer, SizedPayload class StartParams(TypedDict, total=False): @@ -596,9 +596,7 @@ def test_adult_regular_long( class _DummyUsbBulkTransferDevice(BulkTransferDevice): - def bulk_transfer( - self, request: Union[list[int], bytes, bytearray] - ) -> bytes: + def bulk_transfer(self, _request: SizedPayload) -> bytes: return b'123456789012' @property From bc34f698ce22c83d28144386694a8cecaec2f12a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 4 Sep 2024 12:21:14 +0000 Subject: [PATCH 11/14] Bump version to 0.4.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8ebf08c..0d64c7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ warn_unused_ignores = true [tool.poetry] name = "itchcraft" -version = "0.3.0" +version = "0.4.0" description = "Tech demo for interfacing with heat-based USB insect bite healers" readme = ["README.md", "USAGE.md"] authors = ["Claudia Pellegrino "] From 4bbc7f1624a47ac09aaf1e7b613835d7e94b0376 Mon Sep 17 00:00:00 2001 From: Claudia Pellegrino Date: Wed, 4 Sep 2024 14:49:59 +0200 Subject: [PATCH 12/14] Fix regression for drivers with no detach support --- itchcraft/backend.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/itchcraft/backend.py b/itchcraft/backend.py index 8944a1b..c2d392c 100644 --- a/itchcraft/backend.py +++ b/itchcraft/backend.py @@ -104,13 +104,26 @@ def serial_number(self) -> Optional[str]: def _detach_driver_if_needed( device: usb.core.Device, interface_index: usb_types.InterfaceIndex ) -> None: - if device.is_kernel_driver_active(interface_index): + try: + want_to_detach_driver = device.is_kernel_driver_active( + interface_index + ) + except NotImplementedError: logger.debug( - 'Detaching driver from interface #%d', + 'Note: unable to detach driver for interface #%d; proceeding', interface_index, ) - device.detach_kernel_driver(interface_index) - logger.debug('Driver successfully detached') + want_to_detach_driver = False + + if not want_to_detach_driver: + return + + logger.debug( + 'Detaching driver from interface #%d', + interface_index, + ) + device.detach_kernel_driver(interface_index) + logger.debug('Driver successfully detached') def _find_endpoint( From 3c5649da80d7599ece1d454dfa112b020ff6c493 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 4 Sep 2024 12:55:04 +0000 Subject: [PATCH 13/14] Bump version to 0.4.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0d64c7c..3b0281c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ warn_unused_ignores = true [tool.poetry] name = "itchcraft" -version = "0.4.0" +version = "0.4.1" description = "Tech demo for interfacing with heat-based USB insect bite healers" readme = ["README.md", "USAGE.md"] authors = ["Claudia Pellegrino "] From b8d440eedc774ba007d346bd9de57dcb1eca4293 Mon Sep 17 00:00:00 2001 From: Claudia Pellegrino Date: Wed, 4 Sep 2024 20:15:04 +0200 Subject: [PATCH 14/14] debian: update changelog --- debian/changelog | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/debian/changelog b/debian/changelog index 798c1e9..95b48b0 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,5 +1,12 @@ -itchcraft (0.3.0) UNRELEASED; urgency=medium +itchcraft (0.4.1) UNRELEASED; urgency=medium - * Initial release + * Detect and show connected bite healer models + * Add support for more models + * Make error handling more robust + * Bump Debian changelog, fix build deps + * Introduce debug mode + * Detach driver if interface busy + * Add typing stubs for pyusb + * Fix regression for drivers with no detach support - -- Claudia Pellegrino Tue, 30 Jul 2024 16:00:12 +0200 + -- Claudia Pellegrino Tue, 4 Sep 2024 20:14:16 +0200