From 0b31b2b2475c8dcb282806a2660cd2d9a62168d0 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Mon, 21 Oct 2024 20:14:23 +0100 Subject: [PATCH 01/86] Add .venv to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 637a4cc..dd379de 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ nosetests.xml #idea pycharm .idea +.venv \ No newline at end of file From 61cb27993ee70767efe4ea37fb2e1df07b137acc Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Mon, 21 Oct 2024 20:18:39 +0100 Subject: [PATCH 02/86] move to a src based, pyproject.toml project --- MANIFEST | 14 ------ MANIFEST.in | 4 -- blinkstick/__init__.py | 8 ---- blinkstick/_version.py | 1 - pyproject.toml | 32 +++++++++++++ setup.py | 49 -------------------- src/__init__.py | 0 src/blinkstick/__init__.py | 7 +++ {blinkstick => src/blinkstick}/blinkstick.py | 5 +- bin/blinkstick => src/blinkstick/main.py | 4 +- 10 files changed, 43 insertions(+), 81 deletions(-) delete mode 100644 MANIFEST delete mode 100644 MANIFEST.in delete mode 100644 blinkstick/__init__.py delete mode 100644 blinkstick/_version.py create mode 100644 pyproject.toml delete mode 100644 setup.py create mode 100644 src/__init__.py create mode 100644 src/blinkstick/__init__.py rename {blinkstick => src/blinkstick}/blinkstick.py (99%) rename bin/blinkstick => src/blinkstick/main.py (98%) mode change 100755 => 100644 diff --git a/MANIFEST b/MANIFEST deleted file mode 100644 index 9f67350..0000000 --- a/MANIFEST +++ /dev/null @@ -1,14 +0,0 @@ -# file GENERATED by distutils, do NOT edit -CHANGES.txt -LICENSE.txt -README.rst -setup.py -bin/blinkstick-connect.py -bin/blinkstick-cpu.py -bin/blinkstick-find.py -bin/blinkstick-info.py -bin/blinkstick-infoblock.py -bin/blinkstick-off.py -bin/blinkstick-random.py -blinkstick/__init__.py -blinkstick/blinkstick.py diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index dac1edf..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,4 +0,0 @@ -include *.txt -include LICENSE.txt -include README.rst -recursive-include bin * diff --git a/blinkstick/__init__.py b/blinkstick/__init__.py deleted file mode 100644 index 8939408..0000000 --- a/blinkstick/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from ._version import __version__ -__author__ = 'Rob Berwick, Arvydas Juskevicius' -__copyright__ = "Copyright 2013-2014, Agile Innovative Ltd" -__credits__ = ["Rob Berwick", "Arvydas Juskevicius", "Sam J Sharpe"] -__license__ = "See LICENSE.txt" -__maintainer__ = "Arvydas Juskevicius" -__email__ = "arvydas@agileinnovative.co.uk" -__status__ = "Production" diff --git a/blinkstick/_version.py b/blinkstick/_version.py deleted file mode 100644 index c68196d..0000000 --- a/blinkstick/_version.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "1.2.0" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b3fe461 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["setuptools>=42", "setuptools-scm>=8", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "BlinkStick" +description = "Python package to control BlinkStick USB devices." +readme = "README.rst" +license = { file = "LICENSE.txt" } +authors = [ + { name = "Arvydas Juskevicius", email = "arvydas@arvydas.co.uk" } +] +dependencies = [ + "pywinusb; platform_system == 'Windows'", + "pyusb>=1.0.0; platform_system != 'Windows'" +] +dynamic = ["version"] + +[project.scripts] +blinkstick = "blinkstick:main" + +[project.urls] +homepage = "https://pypi.python.org/pypi/blinkstick/" +repository = "https://github.com/arvydas/blinkstick-python" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +"blinkstick" = ["*.txt"] + +[tool.setuptools_scm] diff --git a/setup.py b/setup.py deleted file mode 100644 index 910f22b..0000000 --- a/setup.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python -import os -import re -import sys -from setuptools import setup, find_packages - - -def read(fname): - return open(os.path.join(os.path.dirname(__file__), fname)).read() - - -PKG = 'blinkstick' -VERSIONFILE = os.path.join(PKG, "_version.py") -verstr = "unknown" -try: - verstrline = open(VERSIONFILE, "rt").read() -except EnvironmentError: - pass # Okay, there is no version file. -else: - VSRE = r"(\d+\.\d+\.\d+)" - mo = re.search(VSRE, verstrline, re.M) - if mo: - verstr = mo.group(1) - else: - print("unable to find version in {0}").format(VERSIONFILE) - raise RuntimeError("if {0}.py exists, it is required to be well-formed".format(VERSIONFILE)) - -if sys.platform == "win32": - os_requires = [ - "pywinusb" - ] -else: - os_requires = [ - "pyusb>=1.0.0" - ] - -setup( - name='BlinkStick', - version=verstr, - author='Arvydas Juskevicius', - author_email='arvydas@arvydas.co.uk', - packages=find_packages(), - scripts=["bin/blinkstick"], - url='http://pypi.python.org/pypi/BlinkStick/', - license='LICENSE.txt', - description='Python package to control BlinkStick USB devices.', - long_description=read('README.rst'), - install_requires=os_requires, -) diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/blinkstick/__init__.py b/src/blinkstick/__init__.py new file mode 100644 index 0000000..15e6694 --- /dev/null +++ b/src/blinkstick/__init__.py @@ -0,0 +1,7 @@ +from .main import main +from importlib.metadata import version, PackageNotFoundError + +try: + __version__ = version("blinkstick") +except PackageNotFoundError: + __version__ = "BlinkStick package not installed" \ No newline at end of file diff --git a/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py similarity index 99% rename from blinkstick/blinkstick.py rename to src/blinkstick/blinkstick.py index bc2040b..602df4d 100644 --- a/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -1,4 +1,4 @@ -from ._version import __version__ +from importlib.metadata import version import time import sys import re @@ -1638,5 +1638,4 @@ def _remap_rgb_value_reverse(rgb_val, max_value): _remap_color_reverse(rgb_val[2], max_value)] def get_blinkstick_package_version(): - return __version__ - + return version("blinkstick") diff --git a/bin/blinkstick b/src/blinkstick/main.py old mode 100755 new mode 100644 similarity index 98% rename from bin/blinkstick rename to src/blinkstick/main.py index a6786d3..1590554 --- a/bin/blinkstick +++ b/src/blinkstick/main.py @@ -4,7 +4,6 @@ from blinkstick import blinkstick import textwrap import sys -import time import logging logging.basicConfig() @@ -70,7 +69,8 @@ def format_option(self, option): return "".join(result) def format_usage(self, usage): - return "BlinkStick control script %s\n(c) Agile Innovative Ltd 2013-2014\n\n%s" % (blinkstick.get_blinkstick_package_version(), IndentedHelpFormatter.format_usage(self, usage)) + return "BlinkStick control script %s\n(c) Agile Innovative Ltd 2013-2014\n\n%s" % ( + blinkstick.get_blinkstick_package_version(), IndentedHelpFormatter.format_usage(self, usage)) def print_info(stick): From 78bc21b13cf82fa16117714f52d2092553c56fcd Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 26 Oct 2024 13:52:54 +0100 Subject: [PATCH 03/86] add black and isort to dev dependencies --- pyproject.toml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index b3fe461..431597f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,9 @@ dependencies = [ ] dynamic = ["version"] +[project.optional-dependencies] +dev = ["black", "isort"] + [project.scripts] blinkstick = "blinkstick:main" @@ -30,3 +33,10 @@ where = ["src"] "blinkstick" = ["*.txt"] [tool.setuptools_scm] + +[tool.black] +line-length = 88 +target-version = ['py39'] + +[tool.isort] +profile = "black" \ No newline at end of file From 1cf43ec7fd44bd253043a53756cbaab132249806 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 26 Oct 2024 08:43:35 +0100 Subject: [PATCH 04/86] Abstract platform specific USB operations to specific backends Abstract away the platform specifics in the main BlinkStick class by employing a strategy pattern. --- src/blinkstick/backends/__init__.py | 0 src/blinkstick/backends/base.py | 45 ++++++ src/blinkstick/backends/unix_like.py | 89 ++++++++++++ src/blinkstick/backends/win32.py | 78 ++++++++++ src/blinkstick/blinkstick.py | 203 +++++++++------------------ src/blinkstick/constants.py | 2 + src/blinkstick/exceptions.py | 2 + src/blinkstick/main.py | 16 +-- 8 files changed, 287 insertions(+), 148 deletions(-) create mode 100644 src/blinkstick/backends/__init__.py create mode 100644 src/blinkstick/backends/base.py create mode 100644 src/blinkstick/backends/unix_like.py create mode 100644 src/blinkstick/backends/win32.py create mode 100644 src/blinkstick/constants.py create mode 100644 src/blinkstick/exceptions.py diff --git a/src/blinkstick/backends/__init__.py b/src/blinkstick/backends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/blinkstick/backends/base.py b/src/blinkstick/backends/base.py new file mode 100644 index 0000000..1e18472 --- /dev/null +++ b/src/blinkstick/backends/base.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class BaseBackend(ABC): + + serial: str | None + + def __init__(self): + self.serial = None + + @abstractmethod + def _refresh_device(self): + raise NotImplementedError + + @staticmethod + @abstractmethod + def find_blinksticks(find_all: bool = True): + raise NotImplementedError + + @staticmethod + @abstractmethod + def find_by_serial(serial: str) -> BaseBackend | None: + raise NotImplementedError + + @abstractmethod + def control_transfer(self, bmRequestType: int, bRequest: int, wValue: int, wIndex: int, data_or_wLength: bytes | int): + raise NotImplementedError + + @abstractmethod + def get_serial(self): + raise NotImplementedError + + @abstractmethod + def get_manufacturer(self): + raise NotImplementedError + + @abstractmethod + def get_version_attribute(self): + raise NotImplementedError + + @abstractmethod + def get_description(self): + raise NotImplementedError diff --git a/src/blinkstick/backends/unix_like.py b/src/blinkstick/backends/unix_like.py new file mode 100644 index 0000000..bfd8add --- /dev/null +++ b/src/blinkstick/backends/unix_like.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import usb.core +import usb.util + +from blinkstick.constants import VENDOR_ID, PRODUCT_ID +from blinkstick.backends.base import BaseBackend +from blinkstick.exceptions import BlinkStickException + + +class UnixLikeBackend(BaseBackend): + + def __init__(self, device=None): + self.device = device + super().__init__() + if device: + self.open_device() + self.serial = self.get_serial() + + def open_device(self): + if self.device is None: + raise BlinkStickException("Could not find BlinkStick...") + + if self.device.is_kernel_driver_active(0): + try: + self.device.detach_kernel_driver(0) + except usb.core.USBError as e: + raise BlinkStickException("Could not detach kernel driver: %s" % str(e)) + + return True + + def _refresh_device(self): + if not self.serial: + return False + if devices := self.find_by_serial(self.serial): + self.device = devices[0] + self.open_device() + return True + + @staticmethod + def find_blinksticks(find_all: bool = True): + return usb.core.find(find_all=find_all, idVendor=VENDOR_ID, idProduct=PRODUCT_ID) + + @staticmethod + def find_by_serial(serial: str) -> list | None: + for d in UnixLikeBackend.find_blinksticks(): + try: + if usb.util.get_string(d, 3, 1033) == serial: + devices = [d] + return devices + except Exception as e: + print("{0}".format(e)) + + def control_transfer(self, bmRequestType: int, bRequest: int, wValue: int, wIndex: int, + data_or_wLength: bytes | int): + try: + return self.device.ctrl_transfer(bmRequestType, bRequest, wValue, wIndex, data_or_wLength) + except usb.USBError: + # Could not communicate with BlinkStick backend + # attempt to find it again based on serial + + if self._refresh_device(): + return self.device.ctrl_transfer(bmRequestType, bRequest, wValue, wIndex, data_or_wLength) + else: + raise BlinkStickException("Could not communicate with BlinkStick {0} - it may have been removed".format(self.serial)) + + def get_serial(self) -> str: + return self._usb_get_string(3) + + def get_manufacturer(self): + return self._usb_get_string(1) + + def get_version_attribute(self): + return self.device.bcdDevice + + def get_description(self): + return self._usb_get_string(2) + + def _usb_get_string(self, index): + try: + return usb.util.get_string(self.device, index, 1033) + except usb.USBError: + # Could not communicate with BlinkStick backend + # attempt to find it again based on serial + + if self._refresh_device(): + return usb.util.get_string(self.device, index, 1033) + else: + raise BlinkStickException("Could not communicate with BlinkStick {0} - it may have been removed".format(self.serial)) \ No newline at end of file diff --git a/src/blinkstick/backends/win32.py b/src/blinkstick/backends/win32.py new file mode 100644 index 0000000..42ad5b4 --- /dev/null +++ b/src/blinkstick/backends/win32.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import sys +from ctypes import * + +from pywinusb import hid + +from blinkstick.constants import VENDOR_ID, PRODUCT_ID +from blinkstick.backends.base import BaseBackend +from blinkstick.exceptions import BlinkStickException + + +class Win32Backend(BaseBackend): + def __init__(self, device=None): + super().__init__() + self.device = device + if device: + self.device.open() + self.reports = self.device.find_feature_reports() + self.serial = self.get_serial() + + @staticmethod + def find_by_serial(serial: str) -> list | None: + devices = [d for d in Win32Backend.find_blinksticks() + if d.serial_number == serial] + + if len(devices) > 0: + return devices + + + def _refresh_device(self): + # TODO This is weird semantics. fix up return values to be more sensible + if not self.serial: + return False + if devices := self.find_by_serial(self.serial): + self.device = devices[0] + self.device.open() + self.reports = self.device.find_feature_reports() + return True + + @staticmethod + def find_blinksticks(find_all: bool = True): + devices = hid.HidDeviceFilter(vendor_id =VENDOR_ID, product_id =PRODUCT_ID).get_devices() + if find_all: + return devices + elif len(devices) > 0: + return devices[0] + else: + return None + + + def control_transfer(self, bmRequestType, bRequest, wValue, wIndex, data_or_wLength): + if bmRequestType == 0x20: + if sys.version_info[0] < 3: + data = (c_ubyte * len(data_or_wLength))(*[c_ubyte(ord(c)) for c in data_or_wLength]) + else: + data = (c_ubyte * len(data_or_wLength))(*[c_ubyte(c) for c in data_or_wLength]) + data[0] = wValue + if not self.device.send_feature_report(data): + if self._refresh_device(): + self.device.send_feature_report(data) + else: + raise BlinkStickException("Could not communicate with BlinkStick {0} - it may have been removed".format(self.serial)) + + elif bmRequestType == 0x80 | 0x20: + return self.reports[wValue - 1].get() + + def get_serial(self): + return self.device.serial_number + + def get_manufacturer(self): + return self.device.vendor_name + + def get_version_attribute(self): + return self.device.version_number + + def get_description(self): + return self.device.product_name \ No newline at end of file diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 602df4d..8a330be 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -2,15 +2,20 @@ import time import sys import re + +from blinkstick.constants import VENDOR_ID, PRODUCT_ID +from blinkstick.exceptions import BlinkStickException + try: from collections.abc import Callable except ImportError: from collections import Callable if sys.platform == "win32": + from blinkstick.backends.win32 import Win32Backend as USBBackend import pywinusb.hid as hid - from ctypes import * else: + from blinkstick.backends.unix_like import UnixLikeBackend as USBBackend import usb.core import usb.util @@ -20,18 +25,12 @@ Main module to control BlinkStick and BlinkStick Pro devices. """ -VENDOR_ID = 0x20a0 -PRODUCT_ID = 0x41e5 - -class BlinkStickException(Exception): - pass - class BlinkStick(object): """ BlinkStick class is designed to control regular BlinkStick devices, or BlinkStick Pro devices in Normal or Inverse modes. Please refer to L{BlinkStick.set_mode} for more details - about BlinkStick Pro device modes. + about BlinkStick Pro backend modes. Code examples on how you can use this class are available here: @@ -205,103 +204,44 @@ def __init__(self, device=None, error_reporting=True): Constructor for the class. @type error_reporting: Boolean - @param error_reporting: display errors if they occur during communication with the device + @param error_reporting: display errors if they occur during communication with the backend """ self.error_reporting = error_reporting if device: - self.device = device - if sys.platform == "win32": - self.device.open() - self.reports = self.device.find_feature_reports() - else: - self.open_device(device) - + self.backend = USBBackend(device) self.bs_serial = self.get_serial() - def _usb_get_string(self, device, index): - try: - return usb.util.get_string(device, index, 1033) - except usb.USBError: - # Could not communicate with BlinkStick device - # attempt to find it again based on serial - - if self._refresh_device(): - return usb.util.get_string(self.device, index, 1033) - else: - raise BlinkStickException("Could not communicate with BlinkStick {0} - it may have been removed".format(self.bs_serial)) - - def _usb_ctrl_transfer(self, bmRequestType, bRequest, wValue, wIndex, data_or_wLength): - if sys.platform == "win32": - if bmRequestType == 0x20: - if sys.version_info[0] < 3: - data = (c_ubyte * len(data_or_wLength))(*[c_ubyte(ord(c)) for c in data_or_wLength]) - else: - data = (c_ubyte * len(data_or_wLength))(*[c_ubyte(c) for c in data_or_wLength]) - data[0] = wValue - if not self.device.send_feature_report(data): - if self._refresh_device(): - self.device.send_feature_report(data) - else: - raise BlinkStickException("Could not communicate with BlinkStick {0} - it may have been removed".format(self.bs_serial)) - - elif bmRequestType == 0x80 | 0x20: - return self.reports[wValue - 1].get() - else: - try: - return self.device.ctrl_transfer(bmRequestType, bRequest, wValue, wIndex, data_or_wLength) - except usb.USBError: - # Could not communicate with BlinkStick device - # attempt to find it again based on serial - - if self._refresh_device(): - return self.device.ctrl_transfer(bmRequestType, bRequest, wValue, wIndex, data_or_wLength) - else: - raise BlinkStickException("Could not communicate with BlinkStick {0} - it may have been removed".format(self.bs_serial)) - - def _refresh_device(self): - if not hasattr(self, 'bs_serial'): - return False - d = find_by_serial(self.bs_serial) - if d: - self.device = d.device - return True def get_serial(self): """ - Returns the serial number of device.:: + Returns the serial number of backend.:: BSnnnnnn-1.0 || | | |- Software minor version || | |--- Software major version || |-------- Denotes sequential number - ||----------- Denotes BlinkStick device + ||----------- Denotes BlinkStick backend - Software version defines the capabilities of the device + Software version defines the capabilities of the backend @rtype: str - @return: Serial number of the device + @return: Serial number of the backend """ - if sys.platform == "win32": - return self.device.serial_number - else: - return self._usb_get_string(self.device, 3) + return self.backend.get_serial() def get_manufacturer(self): """ - Get the manufacturer of the device + Get the manufacturer of the backend @rtype: str @return: Device manufacturer's name """ - if sys.platform == "win32": - return self.device.vendor_name - else: - return self._usb_get_string(self.device, 1) + return self.backend.get_manufacturer() def get_variant(self): """ - Get the product variant of the device. + Get the product variant of the backend. @rtype: int @return: BlinkStick.UNKNOWN, BlinkStick.BLINKSTICK, BlinkStick.BLINKSTICK_PRO and etc @@ -311,10 +251,7 @@ def get_variant(self): major = serial[-3] minor = serial[-1] - if sys.platform == "win32": - version_attribute = self.device.version_number - else: - version_attribute = self.device.bcdDevice + version_attribute = self.backend.get_version_attribute() if major == "1": return self.BLINKSTICK @@ -336,7 +273,7 @@ def get_variant(self): def get_variant_string(self): """ - Get the product variant of the device as string. + Get the product variant of the backend as string. @rtype: string @return: "BlinkStick", "BlinkStick Pro", etc @@ -360,28 +297,25 @@ def get_variant_string(self): def get_description(self): """ - Get the description of the device + Get the description of the backend @rtype: str @return: Device description """ - if sys.platform == "win32": - return self.device.product_name - else: - return self._usb_get_string(self.device, 2) + return self.backend.get_description() def set_error_reporting(self, error_reporting): """ Enable or disable error reporting @type error_reporting: Boolean - @param error_reporting: display errors if they occur during communication with the device + @param error_reporting: display errors if they occur during communication with the backend """ self.error_reporting = error_reporting def set_color(self, channel=0, index=0, red=0, green=0, blue=0, name=None, hex=None): """ - Set the color to the device as RGB + Set the color to the backend as RGB @type red: int @param red: Red color intensity 0 is off, 255 is full red intensity @@ -412,10 +346,10 @@ def set_color(self, channel=0, index=0, red=0, green=0, blue=0, name=None, hex=N report_id = 0x0005 if self.error_reporting: - self._usb_ctrl_transfer(0x20, 0x9, report_id, 0, control_string) + self.backend.control_transfer(0x20, 0x9, report_id, 0, control_string) else: try: - self._usb_ctrl_transfer(0x20, 0x9, report_id, 0, control_string) + self.backend.control_transfer(0x20, 0x9, report_id, 0, control_string) except Exception: pass @@ -443,7 +377,7 @@ def _determine_rgb(self, red=0, green=0, blue=0, name=None, hex=None): def _get_color_rgb(self, index=0): if index == 0: - device_bytes = self._usb_ctrl_transfer(0x80 | 0x20, 0x1, 0x0001, 0, 33) + device_bytes = self.backend.control_transfer(0x80 | 0x20, 0x1, 0x0001, 0, 33) if self.inverse: return [255 - device_bytes[1], 255 - device_bytes[2], 255 - device_bytes[3]] else: @@ -459,12 +393,12 @@ def _get_color_hex(self, index=0): def get_color(self, index=0, color_format='rgb'): """ - Get the current device color in the defined format. + Get the current backend color in the defined format. Currently supported formats: 1. rgb (default) - Returns values as 3-tuple (r,g,b) - 2. hex - returns current device color as hexadecimal string + 2. hex - returns current backend color as hexadecimal string >>> b = blinkstick.find_first() >>> b.set_color(red=255,green=0,blue=0) @@ -529,27 +463,27 @@ def set_led_data(self, channel, data): else: report.append(0) - self._usb_ctrl_transfer(0x20, 0x9, report_id, 0, bytes(bytearray(report))) + self.backend.control_transfer(0x20, 0x9, report_id, 0, bytes(bytearray(report))) def get_led_data(self, count): """ - Get LED data frame on the device. + Get LED data frame on the backend. @type count: int @param count: How much data to retrieve. Can be in the range of 0..64*3 @rtype: int[0..64*3] - @return: LED data currently stored in the RAM of the device + @return: LED data currently stored in the RAM of the backend """ report_id, max_leds = self._determine_report_id(count) - device_bytes = self._usb_ctrl_transfer(0x80 | 0x20, 0x1, report_id, 0, max_leds * 3 + 2) + device_bytes = self.backend.control_transfer(0x80 | 0x20, 0x1, report_id, 0, max_leds * 3 + 2) return device_bytes[2: 2 + count * 3] def set_mode(self, mode): """ - Set device mode for BlinkStick Pro. Device currently supports the following modes: + Set backend mode for BlinkStick Pro. Device currently supports the following modes: - 0 - (default) use R, G and B channels to control single RGB LED - 1 - same as 0, but inverse mode @@ -564,7 +498,7 @@ def set_mode(self, mode): """ control_string = bytes(bytearray([4, mode])) - self._usb_ctrl_transfer(0x20, 0x9, 0x0004, 0, control_string) + self.backend.control_transfer(0x20, 0x9, 0x0004, 0, control_string) def get_mode(self): """ @@ -582,7 +516,7 @@ def get_mode(self): @return: Device mode """ - device_bytes = self._usb_ctrl_transfer(0x80 | 0x20, 0x1, 0x0004, 0, 2) + device_bytes = self.backend.control_transfer(0x80 | 0x20, 0x1, 0x0004, 0, 2) if len(device_bytes) >= 2: return device_bytes[1] @@ -598,7 +532,7 @@ def set_led_count(self, count): """ control_string = bytes(bytearray([0x81, count])) - self._usb_ctrl_transfer(0x20, 0x9, 0x81, 0, control_string) + self.backend.control_transfer(0x20, 0x9, 0x81, 0, control_string) def get_led_count(self): @@ -609,7 +543,7 @@ def get_led_count(self): @return: Number of LEDs """ - device_bytes = self._usb_ctrl_transfer(0x80 | 0x20, 0x1, 0x81, 0, 2) + device_bytes = self.backend.control_transfer(0x80 | 0x20, 0x1, 0x81, 0, 2) if len(device_bytes) >= 2: return device_bytes[1] @@ -618,17 +552,17 @@ def get_led_count(self): def get_info_block1(self): """ - Get the infoblock1 of the device. + Get the infoblock1 of the backend. This is a 32 byte array that can contain any data. It's supposed to - hold the "Name" of the device making it easier to identify rather than + hold the "Name" of the backend making it easier to identify rather than a serial number. @rtype: str - @return: InfoBlock1 currently stored on the device + @return: InfoBlock1 currently stored on the backend """ - device_bytes = self._usb_ctrl_transfer(0x80 | 0x20, 0x1, 0x0002, 0, 33) + device_bytes = self.backend.control_transfer(0x80 | 0x20, 0x1, 0x0002, 0, 33) result = "" for i in device_bytes[1:]: if i == 0: @@ -638,14 +572,14 @@ def get_info_block1(self): def get_info_block2(self): """ - Get the infoblock2 of the device. + Get the infoblock2 of the backend. This is a 32 byte array that can contain any data. @rtype: str - @return: InfoBlock2 currently stored on the device + @return: InfoBlock2 currently stored on the backend """ - device_bytes = self._usb_ctrl_transfer(0x80 | 0x20, 0x1, 0x0003, 0, 33) + device_bytes = self.backend.control_transfer(0x80 | 0x20, 0x1, 0x0003, 0, 33) result = "" for i in device_bytes[1:]: if i == 0: @@ -679,9 +613,9 @@ def set_info_block1(self, data): It fills the rest of 32 bytes with zeros. @type data: str - @param data: InfoBlock1 for the device to set + @param data: InfoBlock1 for the backend to set """ - self._usb_ctrl_transfer(0x20, 0x9, 0x0002, 0, self._data_to_message(data)) + self.backend.control_transfer(0x20, 0x9, 0x0002, 0, self._data_to_message(data)) def set_info_block2(self, data): """ @@ -690,13 +624,13 @@ def set_info_block2(self, data): It fills the rest of 32 bytes with zeros. @type data: str - @param data: InfoBlock2 for the device to set + @param data: InfoBlock2 for the backend to set """ - self._usb_ctrl_transfer(0x20, 0x9, 0x0003, 0, self._data_to_message(data)) + self.backend.control_transfer(0x20, 0x9, 0x0003, 0, self._data_to_message(data)) def set_random_color(self): """ - Sets random color to the device. + Sets random color to the backend. """ self.set_color(name="random") @@ -814,15 +748,15 @@ def morph(self, channel=0, index=0, red=0, green=0, blue=0, name=None, hex=None, self.set_color(channel=channel, index=index, red=r_end, green=g_end, blue=b_end) def open_device(self, d): - """Open device. + """Open backend. @param d: Device to open """ - if self.device is None: + if self.backend is None: raise BlinkStickException("Could not find BlinkStick...") - if self.device.is_kernel_driver_active(0): + if self.backend.is_kernel_driver_active(0): try: - self.device.detach_kernel_driver(0) + self.backend.detach_kernel_driver(0) except usb.core.USBError as e: raise BlinkStickException("Could not detach kernel driver: %s" % str(e)) @@ -831,7 +765,7 @@ def open_device(self, d): def get_inverse(self): """ Get the value of inverse mode. This applies only to BlinkStick. Please use L{set_mode} for BlinkStick Pro - to permanently set the inverse mode to the device. + to permanently set the inverse mode to the backend. @rtype: bool @return: True if inverse mode, otherwise false @@ -841,7 +775,7 @@ def get_inverse(self): def set_inverse(self, value): """ Set inverse mode. This applies only to BlinkStick. Please use L{set_mode} for BlinkStick Pro - to permanently set the inverse mode to the device. + to permanently set the inverse mode to the backend. @type value: bool @param value: True/False to set the inverse mode @@ -975,7 +909,7 @@ def _name_to_rgb(self, name): class BlinkStickPro(object): """ BlinkStickPro class is specifically designed to control the individually - addressable LEDs connected to the device. The tutorials section contains + addressable LEDs connected to the backend. The tutorials section contains all the details on how to connect them to BlinkStick Pro. U{http://www.blinkstick.com/help/tutorials} @@ -1079,7 +1013,7 @@ def clear(self): def off(self): """ - Set all pixels to black in on the device. + Set all pixels to black in on the backend. """ self.clear() self.send_data_all() @@ -1132,7 +1066,7 @@ def send_data_all(self): class BlinkStickProMatrix(BlinkStickPro): """ BlinkStickProMatrix class is specifically designed to control the individually - addressable LEDs connected to the device and arranged in a matrix. The tutorials section contains + addressable LEDs connected to the backend and arranged in a matrix. The tutorials section contains all the details on how to connect them to BlinkStick Pro with matrices. U{http://www.blinkstick.com/help/tutorials/blinkstick-pro-adafruit-neopixel-matrices} @@ -1546,7 +1480,7 @@ def send_data(self, channel): def _find_blicksticks(find_all=True): if sys.platform == "win32": - devices = hid.HidDeviceFilter(vendor_id = VENDOR_ID, product_id = PRODUCT_ID).get_devices() + devices = hid.HidDeviceFilter(vendor_id =VENDOR_ID, product_id =PRODUCT_ID).get_devices() if find_all: return devices elif len(devices) > 0: @@ -1566,7 +1500,7 @@ def find_all(): @return: a list of BlinkStick objects or None if no devices found """ result = [] - for d in _find_blicksticks(): + for d in USBBackend.find_blinksticks(): result.extend([BlinkStick(device=d)]) return result @@ -1579,7 +1513,7 @@ def find_first(): @rtype: BlinkStick @return: BlinkStick object or None if no devices are found """ - d = _find_blicksticks(find_all=False) + d = USBBackend.find_blinksticks(find_all=False) if d: return BlinkStick(device=d) @@ -1587,24 +1521,13 @@ def find_first(): def find_by_serial(serial=None): """ - Find BlinkStick device based on serial number. + Find BlinkStick backend based on serial number. @rtype: BlinkStick @return: BlinkStick object or None if no devices are found """ - devices = [] - if sys.platform == "win32": - devices = [d for d in _find_blicksticks() - if d.serial_number == serial] - else: - for d in _find_blicksticks(): - try: - if usb.util.get_string(d, 3, 1033) == serial: - devices = [d] - break - except Exception as e: - print("{0}".format(e)) + devices = USBBackend.find_by_serial(serial=serial) if devices: return BlinkStick(device=devices[0]) diff --git a/src/blinkstick/constants.py b/src/blinkstick/constants.py new file mode 100644 index 0000000..9bd4840 --- /dev/null +++ b/src/blinkstick/constants.py @@ -0,0 +1,2 @@ +VENDOR_ID = 0x20a0 +PRODUCT_ID = 0x41e5 diff --git a/src/blinkstick/exceptions.py b/src/blinkstick/exceptions.py new file mode 100644 index 0000000..e1f04c6 --- /dev/null +++ b/src/blinkstick/exceptions.py @@ -0,0 +1,2 @@ +class BlinkStickException(Exception): + pass diff --git a/src/blinkstick/main.py b/src/blinkstick/main.py index 1590554..2b24bde 100644 --- a/src/blinkstick/main.py +++ b/src/blinkstick/main.py @@ -74,7 +74,7 @@ def format_usage(self, usage): def print_info(stick): - print("Found device:") + print("Found backend:") print(" Manufacturer: {0}".format(stick.get_manufacturer())) print(" Description: {0}".format(stick.get_description())) print(" Variant: {0}".format(stick.get_variant_string())) @@ -108,7 +108,7 @@ def main(): parser.add_option("-s", "--serial", dest="serial", - help="Select device by serial number. If unspecified, action will be performed on all BlinkSticks.") + help="Select backend by serial number. If unspecified, action will be performed on all BlinkSticks.") parser.add_option("-v", "--verbose", action="store_true", dest="verbose", @@ -116,7 +116,7 @@ def main(): group = OptionGroup(parser, "Change color", - "These options control the color of the device ") + "These options control the color of the backend ") group.add_option("--channel", default=0, dest="channel", @@ -136,7 +136,7 @@ def main(): group.add_option("--set-color", dest="color", - help="Set the color for the device. This can also be the last argument for the script. " + help="Set the color for the backend. This can also be the last argument for the script. " "The value can either be a named color, hex value, 'random' or 'off'.\n\n" "CSS color names are defined http://www.w3.org/TR/css3-color/ e.g. red, green, blue. " "Specify color using hexadecimal color value e.g. 'FF3366'") @@ -186,7 +186,7 @@ def main(): parser.add_option_group(group) group = OptionGroup(parser, "Device data and behaviour", - "These options will change device mode and data stored internally. ") + "These options will change backend mode and data stored internally. ") group.add_option("--set-mode", default=0, dest="mode", @@ -194,11 +194,11 @@ def main(): group.add_option("--set-infoblock1", dest="infoblock1", - help="Set the first info block for the device.") + help="Set the first info block for the backend.") group.add_option("--set-infoblock2", dest="infoblock2", - help="Set the second info block for the device.") + help="Set the second info block for the backend.") parser.add_option_group(group) @@ -221,7 +221,7 @@ def main(): sticks = [blinkstick.find_by_serial(options.serial)] if len(sticks) == 0: - print("BlinkStick with serial number " + options.device + " not found...") + print("BlinkStick with serial number " + options.backend + " not found...") return 64 #Global action From 28a646bb4c6fa62a94e4495290b0d29312d31ead Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 26 Oct 2024 17:24:43 +0100 Subject: [PATCH 05/86] add random color example script --- examples/random_color.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 examples/random_color.py diff --git a/examples/random_color.py b/examples/random_color.py new file mode 100644 index 0000000..cce40f0 --- /dev/null +++ b/examples/random_color.py @@ -0,0 +1,25 @@ +from blinkstick import blinkstick +from blinkstick.exceptions import BlinkStickException + +bs = blinkstick.find_first() + +if bs is None: + print("Could not find any BlinkSticks") + exit() + +while True: + try: + print(f"Serial: {bs.get_serial()}") + print(f"Manufacturer: {bs.get_manufacturer()}") + print(f"Description: {bs.get_description()}") + # print(f"Mode: {bs.get_mode()}") + print(f"InfoBlock1: {bs.get_info_block1()}") + print(f"InfoBlock2: {bs.get_info_block2()}") + print(f"Variant: {bs.get_variant()}") + print(f"Variant String: {bs.get_variant_string()}") + while True: + bs.set_random_color() + print(f"Color: {bs.get_color()}") + input("Press Enter to continue...") + except BlinkStickException: + print("Could not communicate with BlinkStick") From 3c8b3964a72780f391bc97e27fcacbb8c3118e6c Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Tue, 22 Oct 2024 19:33:44 +0100 Subject: [PATCH 06/86] Add test dependencies to pyproject.toml --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 431597f..ce2e228 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dynamic = ["version"] [project.optional-dependencies] dev = ["black", "isort"] +test = ["coverage", "pytest", "pytest-cov", "pytest-mock"] [project.scripts] blinkstick = "blinkstick:main" From 5e1d024624fcb1fccc802578da8967eedab19551 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Tue, 22 Oct 2024 20:22:14 +0100 Subject: [PATCH 07/86] add super minimal test stubs Add a minimal instantiation test for all 3 blinkstick classes. --- tests/__init__.py | 0 tests/test_blinkstick.py | 6 ++++++ tests/test_blinkstick_matrix.py | 6 ++++++ tests/test_blinkstick_pro_matrix.py | 6 ++++++ 4 files changed, 18 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/test_blinkstick.py create mode 100644 tests/test_blinkstick_matrix.py create mode 100644 tests/test_blinkstick_pro_matrix.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_blinkstick.py b/tests/test_blinkstick.py new file mode 100644 index 0000000..a7b4016 --- /dev/null +++ b/tests/test_blinkstick.py @@ -0,0 +1,6 @@ +from blinkstick.blinkstick import BlinkStick + + +def test_instantiate(): + bs = BlinkStick() + assert bs is not None diff --git a/tests/test_blinkstick_matrix.py b/tests/test_blinkstick_matrix.py new file mode 100644 index 0000000..987c62e --- /dev/null +++ b/tests/test_blinkstick_matrix.py @@ -0,0 +1,6 @@ +from blinkstick.blinkstick import BlinkStickProMatrix + + +def test_instantiate(): + bs = BlinkStickProMatrix() + assert bs is not None diff --git a/tests/test_blinkstick_pro_matrix.py b/tests/test_blinkstick_pro_matrix.py new file mode 100644 index 0000000..1034acb --- /dev/null +++ b/tests/test_blinkstick_pro_matrix.py @@ -0,0 +1,6 @@ +from blinkstick.blinkstick import BlinkStickPro + + +def test_instantiate(): + bs = BlinkStickPro() + assert bs is not None From d72178b9bf6c2aac9468a1d7c189eb173b859681 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Tue, 22 Oct 2024 20:28:54 +0100 Subject: [PATCH 08/86] add pytest github workflow --- .github/workflows/pytest.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/pytest.yml diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 0000000..afd1f2b --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,33 @@ +name: Run Pytest + +on: + pull_request: + branches: + - master + +jobs: + test: + name: Test on ${{ matrix.os }} with Python ${{ matrix.python-version }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[test] + + - name: Run tests + run: | + pytest \ No newline at end of file From 566adf48b6ef17db7d6947b0cacbc5b51f31972e Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 2 Nov 2024 11:39:34 +0000 Subject: [PATCH 09/86] run tests on feature branches --- .github/workflows/pytest.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index afd1f2b..381e1a7 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -4,6 +4,7 @@ on: pull_request: branches: - master + - feature/* jobs: test: From 0da5feec56b1d43c1a870a6da6ef6af793ebfb2e Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 2 Nov 2024 11:53:16 +0000 Subject: [PATCH 10/86] use py3 style classes --- src/blinkstick/blinkstick.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 8a330be..9d54106 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -26,7 +26,7 @@ """ -class BlinkStick(object): +class BlinkStick: """ BlinkStick class is designed to control regular BlinkStick devices, or BlinkStick Pro devices in Normal or Inverse modes. Please refer to L{BlinkStick.set_mode} for more details @@ -906,7 +906,7 @@ def _name_to_rgb(self, name): """ return self._hex_to_rgb(self._name_to_hex(name)) -class BlinkStickPro(object): +class BlinkStickPro: """ BlinkStickPro class is specifically designed to control the individually addressable LEDs connected to the backend. The tutorials section contains From d50bca486f5b53064411c0b3a308f4f7c0e18169 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 27 Oct 2024 08:24:52 +0000 Subject: [PATCH 11/86] Move color logic to separate package --- src/blinkstick/blinkstick.py | 166 ++--------------------------------- src/blinkstick/colors.py | 161 +++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 159 deletions(-) create mode 100644 src/blinkstick/colors.py diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 9d54106..90286fe 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -1,8 +1,9 @@ -from importlib.metadata import version -import time -import sys import re +import sys +import time +from importlib.metadata import version +from blinkstick.colors import Color, HEX_COLOR_RE from blinkstick.constants import VENDOR_ID, PRODUCT_ID from blinkstick.exceptions import BlinkStickException @@ -37,155 +38,7 @@ class BlinkStick: U{https://github.com/arvydas/blinkstick-python/wiki} """ - _names_to_hex = {'aliceblue': '#f0f8ff', - 'antiquewhite': '#faebd7', - 'aqua': '#00ffff', - 'aquamarine': '#7fffd4', - 'azure': '#f0ffff', - 'beige': '#f5f5dc', - 'bisque': '#ffe4c4', - 'black': '#000000', - 'blanchedalmond': '#ffebcd', - 'blue': '#0000ff', - 'blueviolet': '#8a2be2', - 'brown': '#a52a2a', - 'burlywood': '#deb887', - 'cadetblue': '#5f9ea0', - 'chartreuse': '#7fff00', - 'chocolate': '#d2691e', - 'coral': '#ff7f50', - 'cornflowerblue': '#6495ed', - 'cornsilk': '#fff8dc', - 'crimson': '#dc143c', - 'cyan': '#00ffff', - 'darkblue': '#00008b', - 'darkcyan': '#008b8b', - 'darkgoldenrod': '#b8860b', - 'darkgray': '#a9a9a9', - 'darkgrey': '#a9a9a9', - 'darkgreen': '#006400', - 'darkkhaki': '#bdb76b', - 'darkmagenta': '#8b008b', - 'darkolivegreen': '#556b2f', - 'darkorange': '#ff8c00', - 'darkorchid': '#9932cc', - 'darkred': '#8b0000', - 'darksalmon': '#e9967a', - 'darkseagreen': '#8fbc8f', - 'darkslateblue': '#483d8b', - 'darkslategray': '#2f4f4f', - 'darkslategrey': '#2f4f4f', - 'darkturquoise': '#00ced1', - 'darkviolet': '#9400d3', - 'deeppink': '#ff1493', - 'deepskyblue': '#00bfff', - 'dimgray': '#696969', - 'dimgrey': '#696969', - 'dodgerblue': '#1e90ff', - 'firebrick': '#b22222', - 'floralwhite': '#fffaf0', - 'forestgreen': '#228b22', - 'fuchsia': '#ff00ff', - 'gainsboro': '#dcdcdc', - 'ghostwhite': '#f8f8ff', - 'gold': '#ffd700', - 'goldenrod': '#daa520', - 'gray': '#808080', - 'grey': '#808080', - 'green': '#008000', - 'greenyellow': '#adff2f', - 'honeydew': '#f0fff0', - 'hotpink': '#ff69b4', - 'indianred': '#cd5c5c', - 'indigo': '#4b0082', - 'ivory': '#fffff0', - 'khaki': '#f0e68c', - 'lavender': '#e6e6fa', - 'lavenderblush': '#fff0f5', - 'lawngreen': '#7cfc00', - 'lemonchiffon': '#fffacd', - 'lightblue': '#add8e6', - 'lightcoral': '#f08080', - 'lightcyan': '#e0ffff', - 'lightgoldenrodyellow': '#fafad2', - 'lightgray': '#d3d3d3', - 'lightgrey': '#d3d3d3', - 'lightgreen': '#90ee90', - 'lightpink': '#ffb6c1', - 'lightsalmon': '#ffa07a', - 'lightseagreen': '#20b2aa', - 'lightskyblue': '#87cefa', - 'lightslategray': '#778899', - 'lightslategrey': '#778899', - 'lightsteelblue': '#b0c4de', - 'lightyellow': '#ffffe0', - 'lime': '#00ff00', - 'limegreen': '#32cd32', - 'linen': '#faf0e6', - 'magenta': '#ff00ff', - 'maroon': '#800000', - 'mediumaquamarine': '#66cdaa', - 'mediumblue': '#0000cd', - 'mediumorchid': '#ba55d3', - 'mediumpurple': '#9370d8', - 'mediumseagreen': '#3cb371', - 'mediumslateblue': '#7b68ee', - 'mediumspringgreen': '#00fa9a', - 'mediumturquoise': '#48d1cc', - 'mediumvioletred': '#c71585', - 'midnightblue': '#191970', - 'mintcream': '#f5fffa', - 'mistyrose': '#ffe4e1', - 'moccasin': '#ffe4b5', - 'navajowhite': '#ffdead', - 'navy': '#000080', - 'oldlace': '#fdf5e6', - 'olive': '#808000', - 'olivedrab': '#6b8e23', - 'orange': '#ffa500', - 'orangered': '#ff4500', - 'orchid': '#da70d6', - 'palegoldenrod': '#eee8aa', - 'palegreen': '#98fb98', - 'paleturquoise': '#afeeee', - 'palevioletred': '#d87093', - 'papayawhip': '#ffefd5', - 'peachpuff': '#ffdab9', - 'peru': '#cd853f', - 'pink': '#ffc0cb', - 'plum': '#dda0dd', - 'powderblue': '#b0e0e6', - 'purple': '#800080', - 'red': '#ff0000', - 'rosybrown': '#bc8f8f', - 'royalblue': '#4169e1', - 'saddlebrown': '#8b4513', - 'salmon': '#fa8072', - 'sandybrown': '#f4a460', - 'seagreen': '#2e8b57', - 'seashell': '#fff5ee', - 'sienna': '#a0522d', - 'silver': '#c0c0c0', - 'skyblue': '#87ceeb', - 'slateblue': '#6a5acd', - 'slategray': '#708090', - 'slategrey': '#708090', - 'snow': '#fffafa', - 'springgreen': '#00ff7f', - 'steelblue': '#4682b4', - 'tan': '#d2b48c', - 'teal': '#008080', - 'thistle': '#d8bfd8', - 'tomato': '#ff6347', - 'turquoise': '#40e0d0', - 'violet': '#ee82ee', - 'wheat': '#f5deb3', - 'white': '#ffffff', - 'whitesmoke': '#f5f5f5', - 'yellow': '#ffff00', - 'yellowgreen': '#9acd32'} - - HEX_COLOR_RE = re.compile(r'^#([a-fA-F0-9]{3}|[a-fA-F0-9]{6})$') + UNKNOWN = 0 BLINKSTICK = 1 @@ -819,12 +672,7 @@ def _name_to_hex(self, name): >>> _name_to_hex('goldenrod') '#daa520' """ - normalized = name.lower() - try: - hex_value = self._names_to_hex[normalized] - except KeyError: - raise ValueError("'%s' is not defined as a named color." % (name)) - return hex_value + return Color.from_name(name) def _hex_to_rgb(self, hex_value): """ @@ -878,7 +726,7 @@ def _normalize_hex(self, hex_value): """ try: - hex_digits = self.HEX_COLOR_RE.match(hex_value).groups()[0] + hex_digits = HEX_COLOR_RE.match(hex_value).groups()[0] except AttributeError: raise ValueError("'%s' is not a valid hexadecimal color value." % hex_value) if len(hex_digits) == 3: diff --git a/src/blinkstick/colors.py b/src/blinkstick/colors.py new file mode 100644 index 0000000..28910bb --- /dev/null +++ b/src/blinkstick/colors.py @@ -0,0 +1,161 @@ +import re +from enum import Enum + + +HEX_COLOR_RE = re.compile(r'^#([a-fA-F0-9]{3}|[a-fA-F0-9]{6})$') + +class Color(Enum): + ALICEBLUE = "#f0f8ff" + ANTIQUEWHITE = "#faebd7" + AQUA = "#00ffff" + AQUAMARINE = "#7fffd4" + AZURE = "#f0ffff" + BEIGE = "#f5f5dc" + BISQUE = "#ffe4c4" + BLACK = "#000000" + BLANCHEDALMOND = "#ffebcd" + BLUE = "#0000ff" + BLUEVIOLET = "#8a2be2" + BROWN = "#a52a2a" + BURLYWOOD = "#deb887" + CADETBLUE = "#5f9ea0" + CHARTREUSE = "#7fff00" + CHOCOLATE = "#d2691e" + CORAL = "#ff7f50" + CORNFLOWERBLUE = "#6495ed" + CORNSILK = "#fff8dc" + CRIMSON = "#dc143c" + CYAN = "#00ffff" + DARKBLUE = "#00008b" + DARKCYAN = "#008b8b" + DARKGOLDENROD = "#b8860b" + DARKGRAY = "#a9a9a9" + DARKGREY = "#a9a9a9" + DARKGREEN = "#006400" + DARKKHAKI = "#bdb76b" + DARKMAGENTA = "#8b008b" + DARKOLIVEGREEN = "#556b2f" + DARKORANGE = "#ff8c00" + DARKORCHID = "#9932cc" + DARKRED = "#8b0000" + DARKSALMON = "#e9967a" + DARKSEAGREEN = "#8fbc8f" + DARKSLATEBLUE = "#483d8b" + DARKSLATEGRAY = "#2f4f4f" + DARKSLATEGREY = "#2f4f4f" + DARKTURQUOISE = "#00ced1" + DARKVIOLET = "#9400d3" + DEEPPINK = "#ff1493" + DEEPSKYBLUE = "#00bfff" + DIMGRAY = "#696969" + DIMGREY = "#696969" + DODGERBLUE = "#1e90ff" + FIREBRICK = "#b22222" + FLORALWHITE = "#fffaf0" + FORESTGREEN = "#228b22" + FUCHSIA = "#ff00ff" + GAINSBORO = "#dcdcdc" + GHOSTWHITE = "#f8f8ff" + GOLD = "#ffd700" + GOLDENROD = "#daa520" + GRAY = "#808080" + GREY = "#808080" + GREEN = "#008000" + GREENYELLOW = "#adff2f" + HONEYDEW = "#f0fff0" + HOTPINK = "#ff69b4" + INDIANRED = "#cd5c5c" + INDIGO = "#4b0082" + IVORY = "#fffff0" + KHAKI = "#f0e68c" + LAVENDER = "#e6e6fa" + LAVENDERBLUSH = "#fff0f5" + LAWNGREEN = "#7cfc00" + LEMONCHIFFON = "#fffacd" + LIGHTBLUE = "#add8e6" + LIGHTCORAL = "#f08080" + LIGHTCYAN = "#e0ffff" + LIGHTGOLDENRODYELLOW = "#fafad2" + LIGHTGRAY = "#d3d3d3" + LIGHTGREY = "#d3d3d3" + LIGHTGREEN = "#90ee90" + LIGHTPINK = "#ffb6c1" + LIGHTSALMON = "#ffa07a" + LIGHTSEAGREEN = "#20b2aa" + LIGHTSKYBLUE = "#87cefa" + LIGHTSLATEGRAY = "#778899" + LIGHTSLATEGREY = "#778899" + LIGHTSTEELBLUE = "#b0c4de" + LIGHTYELLOW = "#ffffe0" + LIME = "#00ff00" + LIMEGREEN = "#32cd32" + LINEN = "#faf0e6" + MAGENTA = "#ff00ff" + MAROON = "#800000" + MEDIUMAQUAMARINE = "#66cdaa" + MEDIUMBLUE = "#0000cd" + MEDIUMORCHID = "#ba55d3" + MEDIUMPURPLE = "#9370d8" + MEDIUMSEAGREEN = "#3cb371" + MEDIUMSLATEBLUE = "#7b68ee" + MEDIUMSPRINGGREEN = "#00fa9a" + MEDIUMTURQUOISE = "#48d1cc" + MEDIUMVIOLETRED = "#c71585" + MIDNIGHTBLUE = "#191970" + MINTCREAM = "#f5fffa" + MISTYROSE = "#ffe4e1" + MOCCASIN = "#ffe4b5" + NAVAJOWHITE = "#ffdead" + NAVY = "#000080" + OLDLACE = "#fdf5e6" + OLIVE = "#808000" + OLIVEDRAB = "#6b8e23" + ORANGE = "#ffa500" + ORANGERED = "#ff4500" + ORCHID = "#da70d6" + PALEGOLDENROD = "#eee8aa" + PALEGREEN = "#98fb98" + PALETURQUOISE = "#afeeee" + PALEVIOLETRED = "#d87093" + PAPAYAWHIP = "#ffefd5" + PEACHPUFF = "#ffdab9" + PERU = "#cd853f" + PINK = "#ffc0cb" + PLUM = "#dda0dd" + POWDERBLUE = "#b0e0e6" + PURPLE = "#800080" + RED = "#ff0000" + ROSYBROWN = "#bc8f8f" + ROYALBLUE = "#4169e1" + SADDLEBROWN = "#8b4513" + SALMON = "#fa8072" + SANDYBROWN = "#f4a460" + SEAGREEN = "#2e8b57" + SEASHELL = "#fff5ee" + SIENNA = "#a0522d" + SILVER = "#c0c0c0" + SKYBLUE = "#87ceeb" + SLATEBLUE = "#6a5acd" + SLATEGRAY = "#708090" + SLATEGREY = "#708090" + SNOW = "#fffafa" + SPRINGGREEN = "#00ff7f" + STEELBLUE = "#4682b4" + TAN = "#d2b48c" + TEAL = "#008080" + THISTLE = "#d8bfd8" + TOMATO = "#ff6347" + TURQUOISE = "#40e0d0" + VIOLET = "#ee82ee" + WHEAT = "#f5deb3" + WHITE = "#ffffff" + WHITESMOKE = "#f5f5f5" + YELLOW = "#ffff00" + YELLOWGREEN = "#9acd32" + + @classmethod + def from_name(cls, name): + try: + return cls[name.upper()] + except KeyError: + raise ValueError(f"'{name}' is not defined as a named color.") From 1a3e8067ad956c91fa44221782f7af12308b8697 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 2 Nov 2024 08:45:46 +0000 Subject: [PATCH 12/86] move _name_to_hex to colors module and rename --- src/blinkstick/blinkstick.py | 24 ++---------------------- src/blinkstick/colors.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 90286fe..5c76773 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -1,9 +1,8 @@ -import re import sys import time from importlib.metadata import version -from blinkstick.colors import Color, HEX_COLOR_RE +from blinkstick.colors import HEX_COLOR_RE, name_to_hex from blinkstick.constants import VENDOR_ID, PRODUCT_ID from blinkstick.exceptions import BlinkStickException @@ -655,25 +654,6 @@ def get_max_rgb_value(self, max_rgb_value): """ return self.max_rgb_value - def _name_to_hex(self, name): - """ - Convert a color name to a normalized hexadecimal color value. - - The color name will be normalized to lower-case before being - looked up, and when no color of that name exists in the given - specification, ``ValueError`` is raised. - - Examples: - - >>> _name_to_hex('white') - '#ffffff' - >>> _name_to_hex('navy') - '#000080' - >>> _name_to_hex('goldenrod') - '#daa520' - """ - return Color.from_name(name) - def _hex_to_rgb(self, hex_value): """ Convert a hexadecimal color value to a 3-tuple of integers @@ -752,7 +732,7 @@ def _name_to_rgb(self, name): (218, 165, 32) """ - return self._hex_to_rgb(self._name_to_hex(name)) + return self._hex_to_rgb(name_to_hex(name)) class BlinkStickPro: """ diff --git a/src/blinkstick/colors.py b/src/blinkstick/colors.py index 28910bb..49e71c0 100644 --- a/src/blinkstick/colors.py +++ b/src/blinkstick/colors.py @@ -159,3 +159,23 @@ def from_name(cls, name): return cls[name.upper()] except KeyError: raise ValueError(f"'{name}' is not defined as a named color.") + + +def name_to_hex(name: str) -> str: + """ + Convert a color name to a normalized hexadecimal color value. + + The color name will be normalized to lower-case before being + looked up, and when no color of that name exists in the given + specification, ``ValueError`` is raised. + + Examples: + + >>> name_to_hex('white') + '#ffffff' + >>> name_to_hex('navy') + '#000080' + >>> name_to_hex('goldenrod') + '#daa520' + """ + return Color.from_name(name).value \ No newline at end of file From 8464ed05bcf313d46f199ea82adc57d10ec327e4 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 2 Nov 2024 08:49:06 +0000 Subject: [PATCH 13/86] move _normalize_hex to colors module and rename --- src/blinkstick/blinkstick.py | 45 ++---------------------------------- src/blinkstick/colors.py | 44 ++++++++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 44 deletions(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 5c76773..47e36b9 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -2,7 +2,7 @@ import time from importlib.metadata import version -from blinkstick.colors import HEX_COLOR_RE, name_to_hex +from blinkstick.colors import name_to_hex, normalize_hex from blinkstick.constants import VENDOR_ID, PRODUCT_ID from blinkstick.exceptions import BlinkStickException @@ -669,50 +669,9 @@ def _hex_to_rgb(self, hex_value): (0, 0, 128) """ - hex_digits = self._normalize_hex(hex_value) + hex_digits = normalize_hex(hex_value) return tuple([int(s, 16) for s in (hex_digits[1:3], hex_digits[3:5], hex_digits[5:7])]) - def _normalize_hex(self, hex_value): - """ - Normalize a hexadecimal color value to the following form and - return the result:: - - #[a-f0-9]{6} - - In other words, the following transformations are applied as - needed: - - * If the value contains only three hexadecimal digits, it is expanded to six. - - * The value is normalized to lower-case. - - If the supplied value cannot be interpreted as a hexadecimal color - value, ``ValueError`` is raised. - - Examples: - - >>> _normalize_hex('#0099cc') - '#0099cc' - >>> _normalize_hex('#0099CC') - '#0099cc' - >>> _normalize_hex('#09c') - '#0099cc' - >>> _normalize_hex('#09C') - '#0099cc' - >>> _normalize_hex('0099cc') - Traceback (most recent call last): - ... - ValueError: '0099cc' is not a valid hexadecimal color value. - - """ - try: - hex_digits = HEX_COLOR_RE.match(hex_value).groups()[0] - except AttributeError: - raise ValueError("'%s' is not a valid hexadecimal color value." % hex_value) - if len(hex_digits) == 3: - hex_digits = ''.join([2 * s for s in hex_digits]) - return '#%s' % hex_digits.lower() - def _name_to_rgb(self, name): """ Convert a color name to a 3-tuple of integers suitable for use in diff --git a/src/blinkstick/colors.py b/src/blinkstick/colors.py index 49e71c0..c1c1edb 100644 --- a/src/blinkstick/colors.py +++ b/src/blinkstick/colors.py @@ -178,4 +178,46 @@ def name_to_hex(name: str) -> str: >>> name_to_hex('goldenrod') '#daa520' """ - return Color.from_name(name).value \ No newline at end of file + return Color.from_name(name).value + + +def normalize_hex(hex_value: str) -> str: + """ + Normalize a hexadecimal color value to the following form and + return the result:: + + #[a-f0-9]{6} + + In other words, the following transformations are applied as + needed: + + * If the value contains only three hexadecimal digits, it is expanded to six. + + * The value is normalized to lower-case. + + If the supplied value cannot be interpreted as a hexadecimal color + value, ``ValueError`` is raised. + + Examples: + + >>> normalize_hex('#0099cc') + '#0099cc' + >>> normalize_hex('#0099CC') + '#0099cc' + >>> normalize_hex('#09c') + '#0099cc' + >>> normalize_hex('#09C') + '#0099cc' + >>> normalize_hex('0099cc') + Traceback (most recent call last): + ... + ValueError: '0099cc' is not a valid hexadecimal color value. + + """ + try: + hex_digits = HEX_COLOR_RE.match(hex_value).groups()[0] + except AttributeError: + raise ValueError("'%s' is not a valid hexadecimal color value." % hex_value) + if len(hex_digits) == 3: + hex_digits = ''.join([2 * s for s in hex_digits]) + return '#%s' % hex_digits.lower() \ No newline at end of file From 41f0d82eb248d8766426ea1c01bc13a27bb1ac2d Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 2 Nov 2024 08:50:27 +0000 Subject: [PATCH 14/86] move _hex_to_rgb to colors module and rename --- src/blinkstick/blinkstick.py | 24 +++--------------------- src/blinkstick/colors.py | 21 ++++++++++++++++++++- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 47e36b9..9f66f76 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -2,7 +2,7 @@ import time from importlib.metadata import version -from blinkstick.colors import name_to_hex, normalize_hex +from blinkstick.colors import name_to_hex, hex_to_rgb from blinkstick.constants import VENDOR_ID, PRODUCT_ID from blinkstick.exceptions import BlinkStickException @@ -217,7 +217,7 @@ def _determine_rgb(self, red=0, green=0, blue=0, name=None, hex=None): else: red, green, blue = self._name_to_rgb(name) elif hex: - red, green, blue = self._hex_to_rgb(hex) + red, green, blue = hex_to_rgb(hex) except ValueError: red = green = blue = 0 @@ -654,24 +654,6 @@ def get_max_rgb_value(self, max_rgb_value): """ return self.max_rgb_value - def _hex_to_rgb(self, hex_value): - """ - Convert a hexadecimal color value to a 3-tuple of integers - suitable for use in an ``rgb()`` triplet specifying that color. - - The hexadecimal value will be normalized before being converted. - - Examples: - - >>> _hex_to_rgb('#fff') - (255, 255, 255) - >>> _hex_to_rgb('#000080') - (0, 0, 128) - - """ - hex_digits = normalize_hex(hex_value) - return tuple([int(s, 16) for s in (hex_digits[1:3], hex_digits[3:5], hex_digits[5:7])]) - def _name_to_rgb(self, name): """ Convert a color name to a 3-tuple of integers suitable for use in @@ -691,7 +673,7 @@ def _name_to_rgb(self, name): (218, 165, 32) """ - return self._hex_to_rgb(name_to_hex(name)) + return hex_to_rgb(name_to_hex(name)) class BlinkStickPro: """ diff --git a/src/blinkstick/colors.py b/src/blinkstick/colors.py index c1c1edb..92a9490 100644 --- a/src/blinkstick/colors.py +++ b/src/blinkstick/colors.py @@ -220,4 +220,23 @@ def normalize_hex(hex_value: str) -> str: raise ValueError("'%s' is not a valid hexadecimal color value." % hex_value) if len(hex_digits) == 3: hex_digits = ''.join([2 * s for s in hex_digits]) - return '#%s' % hex_digits.lower() \ No newline at end of file + return '#%s' % hex_digits.lower() + + +def hex_to_rgb(hex_value: str) -> tuple[int, int, int]: + """ + Convert a hexadecimal color value to a 3-tuple of integers + suitable for use in an ``rgb()`` triplet specifying that color. + + The hexadecimal value will be normalized before being converted. + + Examples: + + >>> hex_to_rgb('#fff') + (255, 255, 255) + >>> hex_to_rgb('#000080') + (0, 0, 128) + + """ + hex_digits = normalize_hex(hex_value) + return int(hex_digits[1:3], 16), int(hex_digits[3:5], 16), int(hex_digits[5:7], 16) From 13fc46319366eef0d1029d6d34793afadd9c1193 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 2 Nov 2024 08:54:59 +0000 Subject: [PATCH 15/86] move _name_to_rgb to colors module and rename --- src/blinkstick/blinkstick.py | 24 ++---------------------- src/blinkstick/colors.py | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 9f66f76..4d2fe0c 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -2,7 +2,7 @@ import time from importlib.metadata import version -from blinkstick.colors import name_to_hex, hex_to_rgb +from blinkstick.colors import hex_to_rgb, name_to_rgb from blinkstick.constants import VENDOR_ID, PRODUCT_ID from blinkstick.exceptions import BlinkStickException @@ -215,7 +215,7 @@ def _determine_rgb(self, red=0, green=0, blue=0, name=None, hex=None): green = randint(0, 255) blue = randint(0, 255) else: - red, green, blue = self._name_to_rgb(name) + red, green, blue = name_to_rgb(name) elif hex: red, green, blue = hex_to_rgb(hex) except ValueError: @@ -654,26 +654,6 @@ def get_max_rgb_value(self, max_rgb_value): """ return self.max_rgb_value - def _name_to_rgb(self, name): - """ - Convert a color name to a 3-tuple of integers suitable for use in - an ``rgb()`` triplet specifying that color. - - The color name will be normalized to lower-case before being - looked up, and when no color of that name exists in the given - specification, ``ValueError`` is raised. - - Examples: - - >>> _name_to_rgb('white') - (255, 255, 255) - >>> _name_to_rgb('navy') - (0, 0, 128) - >>> _name_to_rgb('goldenrod') - (218, 165, 32) - - """ - return hex_to_rgb(name_to_hex(name)) class BlinkStickPro: """ diff --git a/src/blinkstick/colors.py b/src/blinkstick/colors.py index 92a9490..0a55844 100644 --- a/src/blinkstick/colors.py +++ b/src/blinkstick/colors.py @@ -240,3 +240,25 @@ def hex_to_rgb(hex_value: str) -> tuple[int, int, int]: """ hex_digits = normalize_hex(hex_value) return int(hex_digits[1:3], 16), int(hex_digits[3:5], 16), int(hex_digits[5:7], 16) + + +def name_to_rgb(name: str) -> tuple[int, int, int]: + """ + Convert a color name to a 3-tuple of integers suitable for use in + an ``rgb()`` triplet specifying that color. + + The color name will be normalized to lower-case before being + looked up, and when no color of that name exists in the given + specification, ``ValueError`` is raised. + + Examples: + + >>> name_to_rgb('white') + (255, 255, 255) + >>> name_to_rgb('navy') + (0, 0, 128) + >>> name_to_rgb('goldenrod') + (218, 165, 32) + + """ + return hex_to_rgb(name_to_hex(name)) \ No newline at end of file From b78b4b15ac30c1f29478b737cbd0bbf348505a96 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 2 Nov 2024 09:17:56 +0000 Subject: [PATCH 16/86] move remapping helpers to colors module and rename --- src/blinkstick/blinkstick.py | 39 ++++++------------------------------ src/blinkstick/colors.py | 39 +++++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 34 deletions(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 4d2fe0c..f06f8cc 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -2,7 +2,7 @@ import time from importlib.metadata import version -from blinkstick.colors import hex_to_rgb, name_to_rgb +from blinkstick.colors import hex_to_rgb, name_to_rgb, remap_color, remap_rgb_value, remap_rgb_value_reverse from blinkstick.constants import VENDOR_ID, PRODUCT_ID from blinkstick.exceptions import BlinkStickException @@ -221,7 +221,7 @@ def _determine_rgb(self, red=0, green=0, blue=0, name=None, hex=None): except ValueError: red = green = blue = 0 - red, green, blue = _remap_rgb_value([red, green, blue], self.max_rgb_value) + red, green, blue = remap_rgb_value([red, green, blue], self.max_rgb_value) # TODO - do smarts to determine input type from red var in case it is not int @@ -567,9 +567,9 @@ def morph(self, channel=0, index=0, red=0, green=0, blue=0, name=None, hex=None, r_end, g_end, b_end = self._determine_rgb(red=red, green=green, blue=blue, name=name, hex=hex) # descale the above values - r_end, g_end, b_end = _remap_rgb_value_reverse([r_end, g_end, b_end], self.max_rgb_value) + r_end, g_end, b_end = remap_rgb_value_reverse([r_end, g_end, b_end], self.max_rgb_value) - r_start, g_start, b_start = _remap_rgb_value_reverse(self._get_color_rgb(index), self.max_rgb_value) + r_start, g_start, b_start = remap_rgb_value_reverse(self._get_color_rgb(index), self.max_rgb_value) if r_start > 255 or g_start > 255 or b_start > 255: r_start = 0 @@ -727,7 +727,7 @@ def set_color(self, channel, index, r, g, b, remap_values=True): """ if remap_values: - r, g, b = [_remap_color(val, self.max_rgb_value) for val in [r, g, b]] + r, g, b = [remap_color(val, self.max_rgb_value) for val in [r, g, b]] self.data[channel][index] = [g, r, b] @@ -899,7 +899,7 @@ def set_color(self, x, y, r, g, b, remap_values=True): """ if remap_values: - r, g, b = [_remap_color(val, self.max_rgb_value) for val in [r, g, b]] + r, g, b = [remap_color(val, self.max_rgb_value) for val in [r, g, b]] self.matrix_data[self._coord_to_index(x, y)] = [g, r, b] @@ -1282,32 +1282,5 @@ def find_by_serial(serial=None): return BlinkStick(device=devices[0]) -def _remap(value, leftMin, leftMax, rightMin, rightMax): - # Figure out how 'wide' each range is - leftSpan = leftMax - leftMin - rightSpan = rightMax - rightMin - - # Convert the left range into a 0-1 range (float) - valueScaled = float(value - leftMin) / float(leftSpan) - - # Convert the 0-1 range into a value in the right range. - return int(rightMin + (valueScaled * rightSpan)) - -def _remap_color(value, max_value): - return _remap(value, 0, 255, 0, max_value) - -def _remap_color_reverse(value, max_value): - return _remap(value, 0, max_value, 0, 255) - -def _remap_rgb_value(rgb_val, max_value): - return [_remap_color(rgb_val[0], max_value), - _remap_color(rgb_val[1], max_value), - _remap_color(rgb_val[2], max_value)] - -def _remap_rgb_value_reverse(rgb_val, max_value): - return [_remap_color_reverse(rgb_val[0], max_value), - _remap_color_reverse(rgb_val[1], max_value), - _remap_color_reverse(rgb_val[2], max_value)] - def get_blinkstick_package_version(): return version("blinkstick") diff --git a/src/blinkstick/colors.py b/src/blinkstick/colors.py index 0a55844..f82b872 100644 --- a/src/blinkstick/colors.py +++ b/src/blinkstick/colors.py @@ -261,4 +261,41 @@ def name_to_rgb(name: str) -> tuple[int, int, int]: (218, 165, 32) """ - return hex_to_rgb(name_to_hex(name)) \ No newline at end of file + return hex_to_rgb(name_to_hex(name)) + + +def remap(value: int, left_min: int, left_max: int, right_min: int, right_max: int) -> int: + """ + Remap a value from one range to another. + """ + # TODO: decide if we should raise an exception if the value is outside the left range + # Figure out how 'wide' each range is + left_span = left_max - left_min + right_span = right_max - right_min + + # Convert the left range into a 0-1 range (float) + value_scaled = float(value - left_min) / float(left_span) + + # TODO: decide if we should use round() here, as int() will always round down + # Convert the 0-1 range into a value in the right range. + return int(right_min + (value_scaled * right_span)) + + +def remap_color(value: int, max_value: int) -> int: + return remap(value, 0, 255, 0, max_value) + + +def remap_color_reverse(value: int, max_value : int) -> int: + return remap(value, 0, max_value, 0, 255) + + +def remap_rgb_value(rgb_val: tuple[int, int, int], max_value: int) -> tuple[int, int, int]: + return (remap_color(rgb_val[0], max_value), + remap_color(rgb_val[1], max_value), + remap_color(rgb_val[2], max_value)) + + +def remap_rgb_value_reverse(rgb_val: tuple[int, int, int], max_value: int) -> tuple[int, int, int]: + return (remap_color_reverse(rgb_val[0], max_value), + remap_color_reverse(rgb_val[1], max_value), + remap_color_reverse(rgb_val[2], max_value)) From 020da144366de1e6278bd626c2c4fbd86ee76f93 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 2 Nov 2024 11:31:22 +0000 Subject: [PATCH 17/86] add tests for color functions --- tests/colors/__init__.py | 0 tests/colors/conftest.py | 145 +++++++++++++++++++++++++++++++++ tests/colors/test_colors.py | 156 ++++++++++++++++++++++++++++++++++++ 3 files changed, 301 insertions(+) create mode 100644 tests/colors/__init__.py create mode 100644 tests/colors/conftest.py create mode 100644 tests/colors/test_colors.py diff --git a/tests/colors/__init__.py b/tests/colors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/colors/conftest.py b/tests/colors/conftest.py new file mode 100644 index 0000000..e7e5358 --- /dev/null +++ b/tests/colors/conftest.py @@ -0,0 +1,145 @@ +import pytest + + +@pytest.fixture +def w3c_colors(): + return [ + ("ALICEBLUE", "#f0f8ff"), + ("ANTIQUEWHITE", "#faebd7"), + ("AQUA", "#00ffff"), + ("AQUAMARINE", "#7fffd4"), + ("AZURE", "#f0ffff"), + ("BEIGE", "#f5f5dc"), + ("BISQUE", "#ffe4c4"), + ("BLACK", "#000000"), + ("BLANCHEDALMOND", "#ffebcd"), + ("BLUE", "#0000ff"), + ("BLUEVIOLET", "#8a2be2"), + ("BROWN", "#a52a2a"), + ("BURLYWOOD", "#deb887"), + ("CADETBLUE", "#5f9ea0"), + ("CHARTREUSE", "#7fff00"), + ("CHOCOLATE", "#d2691e"), + ("CORAL", "#ff7f50"), + ("CORNFLOWERBLUE", "#6495ed"), + ("CORNSILK", "#fff8dc"), + ("CRIMSON", "#dc143c"), + ("DARKBLUE", "#00008b"), + ("DARKCYAN", "#008b8b"), + ("DARKGOLDENROD", "#b8860b"), + ("DARKGRAY", "#a9a9a9"), + ("DARKGREEN", "#006400"), + ("DARKKHAKI", "#bdb76b"), + ("DARKMAGENTA", "#8b008b"), + ("DARKOLIVEGREEN", "#556b2f"), + ("DARKORANGE", "#ff8c00"), + ("DARKORCHID", "#9932cc"), + ("DARKRED", "#8b0000"), + ("DARKSALMON", "#e9967a"), + ("DARKSEAGREEN", "#8fbc8f"), + ("DARKSLATEBLUE", "#483d8b"), + ("DARKSLATEGRAY", "#2f4f4f"), + ("DARKTURQUOISE", "#00ced1"), + ("DARKVIOLET", "#9400d3"), + ("DEEPPINK", "#ff1493"), + ("DEEPSKYBLUE", "#00bfff"), + ("DIMGRAY", "#696969"), + ("DODGERBLUE", "#1e90ff"), + ("FIREBRICK", "#b22222"), + ("FLORALWHITE", "#fffaf0"), + ("FORESTGREEN", "#228b22"), + ("FUCHSIA", "#ff00ff"), + ("GAINSBORO", "#dcdcdc"), + ("GHOSTWHITE", "#f8f8ff"), + ("GOLD", "#ffd700"), + ("GOLDENROD", "#daa520"), + ("GRAY", "#808080"), + ("GREEN", "#008000"), + ("GREENYELLOW", "#adff2f"), + ("HONEYDEW", "#f0fff0"), + ("HOTPINK", "#ff69b4"), + ("INDIANRED", "#cd5c5c"), + ("INDIGO", "#4b0082"), + ("IVORY", "#fffff0"), + ("KHAKI", "#f0e68c"), + ("LAVENDER", "#e6e6fa"), + ("LAVENDERBLUSH", "#fff0f5"), + ("LAWNGREEN", "#7cfc00"), + ("LEMONCHIFFON", "#fffacd"), + ("LIGHTBLUE", "#add8e6"), + ("LIGHTCORAL", "#f08080"), + ("LIGHTCYAN", "#e0ffff"), + ("LIGHTGOLDENRODYELLOW", "#fafad2"), + ("LIGHTGRAY", "#d3d3d3"), + ("LIGHTGREEN", "#90ee90"), + ("LIGHTPINK", "#ffb6c1"), + ("LIGHTSALMON", "#ffa07a"), + ("LIGHTSEAGREEN", "#20b2aa"), + ("LIGHTSKYBLUE", "#87cefa"), + ("LIGHTSLATEGRAY", "#778899"), + ("LIGHTSTEELBLUE", "#b0c4de"), + ("LIGHTYELLOW", "#ffffe0"), + ("LIME", "#00ff00"), + ("LIMEGREEN", "#32cd32"), + ("LINEN", "#faf0e6"), + ("MAROON", "#800000"), + ("MEDIUMAQUAMARINE", "#66cdaa"), + ("MEDIUMBLUE", "#0000cd"), + ("MEDIUMORCHID", "#ba55d3"), + ("MEDIUMPURPLE", "#9370d8"), + ("MEDIUMSEAGREEN", "#3cb371"), + ("MEDIUMSLATEBLUE", "#7b68ee"), + ("MEDIUMSPRINGGREEN", "#00fa9a"), + ("MEDIUMTURQUOISE", "#48d1cc"), + ("MEDIUMVIOLETRED", "#c71585"), + ("MIDNIGHTBLUE", "#191970"), + ("MINTCREAM", "#f5fffa"), + ("MISTYROSE", "#ffe4e1"), + ("MOCCASIN", "#ffe4b5"), + ("NAVAJOWHITE", "#ffdead"), + ("NAVY", "#000080"), + ("OLDLACE", "#fdf5e6"), + ("OLIVE", "#808000"), + ("OLIVEDRAB", "#6b8e23"), + ("ORANGE", "#ffa500"), + ("ORANGERED", "#ff4500"), + ("ORCHID", "#da70d6"), + ("PALEGOLDENROD", "#eee8aa"), + ("PALEGREEN", "#98fb98"), + ("PALETURQUOISE", "#afeeee"), + ("PALEVIOLETRED", "#d87093"), + ("PAPAYAWHIP", "#ffefd5"), + ("PEACHPUFF", "#ffdab9"), + ("PERU", "#cd853f"), + ("PINK", "#ffc0cb"), + ("PLUM", "#dda0dd"), + ("POWDERBLUE", "#b0e0e6"), + ("PURPLE", "#800080"), + ("RED", "#ff0000"), + ("ROSYBROWN", "#bc8f8f"), + ("ROYALBLUE", "#4169e1"), + ("SADDLEBROWN", "#8b4513"), + ("SALMON", "#fa8072"), + ("SANDYBROWN", "#f4a460"), + ("SEAGREEN", "#2e8b57"), + ("SEASHELL", "#fff5ee"), + ("SIENNA", "#a0522d"), + ("SILVER", "#c0c0c0"), + ("SKYBLUE", "#87ceeb"), + ("SLATEBLUE", "#6a5acd"), + ("SLATEGRAY", "#708090"), + ("SNOW", "#fffafa"), + ("SPRINGGREEN", "#00ff7f"), + ("STEELBLUE", "#4682b4"), + ("TAN", "#d2b48c"), + ("TEAL", "#008080"), + ("THISTLE", "#d8bfd8"), + ("TOMATO", "#ff6347"), + ("TURQUOISE", "#40e0d0"), + ("VIOLET", "#ee82ee"), + ("WHEAT", "#f5deb3"), + ("WHITE", "#ffffff"), + ("WHITESMOKE", "#f5f5f5"), + ("YELLOW", "#ffff00"), + ("YELLOWGREEN", "#9acd32"), + ] diff --git a/tests/colors/test_colors.py b/tests/colors/test_colors.py new file mode 100644 index 0000000..0c7af05 --- /dev/null +++ b/tests/colors/test_colors.py @@ -0,0 +1,156 @@ +import pytest + +from blinkstick.colors import remap, remap_color, remap_color_reverse, remap_rgb_value, remap_rgb_value_reverse, Color, \ + name_to_hex, normalize_hex, hex_to_rgb, name_to_rgb + + +def test_remap_value_within_range(): + assert remap(5, 0, 10, 0, 100) == 50 + +def test_remap_value_at_minimum(): + assert remap(0, 0, 10, 0, 100) == 0 + +def test_remap_value_at_maximum(): + assert remap(10, 0, 10, 0, 100) == 100 + +def test_remap_value_below_minimum(): + assert remap(-5, 0, 10, 0, 100) == -50 + +def test_remap_value_above_maximum(): + assert remap(15, 0, 10, 0, 100) == 150 + +def test_remap_value_within_negative_range(): + assert remap(-5, -10, 0, -100, 0) == -50 + +def test_remap_value_within_reverse_range(): + assert remap(5, 0, 10, 100, 0) == 50 + +def test_remap_color_value_within_range(): + assert remap_color(128, 100) == 50 + +def test_remap_color_value_at_minimum(): + assert remap_color(0, 100) == 0 + +def test_remap_color_value_at_maximum(): + assert remap_color(255, 100) == 100 + +def test_remap_color_value_below_minimum(): + # note: this returns -3 because of the way the remap function is implemented using int(), which always rounds down + assert remap_color(-10, 100) == -3 + +def test_remap_color_value_above_maximum(): + assert remap_color(300, 100) == 117 + +def test_remap_color_reverse_value_within_range(): + assert remap_color_reverse(50, 100) == 127 + +def test_remap_color_reverse_value_at_minimum(): + assert remap_color_reverse(0, 100) == 0 + +def test_remap_color_reverse_value_at_maximum(): + assert remap_color_reverse(100, 100) == 255 + +def test_remap_color_reverse_value_below_minimum(): + assert remap_color_reverse(-10, 100) == -25 + +def test_remap_color_reverse_value_above_maximum(): + assert remap_color_reverse(150, 100) == 382 + +def test_remap_rgb_value_within_range(): + assert remap_rgb_value((128, 128, 128), 100) == (50, 50, 50) + +def test_remap_rgb_value_at_minimum(): + assert remap_rgb_value((0, 0, 0), 100) == (0, 0, 0) + +def test_remap_rgb_value_at_maximum(): + assert remap_rgb_value((255, 255, 255), 100) == (100, 100, 100) + +def test_remap_rgb_value_below_minimum(): + assert remap_rgb_value((-10, -10, -10), 100) == (-3, -3, -3) + +def test_remap_rgb_value_above_maximum(): + assert remap_rgb_value((300, 300, 300), 100) == (117, 117, 117) + +def test_remap_rgb_value_reverse_within_range(): + assert remap_rgb_value_reverse((50, 50, 50), 100) == (127, 127, 127) + +def test_remap_rgb_value_reverse_at_minimum(): + assert remap_rgb_value_reverse((0, 0, 0), 100) == (0, 0, 0) + +def test_remap_rgb_value_reverse_at_maximum(): + assert remap_rgb_value_reverse((100, 100, 100), 100) == (255, 255, 255) + +def test_remap_rgb_value_reverse_below_minimum(): + assert remap_rgb_value_reverse((-10, -10, -10), 100) == (-25, -25, -25) + +def test_remap_rgb_value_reverse_above_maximum(): + assert remap_rgb_value_reverse((150, 150, 150), 100) == (382, 382, 382) + +def test_all_colors_present(w3c_colors): + assert len(w3c_colors) == len(Color) + +def test_color_from_name_valid_color(w3c_colors): + for color_name, _ in w3c_colors: + assert Color.from_name(color_name) == Color[color_name.upper()] + +def test_color_from_name_invalid_color(): + with pytest.raises(ValueError, match="'invalidcolor' is not defined as a named color."): + Color.from_name('invalidcolor') + +def test_color_from_name_case_insensitive(w3c_colors): + for color_name, _ in w3c_colors: + assert Color.from_name(color_name.upper()) == Color[color_name.upper()] + assert Color.from_name(color_name.lower()) == Color[color_name.upper()] + +def test_color_name_to_hex(w3c_colors): + for color_name, color_hex in w3c_colors: + assert name_to_hex(color_name) == color_hex + +def test_name_to_rgb_white(): + assert name_to_rgb('white') == (255, 255, 255) + +def test_name_to_rgb_navy(): + assert name_to_rgb('navy') == (0, 0, 128) + +def test_name_to_rgb_goldenrod(): + assert name_to_rgb('goldenrod') == (218, 165, 32) + +def test_name_to_rgb_invalid_color(): + with pytest.raises(ValueError, match="'invalidcolor' is not defined as a named color."): + name_to_rgb('invalidcolor') + +def test_normalize_hex_valid_six_digit_lowercase(): + assert normalize_hex('#0099cc') == '#0099cc' + +def test_normalize_hex_valid_six_digit_uppercase(): + assert normalize_hex('#0099CC') == '#0099cc' + +def test_normalize_hex_valid_three_digit_lowercase(): + assert normalize_hex('#09c') == '#0099cc' + +def test_normalize_hex_valid_three_digit_uppercase(): + assert normalize_hex('#09C') == '#0099cc' + +def test_normalize_hex_missing_hash(): + with pytest.raises(ValueError, match="'0099cc' is not a valid hexadecimal color value."): + normalize_hex('0099cc') + +def test_hex_to_rgb_valid_six_digit_lowercase(): + assert hex_to_rgb('#0099cc') == (0, 153, 204) + +def test_hex_to_rgb_valid_six_digit_uppercase(): + assert hex_to_rgb('#0099CC') == (0, 153, 204) + +def test_hex_to_rgb_valid_three_digit_lowercase(): + assert hex_to_rgb('#09c') == (0, 153, 204) + +def test_hex_to_rgb_valid_three_digit_uppercase(): + assert hex_to_rgb('#09C') == (0, 153, 204) + +def test_hex_to_rgb_missing_hash(): + with pytest.raises(ValueError, match="'0099cc' is not a valid hexadecimal color value."): + hex_to_rgb('0099cc') + +def test_hex_to_rgb_invalid_hex(): + with pytest.raises(ValueError, match="'#xyz' is not a valid hexadecimal color value."): + hex_to_rgb('#xyz') \ No newline at end of file From 160da8b228aa85ac930c183218a17b939565c9c9 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 2 Nov 2024 12:22:03 +0000 Subject: [PATCH 18/86] use callable to test if get_color_func is callable --- src/blinkstick/blinkstick.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index f06f8cc..98a2967 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -6,11 +6,6 @@ from blinkstick.constants import VENDOR_ID, PRODUCT_ID from blinkstick.exceptions import BlinkStickException -try: - from collections.abc import Callable -except ImportError: - from collections import Callable - if sys.platform == "win32": from blinkstick.backends.win32 import Win32Backend as USBBackend import pywinusb.hid as hid @@ -243,7 +238,7 @@ def _get_color_hex(self, index=0): r, g, b = self._get_color_rgb(index) return '#%02x%02x%02x' % (r, g, b) - def get_color(self, index=0, color_format='rgb'): + def get_color(self, index: int=0, color_format: str='rgb'): """ Get the current backend color in the defined format. @@ -270,10 +265,10 @@ def get_color(self, index=0, color_format='rgb'): # Attempt to find a function to return the appropriate format get_color_func = getattr(self, "_get_color_%s" % color_format, self._get_color_rgb) - if isinstance(get_color_func, Callable): + if callable(get_color_func): return get_color_func(index) else: - # Should never get here, as we should always default to self._get_color_rgb + # If the function is not callable, raise an exception raise BlinkStickException("Could not return current color in format %s" % color_format) def _determine_report_id(self, led_count): From 6ad548b66a0bf61fc87d60239c9f1f538b52c341 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 2 Nov 2024 13:52:49 +0000 Subject: [PATCH 19/86] add tests for get_color --- tests/clients/__init__.py | 0 tests/clients/blinkstick.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 tests/clients/__init__.py create mode 100644 tests/clients/blinkstick.py diff --git a/tests/clients/__init__.py b/tests/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/clients/blinkstick.py b/tests/clients/blinkstick.py new file mode 100644 index 0000000..8fd1052 --- /dev/null +++ b/tests/clients/blinkstick.py @@ -0,0 +1,32 @@ +import pytest +from pytest_mock import MockFixture + +from blinkstick.blinkstick import BlinkStick, BlinkStickException + +def test_get_color_rgb(mocker: MockFixture): + """Test get_color with color_format='rgb'. We expect it to return the color in RGB format.""" + mock_get_color_rgb = mocker.patch.object(BlinkStick, '_get_color_rgb', return_value=(255, 0, 0)) + blinkstick = BlinkStick() + assert blinkstick.get_color() == (255, 0, 0) + assert mock_get_color_rgb.call_count == 1 + +def test_get_color_hex(mocker): + """Test get_color with color_format='hex'. We expect it to return the color in hex format.""" + mock_get_color_hex = mocker.patch.object(BlinkStick, '_get_color_hex', return_value='#ff0000') + blinkstick = BlinkStick() + assert blinkstick.get_color(color_format='hex') == '#ff0000' + assert mock_get_color_hex.call_count == 1 + +def test_get_color_invalid_format(mocker): + """Test get_color with invalid color_format. We expect it not to raise an exception, but to default to RGB.""" + mock_get_color_rgb = mocker.patch.object(BlinkStick, '_get_color_rgb', return_value=(255, 0, 0)) + blinkstick = BlinkStick() + blinkstick.get_color(color_format='invalid_format') + assert mock_get_color_rgb.call_count == 1 + +def test_non_callable_get_color(mocker): + """Test get_color with a non-callable get_color_func. We expect it to raise an exception.""" + blinkstick = BlinkStick() + blinkstick._get_color_rgb = 'not_a_callable' + with pytest.raises(BlinkStickException): + blinkstick.get_color() \ No newline at end of file From fc52896f89bbd05be99aaba5f87ea3194f334464 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Thu, 7 Nov 2024 19:13:29 +0000 Subject: [PATCH 20/86] test: rename blinkstick test file --- tests/clients/{blinkstick.py => test_blinkstick.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/clients/{blinkstick.py => test_blinkstick.py} (100%) diff --git a/tests/clients/blinkstick.py b/tests/clients/test_blinkstick.py similarity index 100% rename from tests/clients/blinkstick.py rename to tests/clients/test_blinkstick.py From e4809258a08f7f2c5b651c03748f0b132b1b41f0 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Thu, 7 Nov 2024 19:47:12 +0000 Subject: [PATCH 21/86] test: Add tests for get_variant & get_variant_string --- tests/conftest.py | 12 +++++++++ tests/test_blinkstick.py | 55 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b6bce89 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +from unittest.mock import MagicMock + +import pytest + +from blinkstick.blinkstick import BlinkStick + + +@pytest.fixture +def blinkstick(): + bs = BlinkStick() + bs.backend = MagicMock() + return bs diff --git a/tests/test_blinkstick.py b/tests/test_blinkstick.py index a7b4016..8e7ca4f 100644 --- a/tests/test_blinkstick.py +++ b/tests/test_blinkstick.py @@ -1,6 +1,61 @@ +from unittest.mock import MagicMock + +import pytest + from blinkstick.blinkstick import BlinkStick +from tests.conftest import blinkstick def test_instantiate(): bs = BlinkStick() assert bs is not None + +@pytest.mark.parametrize("serial, version_attribute, expected_variant, expected_variant_value", [ + ("BS12345-1.0", 0x0000, BlinkStick.BLINKSTICK, 1), + ("BS12345-2.0", 0x0000, BlinkStick.BLINKSTICK_PRO, 2), + ("BS12345-3.0", 0x200, BlinkStick.BLINKSTICK_SQUARE, 4), # major version 3, version attribute 0x200 is BlinkStickSquare + ("BS12345-3.0", 0x201, BlinkStick.BLINKSTICK_STRIP, 3), # major version 3 is BlinkStickStrip + ("BS12345-3.0", 0x202, BlinkStick.BLINKSTICK_NANO, 5), + ("BS12345-3.0", 0x203, BlinkStick.BLINKSTICK_FLEX, 6), + ("BS12345-4.0", 0x0000, BlinkStick.UNKNOWN, 0), + ("BS12345-3.0", 0x9999, BlinkStick.UNKNOWN, 0), + ("BS12345-0.0", 0x0000, BlinkStick.UNKNOWN, 0), +], ids=[ + "v1==BlinkStick", + "v2==BlinkStickPro", + "v3,0x200==BlinkStickSquare", + "v3,0x201==BlinkStickStrip", + "v3,0x202==BlinkStickNano", + "v3,0x203==BlinkStickFlex", + "v4==Unknown", + "v3,Unknown==Unknown", + "v0,0==Unknown" +]) +def test_get_variant(blinkstick, serial, version_attribute, expected_variant, expected_variant_value): + blinkstick.get_serial = MagicMock(return_value=serial) + blinkstick.backend.get_version_attribute = MagicMock(return_value=version_attribute) + assert blinkstick.get_variant() == expected_variant + assert blinkstick.get_variant() == expected_variant_value + + +@pytest.mark.parametrize("variant_value, expected_variant, expected_name", [ + (1, BlinkStick.BLINKSTICK, "BlinkStick"), + (2, BlinkStick.BLINKSTICK_PRO, "BlinkStick Pro"), + (3, BlinkStick.BLINKSTICK_STRIP, "BlinkStick Strip"), + (4, BlinkStick.BLINKSTICK_SQUARE, "BlinkStick Square"), + (5, BlinkStick.BLINKSTICK_NANO, "BlinkStick Nano"), + (6, BlinkStick.BLINKSTICK_FLEX, "BlinkStick Flex"), + (0, BlinkStick.UNKNOWN, "Unknown"), +], ids=[ + "1==BlinkStick", + "2==BlinkStickPro", + "3==BlinkStickStrip", + "4==BlinkStickSquare", + "5==BlinkStickNano", + "6==BlinkStickFlex", + "0==Unknown" +]) +def test_get_variant_string(blinkstick, variant_value, expected_variant, expected_name): + """Test get_variant method for version 0 returns BlinkStick.UNKNOWN (0)""" + blinkstick.get_variant = MagicMock(return_value=variant_value) + assert blinkstick.get_variant_string() == expected_name From b6df4b12596d4f9481558c1e6fb99334dae6391a Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 26 Oct 2024 20:49:19 +0100 Subject: [PATCH 22/86] Add `BlinkStickVariant` enum to constants --- src/blinkstick/constants.py | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/blinkstick/constants.py b/src/blinkstick/constants.py index 9bd4840..8a0c755 100644 --- a/src/blinkstick/constants.py +++ b/src/blinkstick/constants.py @@ -1,2 +1,40 @@ +from __future__ import annotations + +from enum import Enum + VENDOR_ID = 0x20a0 PRODUCT_ID = 0x41e5 + +class BlinkStickVariant(Enum): + UNKNOWN = (0, "Unknown") + BLINKSTICK = (1, "BlinkStick") + BLINKSTICK_PRO = (2, "BlinkStick Pro") + BLINKSTICK_STRIP = (3, "BlinkStick Strip") + BLINKSTICK_SQUARE = (4, "BlinkStick Square") + BLINKSTICK_NANO = (5, "BlinkStick Nano") + BLINKSTICK_FLEX = (6, "BlinkStick Flex") + + @property + def value(self): + return self._value_[0] + + @property + def description(self): + return self._value_[1] + + @staticmethod + def identify(major_version: int, version_attribute: int | None) -> "BlinkStickVariant": + if major_version == 1: + return BlinkStickVariant.BLINKSTICK + elif major_version == 2: + return BlinkStickVariant.BLINKSTICK_PRO + elif major_version == 3: + if version_attribute == 0x200: + return BlinkStickVariant.BLINKSTICK_SQUARE + elif version_attribute == 0x201: + return BlinkStickVariant.BLINKSTICK_STRIP + elif version_attribute == 0x202: + return BlinkStickVariant.BLINKSTICK_NANO + elif version_attribute == 0x203: + return BlinkStickVariant.BLINKSTICK_FLEX + return BlinkStickVariant.UNKNOWN \ No newline at end of file From 3d771ae66dada0bfa44d82af5e2e732f7a7e9e9b Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 9 Nov 2024 10:52:52 +0000 Subject: [PATCH 23/86] feat: Use BlinkStickVariant enum Return BlinkStickVariant from get_variant BREAKING CHANGE: get_variant() now returns an instance of the BlinkStickVariant Enum, where the value is a 2-tuple of variant value, and variant string --- src/blinkstick/blinkstick.py | 54 ++++-------------------------------- src/blinkstick/main.py | 3 +- 2 files changed, 8 insertions(+), 49 deletions(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 98a2967..3bf3567 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -3,7 +3,7 @@ from importlib.metadata import version from blinkstick.colors import hex_to_rgb, name_to_rgb, remap_color, remap_rgb_value, remap_rgb_value_reverse -from blinkstick.constants import VENDOR_ID, PRODUCT_ID +from blinkstick.constants import VENDOR_ID, PRODUCT_ID, BlinkStickVariant from blinkstick.exceptions import BlinkStickException if sys.platform == "win32": @@ -32,16 +32,6 @@ class BlinkStick: U{https://github.com/arvydas/blinkstick-python/wiki} """ - - - UNKNOWN = 0 - BLINKSTICK = 1 - BLINKSTICK_PRO = 2 - BLINKSTICK_STRIP = 3 - BLINKSTICK_SQUARE = 4 - BLINKSTICK_NANO = 5 - BLINKSTICK_FLEX = 6 - inverse = False error_reporting = True max_rgb_value = 255 @@ -86,61 +76,29 @@ def get_manufacturer(self): """ return self.backend.get_manufacturer() - def get_variant(self): + def get_variant(self) -> BlinkStickVariant: """ Get the product variant of the backend. @rtype: int - @return: BlinkStick.UNKNOWN, BlinkStick.BLINKSTICK, BlinkStick.BLINKSTICK_PRO and etc + @return: BlinkStickVariant.UNKNOWN, BlinkStickVariant.BLINKSTICK, BlinkStickVariant.BLINKSTICK_PRO and etc """ serial = self.get_serial() major = serial[-3] - minor = serial[-1] version_attribute = self.backend.get_version_attribute() - if major == "1": - return self.BLINKSTICK - elif major == "2": - return self.BLINKSTICK_PRO - elif major == "3": - if version_attribute == 0x200: - return self.BLINKSTICK_SQUARE - elif version_attribute == 0x201: - return self.BLINKSTICK_STRIP - elif version_attribute == 0x202: - return self.BLINKSTICK_NANO - elif version_attribute == 0x203: - return self.BLINKSTICK_FLEX - else: - return self.UNKNOWN - else: - return self.UNKNOWN + return BlinkStickVariant.identify(int(major), version_attribute) - def get_variant_string(self): + def get_variant_string(self) -> str: """ Get the product variant of the backend as string. @rtype: string @return: "BlinkStick", "BlinkStick Pro", etc """ - product = self.get_variant() - - if product == self.BLINKSTICK: - return "BlinkStick" - elif product == self.BLINKSTICK_PRO: - return "BlinkStick Pro" - elif product == self.BLINKSTICK_SQUARE: - return "BlinkStick Square" - elif product == self.BLINKSTICK_STRIP: - return "BlinkStick Strip" - elif product == self.BLINKSTICK_NANO: - return "BlinkStick Nano" - elif product == self.BLINKSTICK_FLEX: - return "BlinkStick Flex" - - return "Unknown" + return self.get_variant().description def get_description(self): """ diff --git a/src/blinkstick/main.py b/src/blinkstick/main.py index 2b24bde..f603b10 100644 --- a/src/blinkstick/main.py +++ b/src/blinkstick/main.py @@ -2,6 +2,7 @@ from optparse import OptionParser, IndentedHelpFormatter, OptionGroup from blinkstick import blinkstick +from blinkstick.constants import BlinkStickVariant import textwrap import sys import logging @@ -81,7 +82,7 @@ def print_info(stick): print(" Serial: {0}".format(stick.get_serial())) print(" Current Color: {0}".format(stick.get_color(color_format="hex"))) print(" Mode: {0}".format(stick.get_mode())) - if stick.get_variant() == blinkstick.BlinkStick.BLINKSTICK_FLEX: + if stick.get_variant() == BlinkStickVariant.BLINKSTICK_FLEX: try: count = stick.get_led_count() except: From ac199274df32c29ce4a30e272ccdb41e65984fd5 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 9 Nov 2024 10:54:53 +0000 Subject: [PATCH 24/86] test: update get_variant, get_variant_string tests Update the test for the get_variant and get_variant_string methods to account for the new BlinkStickVariant enum --- tests/test_blinkstick.py | 41 ++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/tests/test_blinkstick.py b/tests/test_blinkstick.py index 8e7ca4f..5e3dcd9 100644 --- a/tests/test_blinkstick.py +++ b/tests/test_blinkstick.py @@ -3,6 +3,7 @@ import pytest from blinkstick.blinkstick import BlinkStick +from blinkstick.constants import BlinkStickVariant from tests.conftest import blinkstick @@ -11,15 +12,15 @@ def test_instantiate(): assert bs is not None @pytest.mark.parametrize("serial, version_attribute, expected_variant, expected_variant_value", [ - ("BS12345-1.0", 0x0000, BlinkStick.BLINKSTICK, 1), - ("BS12345-2.0", 0x0000, BlinkStick.BLINKSTICK_PRO, 2), - ("BS12345-3.0", 0x200, BlinkStick.BLINKSTICK_SQUARE, 4), # major version 3, version attribute 0x200 is BlinkStickSquare - ("BS12345-3.0", 0x201, BlinkStick.BLINKSTICK_STRIP, 3), # major version 3 is BlinkStickStrip - ("BS12345-3.0", 0x202, BlinkStick.BLINKSTICK_NANO, 5), - ("BS12345-3.0", 0x203, BlinkStick.BLINKSTICK_FLEX, 6), - ("BS12345-4.0", 0x0000, BlinkStick.UNKNOWN, 0), - ("BS12345-3.0", 0x9999, BlinkStick.UNKNOWN, 0), - ("BS12345-0.0", 0x0000, BlinkStick.UNKNOWN, 0), + ("BS12345-1.0", 0x0000, BlinkStickVariant.BLINKSTICK, 1), + ("BS12345-2.0", 0x0000, BlinkStickVariant.BLINKSTICK_PRO, 2), + ("BS12345-3.0", 0x200, BlinkStickVariant.BLINKSTICK_SQUARE, 4), # major version 3, version attribute 0x200 is BlinkStickSquare + ("BS12345-3.0", 0x201, BlinkStickVariant.BLINKSTICK_STRIP, 3), # major version 3 is BlinkStickStrip + ("BS12345-3.0", 0x202, BlinkStickVariant.BLINKSTICK_NANO, 5), + ("BS12345-3.0", 0x203, BlinkStickVariant.BLINKSTICK_FLEX, 6), + ("BS12345-4.0", 0x0000, BlinkStickVariant.UNKNOWN, 0), + ("BS12345-3.0", 0x9999, BlinkStickVariant.UNKNOWN, 0), + ("BS12345-0.0", 0x0000, BlinkStickVariant.UNKNOWN, 0), ], ids=[ "v1==BlinkStick", "v2==BlinkStickPro", @@ -35,17 +36,17 @@ def test_get_variant(blinkstick, serial, version_attribute, expected_variant, ex blinkstick.get_serial = MagicMock(return_value=serial) blinkstick.backend.get_version_attribute = MagicMock(return_value=version_attribute) assert blinkstick.get_variant() == expected_variant - assert blinkstick.get_variant() == expected_variant_value + assert blinkstick.get_variant().value == expected_variant_value -@pytest.mark.parametrize("variant_value, expected_variant, expected_name", [ - (1, BlinkStick.BLINKSTICK, "BlinkStick"), - (2, BlinkStick.BLINKSTICK_PRO, "BlinkStick Pro"), - (3, BlinkStick.BLINKSTICK_STRIP, "BlinkStick Strip"), - (4, BlinkStick.BLINKSTICK_SQUARE, "BlinkStick Square"), - (5, BlinkStick.BLINKSTICK_NANO, "BlinkStick Nano"), - (6, BlinkStick.BLINKSTICK_FLEX, "BlinkStick Flex"), - (0, BlinkStick.UNKNOWN, "Unknown"), +@pytest.mark.parametrize("expected_variant, expected_name", [ + (BlinkStickVariant.BLINKSTICK, "BlinkStick"), + (BlinkStickVariant.BLINKSTICK_PRO, "BlinkStick Pro"), + (BlinkStickVariant.BLINKSTICK_STRIP, "BlinkStick Strip"), + (BlinkStickVariant.BLINKSTICK_SQUARE, "BlinkStick Square"), + (BlinkStickVariant.BLINKSTICK_NANO, "BlinkStick Nano"), + (BlinkStickVariant.BLINKSTICK_FLEX, "BlinkStick Flex"), + (BlinkStickVariant.UNKNOWN, "Unknown"), ], ids=[ "1==BlinkStick", "2==BlinkStickPro", @@ -55,7 +56,7 @@ def test_get_variant(blinkstick, serial, version_attribute, expected_variant, ex "6==BlinkStickFlex", "0==Unknown" ]) -def test_get_variant_string(blinkstick, variant_value, expected_variant, expected_name): +def test_get_variant_string(blinkstick, expected_variant, expected_name): """Test get_variant method for version 0 returns BlinkStick.UNKNOWN (0)""" - blinkstick.get_variant = MagicMock(return_value=variant_value) + blinkstick.get_variant = MagicMock(return_value=expected_variant) assert blinkstick.get_variant_string() == expected_name From e9a78c602637eeff44a0684fed43478b9c6217a6 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 9 Nov 2024 12:56:37 +0000 Subject: [PATCH 25/86] refactor: refactor get_color Introduce a `ColorFormat` Enum, and refactor `get_color` to take that as a new `color_mode` param. Default this new param to be `ColorFormat.RGB` to match the existing default behaviour of `color_format`. Change If `color_format` is passed, raise a `DeprecationWarning`, and attempt to convert the passed value to a `ColorFormat` enum instance and use that as the value for `color_mode` --- src/blinkstick/blinkstick.py | 42 +++++++++++++++++++++++++----------- src/blinkstick/colors.py | 15 +++++++++++-- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 3bf3567..23d0f52 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -1,8 +1,16 @@ import sys import time +import warnings from importlib.metadata import version -from blinkstick.colors import hex_to_rgb, name_to_rgb, remap_color, remap_rgb_value, remap_rgb_value_reverse +from blinkstick.colors import ( + hex_to_rgb, + name_to_rgb, + remap_color, + remap_rgb_value, + remap_rgb_value_reverse, + ColorFormat +) from blinkstick.constants import VENDOR_ID, PRODUCT_ID, BlinkStickVariant from blinkstick.exceptions import BlinkStickException @@ -196,7 +204,7 @@ def _get_color_hex(self, index=0): r, g, b = self._get_color_rgb(index) return '#%02x%02x%02x' % (r, g, b) - def get_color(self, index: int=0, color_format: str='rgb'): + def get_color(self, index: int=0, color_mode: ColorFormat = ColorFormat.RGB, color_format: str=None): """ Get the current backend color in the defined format. @@ -209,25 +217,35 @@ def get_color(self, index: int=0, color_format: str='rgb'): >>> b.set_color(red=255,green=0,blue=0) >>> (r,g,b) = b.get_color() # Get color as rbg tuple (255,0,0) - >>> hex = b.get_color(color_format='hex') # Get color as hex string + >>> hex = b.get_color(color_mode=ColorFormat.HEX) # Get color as hex string '#ff0000' @type index: int @param index: the index of the LED + @type color_mode: ColorFormat + @param color_mode: the format to return the color in (ColorFormat.RGB or ColorFormat.HEX) - defaults to ColorFormat.RGB @type color_format: str - @param color_format: "rgb" or "hex". Defaults to "rgb". + @param color_format: "rgb" or "hex". Defaults to "rgb". Deprecated, use color_mode instead. @rtype: (int, int, int) or str @return: Either 3-tuple for R, G and B values, or hex string """ + # color_format is deprecated, and color_mode should be used instead + # if color_format is specified, then raise a DeprecationWarning, but attempt to convert it to a ColorFormat enum + # if it's not possible, then default to ColorFormat.RGB, in line with the previous behavior + if color_format: + warnings.warn("color_format is deprecated, please use color_mode instead", DeprecationWarning) + try: + color_mode = ColorFormat.from_name(color_format) + except ValueError: + color_mode = ColorFormat.RGB - # Attempt to find a function to return the appropriate format - get_color_func = getattr(self, "_get_color_%s" % color_format, self._get_color_rgb) - if callable(get_color_func): - return get_color_func(index) - else: - # If the function is not callable, raise an exception - raise BlinkStickException("Could not return current color in format %s" % color_format) + color_funcs = { + ColorFormat.RGB: self._get_color_rgb, + ColorFormat.HEX: self._get_color_hex + } + + return color_funcs.get(color_mode, ColorFormat.RGB)(index) def _determine_report_id(self, led_count): report_id = 9 @@ -255,7 +273,7 @@ def set_led_data(self, channel, data): @type channel: int @param channel: the channel which to send data to (R=0, G=1, B=2) @type data: int[0..64*3] - @param data: The LED data frame in GRB format + @param data: The LED data frame in GRB color_mode """ report_id, max_leds = self._determine_report_id(len(data)) diff --git a/src/blinkstick/colors.py b/src/blinkstick/colors.py index f82b872..325c6fb 100644 --- a/src/blinkstick/colors.py +++ b/src/blinkstick/colors.py @@ -1,6 +1,5 @@ import re -from enum import Enum - +from enum import Enum, auto HEX_COLOR_RE = re.compile(r'^#([a-fA-F0-9]{3}|[a-fA-F0-9]{6})$') @@ -181,6 +180,18 @@ def name_to_hex(name: str) -> str: return Color.from_name(name).value +class ColorFormat(Enum): + RGB = auto() + HEX = auto() + + @classmethod + def from_name(cls, name): + try: + return cls[name.upper()] + except KeyError: + raise ValueError(f"'{name}' is not a supported color format.") + + def normalize_hex(hex_value: str) -> str: """ Normalize a hexadecimal color value to the following form and From 8147732d5bd75644b4614e6885aa20816766788b Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 9 Nov 2024 13:17:35 +0000 Subject: [PATCH 26/86] refactor: Remove unecessary test There's no way of providing an invalid color func in normal operation, so we don't need to test for it. --- tests/clients/test_blinkstick.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/clients/test_blinkstick.py b/tests/clients/test_blinkstick.py index 8fd1052..95367c8 100644 --- a/tests/clients/test_blinkstick.py +++ b/tests/clients/test_blinkstick.py @@ -24,9 +24,3 @@ def test_get_color_invalid_format(mocker): blinkstick.get_color(color_format='invalid_format') assert mock_get_color_rgb.call_count == 1 -def test_non_callable_get_color(mocker): - """Test get_color with a non-callable get_color_func. We expect it to raise an exception.""" - blinkstick = BlinkStick() - blinkstick._get_color_rgb = 'not_a_callable' - with pytest.raises(BlinkStickException): - blinkstick.get_color() \ No newline at end of file From 11db890f2af3f0f8e87d0bbf74d4228ce610fe92 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 9 Nov 2024 17:01:21 +0000 Subject: [PATCH 27/86] refactor: rename tests rename existing get_color tests to reflect that they're testing the old color_format param behaviour --- tests/clients/test_blinkstick.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/clients/test_blinkstick.py b/tests/clients/test_blinkstick.py index 95367c8..a6f2c38 100644 --- a/tests/clients/test_blinkstick.py +++ b/tests/clients/test_blinkstick.py @@ -3,21 +3,21 @@ from blinkstick.blinkstick import BlinkStick, BlinkStickException -def test_get_color_rgb(mocker: MockFixture): +def test_get_color_rgb_color_format(mocker: MockFixture): """Test get_color with color_format='rgb'. We expect it to return the color in RGB format.""" mock_get_color_rgb = mocker.patch.object(BlinkStick, '_get_color_rgb', return_value=(255, 0, 0)) blinkstick = BlinkStick() assert blinkstick.get_color() == (255, 0, 0) assert mock_get_color_rgb.call_count == 1 -def test_get_color_hex(mocker): +def test_get_color_hex_color_format(mocker): """Test get_color with color_format='hex'. We expect it to return the color in hex format.""" mock_get_color_hex = mocker.patch.object(BlinkStick, '_get_color_hex', return_value='#ff0000') blinkstick = BlinkStick() assert blinkstick.get_color(color_format='hex') == '#ff0000' assert mock_get_color_hex.call_count == 1 -def test_get_color_invalid_format(mocker): +def test_get_color_invalid_color_format(mocker): """Test get_color with invalid color_format. We expect it not to raise an exception, but to default to RGB.""" mock_get_color_rgb = mocker.patch.object(BlinkStick, '_get_color_rgb', return_value=(255, 0, 0)) blinkstick = BlinkStick() From 04b20d487295947930d89bbb0588dc653bc9d56e Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 10 Nov 2024 08:46:40 +0000 Subject: [PATCH 28/86] chore: run pytest action against release/* branches --- .github/workflows/pytest.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 381e1a7..5a7ee8f 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -5,6 +5,7 @@ on: branches: - master - feature/* + - release/* jobs: test: From 5cd0c124c746d19f40c0ac2b95fcf007ceea4ee4 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 9 Nov 2024 18:18:17 +0000 Subject: [PATCH 29/86] refactor: Reconfigure package imports Improve the discoverability of the key parts of the blinkstick package by exposing them at the top level. --- src/__init__.py | 0 src/blinkstick/__init__.py | 7 ++++++- 2 files changed, 6 insertions(+), 1 deletion(-) delete mode 100644 src/__init__.py diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/blinkstick/__init__.py b/src/blinkstick/__init__.py index 15e6694..6d2c3ab 100644 --- a/src/blinkstick/__init__.py +++ b/src/blinkstick/__init__.py @@ -1,6 +1,11 @@ -from .main import main from importlib.metadata import version, PackageNotFoundError +from .blinkstick import BlinkStick, BlinkStickPro, BlinkStickProMatrix +from .blinkstick import find_all, find_by_serial, find_first, get_blinkstick_package_version +from .colors import Color, ColorFormat +from .constants import BlinkStickVariant +from .exceptions import BlinkStickException + try: __version__ = version("blinkstick") except PackageNotFoundError: From 3220403dbf9b5ade5f8052c6654f73b528041312 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 10 Nov 2024 08:29:56 +0000 Subject: [PATCH 30/86] refactor: adjust main script imports Fix up the main script imports to use the new project structure, and update the entrypoint in pyproject.toml --- pyproject.toml | 2 +- src/blinkstick/main.py | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ce2e228..952f868 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dev = ["black", "isort"] test = ["coverage", "pytest", "pytest-cov", "pytest-mock"] [project.scripts] -blinkstick = "blinkstick:main" +blinkstick = "blinkstick.main:main" [project.urls] homepage = "https://pypi.python.org/pypi/blinkstick/" diff --git a/src/blinkstick/main.py b/src/blinkstick/main.py index f603b10..5d9b87f 100644 --- a/src/blinkstick/main.py +++ b/src/blinkstick/main.py @@ -1,11 +1,12 @@ #!/usr/bin/env python3 from optparse import OptionParser, IndentedHelpFormatter, OptionGroup -from blinkstick import blinkstick -from blinkstick.constants import BlinkStickVariant import textwrap import sys import logging + +from .blinkstick import get_blinkstick_package_version, find_all, find_by_serial +from .constants import BlinkStickVariant logging.basicConfig() @@ -71,7 +72,7 @@ def format_option(self, option): def format_usage(self, usage): return "BlinkStick control script %s\n(c) Agile Innovative Ltd 2013-2014\n\n%s" % ( - blinkstick.get_blinkstick_package_version(), IndentedHelpFormatter.format_usage(self, usage)) + get_blinkstick_package_version(), IndentedHelpFormatter.format_usage(self, usage)) def print_info(stick): @@ -217,9 +218,9 @@ def main(): (options, args) = parser.parse_args() if options.serial is None: - sticks = blinkstick.find_all() + sticks = find_all() else: - sticks = [blinkstick.find_by_serial(options.serial)] + sticks = [find_by_serial(options.serial)] if len(sticks) == 0: print("BlinkStick with serial number " + options.backend + " not found...") From 1e843231d18eef0ab6fb9aeb2474cc982b032509 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 10 Nov 2024 09:12:17 +0000 Subject: [PATCH 31/86] refactor: rearrange tests move tests around --- tests/clients/test_blinkstick.py | 67 ++++++++++++++++++- tests/{ => clients}/test_blinkstick_matrix.py | 0 .../test_blinkstick_pro_matrix.py | 0 tests/test_blinkstick.py | 62 ----------------- 4 files changed, 65 insertions(+), 64 deletions(-) rename tests/{ => clients}/test_blinkstick_matrix.py (100%) rename tests/{ => clients}/test_blinkstick_pro_matrix.py (100%) delete mode 100644 tests/test_blinkstick.py diff --git a/tests/clients/test_blinkstick.py b/tests/clients/test_blinkstick.py index a6f2c38..fc87d31 100644 --- a/tests/clients/test_blinkstick.py +++ b/tests/clients/test_blinkstick.py @@ -1,7 +1,69 @@ +from unittest.mock import MagicMock + import pytest +from blinkstick import BlinkStick + +from blinkstick.blinkstick import BlinkStick +from blinkstick.constants import BlinkStickVariant from pytest_mock import MockFixture -from blinkstick.blinkstick import BlinkStick, BlinkStickException +from tests.conftest import blinkstick + + +def test_instantiate(): + bs = BlinkStick() + assert bs is not None + +@pytest.mark.parametrize("serial, version_attribute, expected_variant, expected_variant_value", [ + ("BS12345-1.0", 0x0000, BlinkStickVariant.BLINKSTICK, 1), + ("BS12345-2.0", 0x0000, BlinkStickVariant.BLINKSTICK_PRO, 2), + ("BS12345-3.0", 0x200, BlinkStickVariant.BLINKSTICK_SQUARE, 4), # major version 3, version attribute 0x200 is BlinkStickSquare + ("BS12345-3.0", 0x201, BlinkStickVariant.BLINKSTICK_STRIP, 3), # major version 3 is BlinkStickStrip + ("BS12345-3.0", 0x202, BlinkStickVariant.BLINKSTICK_NANO, 5), + ("BS12345-3.0", 0x203, BlinkStickVariant.BLINKSTICK_FLEX, 6), + ("BS12345-4.0", 0x0000, BlinkStickVariant.UNKNOWN, 0), + ("BS12345-3.0", 0x9999, BlinkStickVariant.UNKNOWN, 0), + ("BS12345-0.0", 0x0000, BlinkStickVariant.UNKNOWN, 0), +], ids=[ + "v1==BlinkStick", + "v2==BlinkStickPro", + "v3,0x200==BlinkStickSquare", + "v3,0x201==BlinkStickStrip", + "v3,0x202==BlinkStickNano", + "v3,0x203==BlinkStickFlex", + "v4==Unknown", + "v3,Unknown==Unknown", + "v0,0==Unknown" +]) +def test_get_variant(blinkstick, serial, version_attribute, expected_variant, expected_variant_value): + blinkstick.get_serial = MagicMock(return_value=serial) + blinkstick.backend.get_version_attribute = MagicMock(return_value=version_attribute) + assert blinkstick.get_variant() == expected_variant + assert blinkstick.get_variant().value == expected_variant_value + + +@pytest.mark.parametrize("expected_variant, expected_name", [ + (BlinkStickVariant.BLINKSTICK, "BlinkStick"), + (BlinkStickVariant.BLINKSTICK_PRO, "BlinkStick Pro"), + (BlinkStickVariant.BLINKSTICK_STRIP, "BlinkStick Strip"), + (BlinkStickVariant.BLINKSTICK_SQUARE, "BlinkStick Square"), + (BlinkStickVariant.BLINKSTICK_NANO, "BlinkStick Nano"), + (BlinkStickVariant.BLINKSTICK_FLEX, "BlinkStick Flex"), + (BlinkStickVariant.UNKNOWN, "Unknown"), +], ids=[ + "1==BlinkStick", + "2==BlinkStickPro", + "3==BlinkStickStrip", + "4==BlinkStickSquare", + "5==BlinkStickNano", + "6==BlinkStickFlex", + "0==Unknown" +]) +def test_get_variant_string(blinkstick, expected_variant, expected_name): + """Test get_variant method for version 0 returns BlinkStick.UNKNOWN (0)""" + blinkstick.get_variant = MagicMock(return_value=expected_variant) + assert blinkstick.get_variant_string() == expected_name + def test_get_color_rgb_color_format(mocker: MockFixture): """Test get_color with color_format='rgb'. We expect it to return the color in RGB format.""" @@ -10,6 +72,7 @@ def test_get_color_rgb_color_format(mocker: MockFixture): assert blinkstick.get_color() == (255, 0, 0) assert mock_get_color_rgb.call_count == 1 + def test_get_color_hex_color_format(mocker): """Test get_color with color_format='hex'. We expect it to return the color in hex format.""" mock_get_color_hex = mocker.patch.object(BlinkStick, '_get_color_hex', return_value='#ff0000') @@ -17,10 +80,10 @@ def test_get_color_hex_color_format(mocker): assert blinkstick.get_color(color_format='hex') == '#ff0000' assert mock_get_color_hex.call_count == 1 + def test_get_color_invalid_color_format(mocker): """Test get_color with invalid color_format. We expect it not to raise an exception, but to default to RGB.""" mock_get_color_rgb = mocker.patch.object(BlinkStick, '_get_color_rgb', return_value=(255, 0, 0)) blinkstick = BlinkStick() blinkstick.get_color(color_format='invalid_format') assert mock_get_color_rgb.call_count == 1 - diff --git a/tests/test_blinkstick_matrix.py b/tests/clients/test_blinkstick_matrix.py similarity index 100% rename from tests/test_blinkstick_matrix.py rename to tests/clients/test_blinkstick_matrix.py diff --git a/tests/test_blinkstick_pro_matrix.py b/tests/clients/test_blinkstick_pro_matrix.py similarity index 100% rename from tests/test_blinkstick_pro_matrix.py rename to tests/clients/test_blinkstick_pro_matrix.py diff --git a/tests/test_blinkstick.py b/tests/test_blinkstick.py deleted file mode 100644 index 5e3dcd9..0000000 --- a/tests/test_blinkstick.py +++ /dev/null @@ -1,62 +0,0 @@ -from unittest.mock import MagicMock - -import pytest - -from blinkstick.blinkstick import BlinkStick -from blinkstick.constants import BlinkStickVariant -from tests.conftest import blinkstick - - -def test_instantiate(): - bs = BlinkStick() - assert bs is not None - -@pytest.mark.parametrize("serial, version_attribute, expected_variant, expected_variant_value", [ - ("BS12345-1.0", 0x0000, BlinkStickVariant.BLINKSTICK, 1), - ("BS12345-2.0", 0x0000, BlinkStickVariant.BLINKSTICK_PRO, 2), - ("BS12345-3.0", 0x200, BlinkStickVariant.BLINKSTICK_SQUARE, 4), # major version 3, version attribute 0x200 is BlinkStickSquare - ("BS12345-3.0", 0x201, BlinkStickVariant.BLINKSTICK_STRIP, 3), # major version 3 is BlinkStickStrip - ("BS12345-3.0", 0x202, BlinkStickVariant.BLINKSTICK_NANO, 5), - ("BS12345-3.0", 0x203, BlinkStickVariant.BLINKSTICK_FLEX, 6), - ("BS12345-4.0", 0x0000, BlinkStickVariant.UNKNOWN, 0), - ("BS12345-3.0", 0x9999, BlinkStickVariant.UNKNOWN, 0), - ("BS12345-0.0", 0x0000, BlinkStickVariant.UNKNOWN, 0), -], ids=[ - "v1==BlinkStick", - "v2==BlinkStickPro", - "v3,0x200==BlinkStickSquare", - "v3,0x201==BlinkStickStrip", - "v3,0x202==BlinkStickNano", - "v3,0x203==BlinkStickFlex", - "v4==Unknown", - "v3,Unknown==Unknown", - "v0,0==Unknown" -]) -def test_get_variant(blinkstick, serial, version_attribute, expected_variant, expected_variant_value): - blinkstick.get_serial = MagicMock(return_value=serial) - blinkstick.backend.get_version_attribute = MagicMock(return_value=version_attribute) - assert blinkstick.get_variant() == expected_variant - assert blinkstick.get_variant().value == expected_variant_value - - -@pytest.mark.parametrize("expected_variant, expected_name", [ - (BlinkStickVariant.BLINKSTICK, "BlinkStick"), - (BlinkStickVariant.BLINKSTICK_PRO, "BlinkStick Pro"), - (BlinkStickVariant.BLINKSTICK_STRIP, "BlinkStick Strip"), - (BlinkStickVariant.BLINKSTICK_SQUARE, "BlinkStick Square"), - (BlinkStickVariant.BLINKSTICK_NANO, "BlinkStick Nano"), - (BlinkStickVariant.BLINKSTICK_FLEX, "BlinkStick Flex"), - (BlinkStickVariant.UNKNOWN, "Unknown"), -], ids=[ - "1==BlinkStick", - "2==BlinkStickPro", - "3==BlinkStickStrip", - "4==BlinkStickSquare", - "5==BlinkStickNano", - "6==BlinkStickFlex", - "0==Unknown" -]) -def test_get_variant_string(blinkstick, expected_variant, expected_name): - """Test get_variant method for version 0 returns BlinkStick.UNKNOWN (0)""" - blinkstick.get_variant = MagicMock(return_value=expected_variant) - assert blinkstick.get_variant_string() == expected_name From 2a3c306a0e2eae9c9a2d9dd9e4d1ba5e39c36968 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 10 Nov 2024 09:21:58 +0000 Subject: [PATCH 32/86] refactor: update test fixtures update the get_color tests to use the blinkstick fixture --- tests/clients/test_blinkstick.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/clients/test_blinkstick.py b/tests/clients/test_blinkstick.py index fc87d31..53fa4d6 100644 --- a/tests/clients/test_blinkstick.py +++ b/tests/clients/test_blinkstick.py @@ -65,25 +65,25 @@ def test_get_variant_string(blinkstick, expected_variant, expected_name): assert blinkstick.get_variant_string() == expected_name -def test_get_color_rgb_color_format(mocker: MockFixture): +def test_get_color_rgb_color_format(mocker: MockFixture, blinkstick: BlinkStick): """Test get_color with color_format='rgb'. We expect it to return the color in RGB format.""" - mock_get_color_rgb = mocker.patch.object(BlinkStick, '_get_color_rgb', return_value=(255, 0, 0)) - blinkstick = BlinkStick() + mock_get_color_rgb = mocker.Mock(return_value=(255, 0, 0)) + blinkstick._get_color_rgb = mock_get_color_rgb assert blinkstick.get_color() == (255, 0, 0) assert mock_get_color_rgb.call_count == 1 -def test_get_color_hex_color_format(mocker): +def test_get_color_hex_color_format(mocker: MockFixture, blinkstick: BlinkStick): """Test get_color with color_format='hex'. We expect it to return the color in hex format.""" - mock_get_color_hex = mocker.patch.object(BlinkStick, '_get_color_hex', return_value='#ff0000') - blinkstick = BlinkStick() + mock_get_color_hex = mocker.Mock(return_value='#ff0000') + blinkstick._get_color_hex = mock_get_color_hex assert blinkstick.get_color(color_format='hex') == '#ff0000' assert mock_get_color_hex.call_count == 1 -def test_get_color_invalid_color_format(mocker): +def test_get_color_invalid_color_format(mocker: MockFixture, blinkstick: BlinkStick): """Test get_color with invalid color_format. We expect it not to raise an exception, but to default to RGB.""" - mock_get_color_rgb = mocker.patch.object(BlinkStick, '_get_color_rgb', return_value=(255, 0, 0)) - blinkstick = BlinkStick() + mock_get_color_rgb = mocker.Mock(return_value=(255, 0, 0)) + blinkstick._get_color_rgb = mock_get_color_rgb blinkstick.get_color(color_format='invalid_format') assert mock_get_color_rgb.call_count == 1 From 3ed9dcd2ca5661063958f3a01f6cdddb2987a830 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 10 Nov 2024 09:38:39 +0000 Subject: [PATCH 33/86] refactor: update blinkstick fixture make the blinkstick fixture return a callable that will make a blinkstick, so that we can use it to creat multiple instances in the same test. --- tests/clients/test_blinkstick.py | 41 ++++++++++++++++++-------------- tests/conftest.py | 11 +++++---- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/tests/clients/test_blinkstick.py b/tests/clients/test_blinkstick.py index 53fa4d6..686f621 100644 --- a/tests/clients/test_blinkstick.py +++ b/tests/clients/test_blinkstick.py @@ -7,7 +7,7 @@ from blinkstick.constants import BlinkStickVariant from pytest_mock import MockFixture -from tests.conftest import blinkstick +from tests.conftest import make_blinkstick def test_instantiate(): @@ -35,11 +35,12 @@ def test_instantiate(): "v3,Unknown==Unknown", "v0,0==Unknown" ]) -def test_get_variant(blinkstick, serial, version_attribute, expected_variant, expected_variant_value): - blinkstick.get_serial = MagicMock(return_value=serial) - blinkstick.backend.get_version_attribute = MagicMock(return_value=version_attribute) - assert blinkstick.get_variant() == expected_variant - assert blinkstick.get_variant().value == expected_variant_value +def test_get_variant(make_blinkstick, serial, version_attribute, expected_variant, expected_variant_value): + bs = make_blinkstick() + bs.get_serial = MagicMock(return_value=serial) + bs.backend.get_version_attribute = MagicMock(return_value=version_attribute) + assert bs.get_variant() == expected_variant + assert bs.get_variant().value == expected_variant_value @pytest.mark.parametrize("expected_variant, expected_name", [ @@ -59,31 +60,35 @@ def test_get_variant(blinkstick, serial, version_attribute, expected_variant, ex "6==BlinkStickFlex", "0==Unknown" ]) -def test_get_variant_string(blinkstick, expected_variant, expected_name): +def test_get_variant_string(make_blinkstick, expected_variant, expected_name): """Test get_variant method for version 0 returns BlinkStick.UNKNOWN (0)""" - blinkstick.get_variant = MagicMock(return_value=expected_variant) - assert blinkstick.get_variant_string() == expected_name + bs = make_blinkstick() + bs.get_variant = MagicMock(return_value=expected_variant) + assert bs.get_variant_string() == expected_name -def test_get_color_rgb_color_format(mocker: MockFixture, blinkstick: BlinkStick): +def test_get_color_rgb_color_format(mocker: MockFixture, make_blinkstick): """Test get_color with color_format='rgb'. We expect it to return the color in RGB format.""" + bs = make_blinkstick() mock_get_color_rgb = mocker.Mock(return_value=(255, 0, 0)) - blinkstick._get_color_rgb = mock_get_color_rgb - assert blinkstick.get_color() == (255, 0, 0) + bs._get_color_rgb = mock_get_color_rgb + assert bs.get_color() == (255, 0, 0) assert mock_get_color_rgb.call_count == 1 -def test_get_color_hex_color_format(mocker: MockFixture, blinkstick: BlinkStick): +def test_get_color_hex_color_format(mocker: MockFixture, make_blinkstick): """Test get_color with color_format='hex'. We expect it to return the color in hex format.""" + bs = make_blinkstick() mock_get_color_hex = mocker.Mock(return_value='#ff0000') - blinkstick._get_color_hex = mock_get_color_hex - assert blinkstick.get_color(color_format='hex') == '#ff0000' + bs._get_color_hex = mock_get_color_hex + assert bs.get_color(color_format='hex') == '#ff0000' assert mock_get_color_hex.call_count == 1 -def test_get_color_invalid_color_format(mocker: MockFixture, blinkstick: BlinkStick): +def test_get_color_invalid_color_format(mocker: MockFixture, make_blinkstick): """Test get_color with invalid color_format. We expect it not to raise an exception, but to default to RGB.""" + bs = make_blinkstick() mock_get_color_rgb = mocker.Mock(return_value=(255, 0, 0)) - blinkstick._get_color_rgb = mock_get_color_rgb - blinkstick.get_color(color_format='invalid_format') + bs._get_color_rgb = mock_get_color_rgb + bs.get_color(color_format='invalid_format') assert mock_get_color_rgb.call_count == 1 diff --git a/tests/conftest.py b/tests/conftest.py index b6bce89..fb49c08 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +from typing import Callable from unittest.mock import MagicMock import pytest @@ -6,7 +7,9 @@ @pytest.fixture -def blinkstick(): - bs = BlinkStick() - bs.backend = MagicMock() - return bs +def make_blinkstick() -> Callable[[], BlinkStick]: + def _make_blinkstick() -> BlinkStick: + bs = BlinkStick() + bs.backend = MagicMock() + return bs + return _make_blinkstick From 675849d59e60b4b254960e57f67f60c3bde62e66 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 10 Nov 2024 10:12:16 +0000 Subject: [PATCH 34/86] test: Add tests for max_rgb_value Add tests for max_rgb_value to ensure: * that it is per-instance and not class based * that it performs bounds checking when setting * that it performs type checking and coercion when setting --- tests/clients/test_blinkstick.py | 67 +++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/tests/clients/test_blinkstick.py b/tests/clients/test_blinkstick.py index 686f621..199a853 100644 --- a/tests/clients/test_blinkstick.py +++ b/tests/clients/test_blinkstick.py @@ -1,7 +1,6 @@ from unittest.mock import MagicMock import pytest -from blinkstick import BlinkStick from blinkstick.blinkstick import BlinkStick from blinkstick.constants import BlinkStickVariant @@ -92,3 +91,69 @@ def test_get_color_invalid_color_format(mocker: MockFixture, make_blinkstick): bs._get_color_rgb = mock_get_color_rgb bs.get_color(color_format='invalid_format') assert mock_get_color_rgb.call_count == 1 + + +def test_max_rgb_value_default(make_blinkstick): + """Test that the default max_rgb_value is 255.""" + bs = make_blinkstick() + assert bs.get_max_rgb_value() == 255 + +def test_max_rgb_value_not_class_attribute(make_blinkstick): + """Test that the max_rgb_value is not a class attribute.""" + bs = make_blinkstick() + assert not hasattr(BlinkStick, 'max_rgb_value') + assert hasattr(bs, 'max_rgb_value') + +def test_set_and_get_max_rgb_value(make_blinkstick): + """Test that we can set and get the max_rgb_value.""" + # Create multiple instances of BlinkStick using the fixture + bs = make_blinkstick() + + # Set different max_rgb_value for each instance + bs.set_max_rgb_value(100) + + # Assert that each instance has its own max_rgb_value + assert bs.get_max_rgb_value() == 100 + + # Change the max_rgb_value again to ensure independence + bs.set_max_rgb_value(150) + + # Assert the new values + assert bs.get_max_rgb_value() == 150 + + +def test_set_max_rgb_value_bounds(make_blinkstick): + """Test that set_max_rgb_value performs bounds checking.""" + bs = make_blinkstick() + + # Test setting a value within bounds + bs.set_max_rgb_value(100) + assert bs.get_max_rgb_value() == 100 + + # Test setting a value below the lower bound + bs.set_max_rgb_value(-1) + assert bs.get_max_rgb_value() == 0 + + # Test setting a value above the upper bound + bs.set_max_rgb_value(256) + assert bs.get_max_rgb_value() == 255 + +def test_set_max_rgb_value_type_checking(make_blinkstick): + """Test that set_max_rgb_value performs type checking and coercion.""" + bs = make_blinkstick() + + # Test setting a valid integer value + bs.set_max_rgb_value(100) + assert bs.get_max_rgb_value() == 100 + + # Test setting a value that can be coerced to an integer + bs.set_max_rgb_value("150") + assert bs.get_max_rgb_value() == 150 + + # Test setting a value that cannot be coerced to an integer + with pytest.raises(ValueError): + bs.set_max_rgb_value("invalid") + + # Test setting a float value + bs.set_max_rgb_value(100.5) + assert bs.get_max_rgb_value() == 100 \ No newline at end of file From 08a827c3e9f2e8e787bc0d39f8f80276114dd313 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 10 Nov 2024 10:14:48 +0000 Subject: [PATCH 35/86] fix: update get/set max rgb value to fix test failures --- src/blinkstick/blinkstick.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 23d0f52..d008de7 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -42,7 +42,7 @@ class BlinkStick: inverse = False error_reporting = True - max_rgb_value = 255 + max_rgb_value: int def __init__(self, device=None, error_reporting=True): """ @@ -52,6 +52,7 @@ def __init__(self, device=None, error_reporting=True): @param error_reporting: display errors if they occur during communication with the backend """ self.error_reporting = error_reporting + self.max_rgb_value = 255 if device: self.backend = USBBackend(device) @@ -605,7 +606,7 @@ def set_inverse(self, value): """ self.inverse = value - def set_max_rgb_value(self, value): + def set_max_rgb_value(self, value: int) -> None: """ Set RGB color limit. {set_color} function will automatically remap the values to maximum supplied. @@ -613,9 +614,11 @@ def set_max_rgb_value(self, value): @type value: int @param value: 0..255 maximum value for each R, G and B color """ + # convert to int and clamp to 0..255 + value = max(0, min(255, int(value))) self.max_rgb_value = value - def get_max_rgb_value(self, max_rgb_value): + def get_max_rgb_value(self) -> int: """ Get RGB color limit. {set_color} function will automatically remap the values to maximum set. From e1bb5f76f5d14f1ee0c7ff794634b634fa16b1a0 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 10 Nov 2024 12:48:49 +0000 Subject: [PATCH 36/86] test: Add tests for inverse method --- tests/clients/test_blinkstick.py | 68 ++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/clients/test_blinkstick.py b/tests/clients/test_blinkstick.py index 199a853..fb3e9ce 100644 --- a/tests/clients/test_blinkstick.py +++ b/tests/clients/test_blinkstick.py @@ -2,6 +2,7 @@ import pytest +from blinkstick import ColorFormat from blinkstick.blinkstick import BlinkStick from blinkstick.constants import BlinkStickVariant from pytest_mock import MockFixture @@ -156,4 +157,71 @@ def test_set_max_rgb_value_type_checking(make_blinkstick): # Test setting a float value bs.set_max_rgb_value(100.5) + assert bs.get_max_rgb_value() == 100 + + +def test_inverse_default(make_blinkstick): + """Test that the default inverse is False.""" + bs = make_blinkstick() + assert bs.get_inverse() == False + + +def test_inverse_not_class_attribute(make_blinkstick): + """Test that the inverse is not a class attribute.""" + bs = make_blinkstick() + assert not hasattr(BlinkStick, 'inverse') + assert hasattr(bs, 'inverse') + + +@pytest.mark.parametrize("input_value, expected_result", [ + pytest.param(True, True, id="True==True"), + pytest.param(False, False, id="False==False"), +]) +def test_inverse_set_and_get(make_blinkstick, input_value, expected_result): + """Test that we can set and get the inverse.""" + bs = make_blinkstick() + bs.set_inverse(input_value) + assert bs.get_inverse() == expected_result + +@pytest.mark.parametrize("input_value, expected_result", [ + pytest.param(True, True, id="True==True"), + pytest.param("True", True, id="StringTrue==True"), + pytest.param(1.0, True, id="1.0==True"), + pytest.param(0, False, id="0==False"), + pytest.param("False", False, id="StringFalse==False"), + pytest.param(False, False, id="False==False"), + pytest.param(0.0, False, id="0.0==False"), + pytest.param("", False, id="EmptyString==False"), + pytest.param([], False, id="EmptyList==False"), + pytest.param({}, False, id="EmptyDict==False"), + pytest.param(None, False, id="None==False"), +]) +def test_set_inverse_type_checking(make_blinkstick, input_value, expected_result): + """Test that set_inverse performs type checking and coercion.""" + bs = make_blinkstick() + bs.set_inverse(input_value) + assert bs.get_inverse() == expected_result + + +@pytest.mark.parametrize("color_mode, ctrl_transfer_bytes, color, inverse, expected_color", [ + pytest.param(ColorFormat.RGB, (0, 255, 0, 0), (255, 0, 0), False, [255, 0, 0], id="RGB, NoInverse"), + pytest.param(ColorFormat.HEX, (0, 255, 0, 0), '#ff0000', False, '#ff0000', id="Hex, NoInverse"), + pytest.param(ColorFormat.RGB, (0, 255, 0, 0), (255, 0, 0), True, [0, 255, 255], id="RGB, Inverse"), + pytest.param(ColorFormat.HEX, (0, 255, 0, 0), '#ff0000', True, '#00ffff', id="Hex, Inverse"), +]) +def test_inverse_correctly_inverts_rgb_color(make_blinkstick, color_mode, ctrl_transfer_bytes, color, inverse, expected_color): + """Test that the color is correctly inverted when the inverse flag is set.""" + bs = make_blinkstick() + # mock the backend control_transfer method to return the 3 bytes of the color + bs.backend.control_transfer = MagicMock(return_value=ctrl_transfer_bytes) + + bs.set_inverse(inverse) + assert bs.get_color(color_mode=color_mode) == expected_color + + +def test_inverse_does_not_affect_max_rgb_value(make_blinkstick): + """Test that the inverse flag does not affect the max_rgb_value.""" + bs = make_blinkstick() + bs.set_max_rgb_value(100) + bs.set_inverse(True) assert bs.get_max_rgb_value() == 100 \ No newline at end of file From dd44a8259496caf44c7b696732a5149f34dbab7f Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 10 Nov 2024 17:55:24 +0000 Subject: [PATCH 37/86] fix: update implementation of inverse functionality Update the inverse property so that it is per-instance only. Also update the set_inverse method so that it uses the truthiness of the supplied parameter as the value to use when setting inverse. --- src/blinkstick/blinkstick.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index d008de7..64016f3 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -40,7 +40,7 @@ class BlinkStick: U{https://github.com/arvydas/blinkstick-python/wiki} """ - inverse = False + inverse: bool error_reporting = True max_rgb_value: int @@ -53,6 +53,7 @@ def __init__(self, device=None, error_reporting=True): """ self.error_reporting = error_reporting self.max_rgb_value = 255 + self.inverse = False if device: self.backend = USBBackend(device) @@ -586,7 +587,7 @@ def open_device(self, d): return True - def get_inverse(self): + def get_inverse(self) -> bool: """ Get the value of inverse mode. This applies only to BlinkStick. Please use L{set_mode} for BlinkStick Pro to permanently set the inverse mode to the backend. @@ -596,7 +597,7 @@ def get_inverse(self): """ return self.inverse - def set_inverse(self, value): + def set_inverse(self, value: bool) -> None: """ Set inverse mode. This applies only to BlinkStick. Please use L{set_mode} for BlinkStick Pro to permanently set the inverse mode to the backend. @@ -604,7 +605,9 @@ def set_inverse(self, value): @type value: bool @param value: True/False to set the inverse mode """ - self.inverse = value + if type(value) is str: + value = value.lower() == "true" + self.inverse = bool(value) def set_max_rgb_value(self, value: int) -> None: """ From c65c5632f85a5bc5945cb51ac746550e02af92e5 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 16 Nov 2024 16:19:10 +0000 Subject: [PATCH 38/86] docs: Update docstrings with missing params --- src/blinkstick/blinkstick.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 64016f3..bb90469 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -132,6 +132,10 @@ def set_color(self, channel=0, index=0, red=0, green=0, blue=0, name=None, hex=N """ Set the color to the backend as RGB + @type channel: int + @param channel: the channel which to send data to (R=0, G=1, B=2) + @type index: int + @param index: the index of the LED @type red: int @param red: Red color intensity 0 is off, 255 is full red intensity @type green: int @@ -469,6 +473,10 @@ def pulse(self, channel=0, index=0, red=0, green=0, blue=0, name=None, hex=None, """ Morph to the specified color from black and back again. + @type channel: int + @param channel: the channel which to send data to (R=0, G=1, B=2) + @type index: int + @param index: the index of the LED @type red: int @param red: Red color intensity 0 is off, 255 is full red intensity @type green: int @@ -495,6 +503,10 @@ def blink(self, channel=0, index=0, red=0, green=0, blue=0, name=None, hex=None, """ Blink the specified color. + @type channel: int + @param channel: the channel which to send data to (R=0, G=1, B=2) + @type index: int + @param index: the index of the LED @type red: int @param red: Red color intensity 0 is off, 255 is full red intensity @type green: int @@ -522,6 +534,10 @@ def morph(self, channel=0, index=0, red=0, green=0, blue=0, name=None, hex=None, """ Morph to the specified color. + @type channel: int + @param channel: the channel which to send data to (R=0, G=1, B=2) + @type index: int + @param index: the index of the LED @type red: int @param red: Red color intensity 0 is off, 255 is full red intensity @type green: int @@ -701,6 +717,8 @@ def set_color(self, channel, index, r, g, b, remap_values=True): @param g: green color byte @type b: int @param b: blue color byte + @type remap_values: bool + @param remap_values: remap the values to maximum set in L{set_max_rgb_value} """ if remap_values: From 55b04eff2100767449950e2b7cf4fefd8a15828c Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 16 Nov 2024 16:24:14 +0000 Subject: [PATCH 39/86] fix: remap_rgb_value and remap_rgb_value_reverse take a tuple pass a tuple, not a list when calling `remap_rgb_value` and `remap_rgb_value_reverse` --- src/blinkstick/blinkstick.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 64016f3..7ef0072 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -184,7 +184,7 @@ def _determine_rgb(self, red=0, green=0, blue=0, name=None, hex=None): except ValueError: red = green = blue = 0 - red, green, blue = remap_rgb_value([red, green, blue], self.max_rgb_value) + red, green, blue = remap_rgb_value((red, green, blue), self.max_rgb_value) # TODO - do smarts to determine input type from red var in case it is not int @@ -540,7 +540,7 @@ def morph(self, channel=0, index=0, red=0, green=0, blue=0, name=None, hex=None, r_end, g_end, b_end = self._determine_rgb(red=red, green=green, blue=blue, name=name, hex=hex) # descale the above values - r_end, g_end, b_end = remap_rgb_value_reverse([r_end, g_end, b_end], self.max_rgb_value) + r_end, g_end, b_end = remap_rgb_value_reverse((r_end, g_end, b_end), self.max_rgb_value) r_start, g_start, b_start = remap_rgb_value_reverse(self._get_color_rgb(index), self.max_rgb_value) From 69d6381762689790532d6dd1c90b49b8c1e132b8 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 16 Nov 2024 16:28:00 +0000 Subject: [PATCH 40/86] fix: _get_color and _get_color_rgb return a tuple return a tuple, not a list from `_get_color` and `_get_color_rgb` --- src/blinkstick/blinkstick.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 64016f3..47386ea 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -200,7 +200,7 @@ def _get_color_rgb(self, index=0): else: data = self.get_led_data((index + 1) * 3) - return [data[index * 3 + 1], data[index * 3], data[index * 3 + 2]] + return data[index * 3 + 1], data[index * 3], data[index * 3 + 2] def _get_color_hex(self, index=0): r, g, b = self._get_color_rgb(index) @@ -722,7 +722,7 @@ def get_color(self, channel, index): """ val = self.data[channel][index] - return [val[1], val[0], val[2]] + return val[1], val[0], val[2] def clear(self): """ @@ -897,7 +897,7 @@ def get_color(self, x, y): """ val = self.matrix_data[self._coord_to_index(x, y)] - return [val[1], val[0], val[2]] + return val[1], val[0], val[2] def shift_left(self, remove=False): """ From 14ffd444b041a2bdaa5cac69427da53b486c3e59 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 16 Nov 2024 16:29:21 +0000 Subject: [PATCH 41/86] chore: type hint functions and methods Add type hinting to all functions and methods --- src/blinkstick/backends/base.py | 8 +- src/blinkstick/backends/unix_like.py | 18 ++-- src/blinkstick/backends/win32.py | 16 ++-- src/blinkstick/blinkstick.py | 131 ++++++++++++++++----------- src/blinkstick/constants.py | 4 +- 5 files changed, 101 insertions(+), 76 deletions(-) diff --git a/src/blinkstick/backends/base.py b/src/blinkstick/backends/base.py index 1e18472..7e0e1ed 100644 --- a/src/blinkstick/backends/base.py +++ b/src/blinkstick/backends/base.py @@ -29,17 +29,17 @@ def control_transfer(self, bmRequestType: int, bRequest: int, wValue: int, wInde raise NotImplementedError @abstractmethod - def get_serial(self): + def get_serial(self) -> str: raise NotImplementedError @abstractmethod - def get_manufacturer(self): + def get_manufacturer(self) -> str: raise NotImplementedError @abstractmethod - def get_version_attribute(self): + def get_version_attribute(self) -> int: raise NotImplementedError @abstractmethod - def get_description(self): + def get_description(self) -> str: raise NotImplementedError diff --git a/src/blinkstick/backends/unix_like.py b/src/blinkstick/backends/unix_like.py index bfd8add..e2a7172 100644 --- a/src/blinkstick/backends/unix_like.py +++ b/src/blinkstick/backends/unix_like.py @@ -10,6 +10,8 @@ class UnixLikeBackend(BaseBackend): + serial: str + def __init__(self, device=None): self.device = device super().__init__() @@ -17,7 +19,7 @@ def __init__(self, device=None): self.open_device() self.serial = self.get_serial() - def open_device(self): + def open_device(self) -> None: if self.device is None: raise BlinkStickException("Could not find BlinkStick...") @@ -27,8 +29,6 @@ def open_device(self): except usb.core.USBError as e: raise BlinkStickException("Could not detach kernel driver: %s" % str(e)) - return True - def _refresh_device(self): if not self.serial: return False @@ -67,23 +67,23 @@ def control_transfer(self, bmRequestType: int, bRequest: int, wValue: int, wInde def get_serial(self) -> str: return self._usb_get_string(3) - def get_manufacturer(self): + def get_manufacturer(self)-> str: return self._usb_get_string(1) - def get_version_attribute(self): - return self.device.bcdDevice + def get_version_attribute(self) -> int: + return int(self.device.bcdDevice) def get_description(self): return self._usb_get_string(2) - def _usb_get_string(self, index): + def _usb_get_string(self, index: int) -> str: try: - return usb.util.get_string(self.device, index, 1033) + return str(usb.util.get_string(self.device, index, 1033)) except usb.USBError: # Could not communicate with BlinkStick backend # attempt to find it again based on serial if self._refresh_device(): - return usb.util.get_string(self.device, index, 1033) + return str(usb.util.get_string(self.device, index, 1033)) else: raise BlinkStickException("Could not communicate with BlinkStick {0} - it may have been removed".format(self.serial)) \ No newline at end of file diff --git a/src/blinkstick/backends/win32.py b/src/blinkstick/backends/win32.py index 42ad5b4..a383cdc 100644 --- a/src/blinkstick/backends/win32.py +++ b/src/blinkstick/backends/win32.py @@ -65,14 +65,14 @@ def control_transfer(self, bmRequestType, bRequest, wValue, wIndex, data_or_wLen elif bmRequestType == 0x80 | 0x20: return self.reports[wValue - 1].get() - def get_serial(self): - return self.device.serial_number + def get_serial(self) -> str: + return str(self.device.serial_number) - def get_manufacturer(self): - return self.device.vendor_name + def get_manufacturer(self) -> str: + return str(self.device.vendor_name) - def get_version_attribute(self): - return self.device.version_number + def get_version_attribute(self) -> int: + return int(self.device.version_number) - def get_description(self): - return self.device.product_name \ No newline at end of file + def get_description(self) -> str: + return str(self.device.product_name) \ No newline at end of file diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 64016f3..cfb82ad 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import sys import time import warnings @@ -44,7 +46,10 @@ class BlinkStick: error_reporting = True max_rgb_value: int - def __init__(self, device=None, error_reporting=True): + backend: USBBackend + bs_serial: str + + def __init__(self, device=None, error_reporting: bool=True): """ Constructor for the class. @@ -60,7 +65,7 @@ def __init__(self, device=None, error_reporting=True): self.bs_serial = self.get_serial() - def get_serial(self): + def get_serial(self) -> str: """ Returns the serial number of backend.:: @@ -77,7 +82,7 @@ def get_serial(self): """ return self.backend.get_serial() - def get_manufacturer(self): + def get_manufacturer(self) -> str: """ Get the manufacturer of the backend @@ -110,7 +115,7 @@ def get_variant_string(self) -> str: """ return self.get_variant().description - def get_description(self): + def get_description(self) -> str: """ Get the description of the backend @@ -119,7 +124,7 @@ def get_description(self): """ return self.backend.get_description() - def set_error_reporting(self, error_reporting): + def set_error_reporting(self, error_reporting: bool) -> None: """ Enable or disable error reporting @@ -128,7 +133,7 @@ def set_error_reporting(self, error_reporting): """ self.error_reporting = error_reporting - def set_color(self, channel=0, index=0, red=0, green=0, blue=0, name=None, hex=None): + def set_color(self, channel: int = 0, index: int = 0, red: int = 0, green: int = 0, blue: int = 0, name: str | None = None, hex: str | None = None) -> None : """ Set the color to the backend as RGB @@ -168,7 +173,7 @@ def set_color(self, channel=0, index=0, red=0, green=0, blue=0, name=None, hex=N except Exception: pass - def _determine_rgb(self, red=0, green=0, blue=0, name=None, hex=None): + def _determine_rgb(self, red: int = 0, green: int = 0, blue: int = 0, name: str | None = None, hex: str | None = None) -> tuple[int, int, int]: try: if name: @@ -190,7 +195,7 @@ def _determine_rgb(self, red=0, green=0, blue=0, name=None, hex=None): return red, green, blue - def _get_color_rgb(self, index=0): + def _get_color_rgb(self, index: int = 0) -> tuple[int, int, int]: if index == 0: device_bytes = self.backend.control_transfer(0x80 | 0x20, 0x1, 0x0001, 0, 33) if self.inverse: @@ -202,11 +207,11 @@ def _get_color_rgb(self, index=0): return [data[index * 3 + 1], data[index * 3], data[index * 3 + 2]] - def _get_color_hex(self, index=0): + def _get_color_hex(self, index: int = 0) -> str: r, g, b = self._get_color_rgb(index) return '#%02x%02x%02x' % (r, g, b) - def get_color(self, index: int=0, color_mode: ColorFormat = ColorFormat.RGB, color_format: str=None): + def get_color(self, index: int=0, color_mode: ColorFormat = ColorFormat.RGB, color_format: str=None) -> tuple[int, int, int] | str: """ Get the current backend color in the defined format. @@ -249,7 +254,7 @@ def get_color(self, index: int=0, color_mode: ColorFormat = ColorFormat.RGB, col return color_funcs.get(color_mode, ColorFormat.RGB)(index) - def _determine_report_id(self, led_count): + def _determine_report_id(self, led_count: int) -> tuple[int, int]: report_id = 9 max_leds = 64 @@ -268,7 +273,7 @@ def _determine_report_id(self, led_count): return report_id, max_leds - def set_led_data(self, channel, data): + def set_led_data(self, channel: int, data: list[int]) -> None: """ Send LED data frame. @@ -290,7 +295,7 @@ def set_led_data(self, channel, data): self.backend.control_transfer(0x20, 0x9, report_id, 0, bytes(bytearray(report))) - def get_led_data(self, count): + def get_led_data(self, count: int) -> list[int]: """ Get LED data frame on the backend. @@ -306,7 +311,7 @@ def get_led_data(self, count): return device_bytes[2: 2 + count * 3] - def set_mode(self, mode): + def set_mode(self, mode: int) -> None: """ Set backend mode for BlinkStick Pro. Device currently supports the following modes: @@ -325,7 +330,7 @@ def set_mode(self, mode): self.backend.control_transfer(0x20, 0x9, 0x0004, 0, control_string) - def get_mode(self): + def get_mode(self) -> int: """ Get BlinkStick Pro mode. Device currently supports the following modes: @@ -348,7 +353,7 @@ def get_mode(self): else: return -1 - def set_led_count(self, count): + def set_led_count(self, count: int) -> None: """ Set number of LEDs for supported devices @@ -360,7 +365,7 @@ def set_led_count(self, count): self.backend.control_transfer(0x20, 0x9, 0x81, 0, control_string) - def get_led_count(self): + def get_led_count(self) -> int: """ Get number of LEDs for supported devices @@ -375,7 +380,7 @@ def get_led_count(self): else: return -1 - def get_info_block1(self): + def get_info_block1(self) -> str: """ Get the infoblock1 of the backend. @@ -395,7 +400,7 @@ def get_info_block1(self): result += chr(i) return result - def get_info_block2(self): + def get_info_block2(self) -> str: """ Get the infoblock2 of the backend. @@ -412,7 +417,7 @@ def get_info_block2(self): result += chr(i) return result - def _data_to_message(self, data): + def _data_to_message(self, data) -> bytes: """ Helper method to convert a string to byte array of 32 bytes. @@ -431,7 +436,7 @@ def _data_to_message(self, data): return bytes - def set_info_block1(self, data): + def set_info_block1(self, data: str) -> None: """ Sets the infoblock1 with specified string. @@ -442,7 +447,7 @@ def set_info_block1(self, data): """ self.backend.control_transfer(0x20, 0x9, 0x0002, 0, self._data_to_message(data)) - def set_info_block2(self, data): + def set_info_block2(self, data: str) -> None: """ Sets the infoblock2 with specified string. @@ -453,19 +458,19 @@ def set_info_block2(self, data): """ self.backend.control_transfer(0x20, 0x9, 0x0003, 0, self._data_to_message(data)) - def set_random_color(self): + def set_random_color(self) -> None: """ Sets random color to the backend. """ self.set_color(name="random") - def turn_off(self): + def turn_off(self) -> None: """ Turns off LED. """ self.set_color() - def pulse(self, channel=0, index=0, red=0, green=0, blue=0, name=None, hex=None, repeats=1, duration=1000, steps=50): + def pulse(self, channel: int = 0, index: int = 0, red: int = 0, green: int = 0, blue: int = 0, name: str | None = None, hex: str | None = None, repeats: int = 1, duration: int = 1000, steps: int = 50) -> None: """ Morph to the specified color from black and back again. @@ -491,7 +496,7 @@ def pulse(self, channel=0, index=0, red=0, green=0, blue=0, name=None, hex=None, self.morph(channel=channel, index=index, red=red, green=green, blue=blue, name=name, hex=hex, duration=duration, steps=steps) self.morph(channel=channel, index=index, red=0, green=0, blue=0, duration=duration, steps=steps) - def blink(self, channel=0, index=0, red=0, green=0, blue=0, name=None, hex=None, repeats=1, delay=500): + def blink(self, channel: int = 0, index: int = 0, red: int = 0, green: int = 0, blue: int = 0, name: str | None = None, hex: str | None = None, repeats: int = 1, delay: int = 500) -> None: """ Blink the specified color. @@ -518,7 +523,7 @@ def blink(self, channel=0, index=0, red=0, green=0, blue=0, name=None, hex=None, time.sleep(ms_delay) self.set_color(channel=channel, index=index) - def morph(self, channel=0, index=0, red=0, green=0, blue=0, name=None, hex=None, duration=1000, steps=50): + def morph(self, channel: int = 0, index: int = 0, red: int = 0, green: int = 0, blue: int = 0, name: str | None = None, hex: str | None = None, duration: int = 1000, steps: int = 50) -> None: """ Morph to the specified color. @@ -645,7 +650,17 @@ class BlinkStickPro: U{https://github.com/arvydas/blinkstick-python/wiki#code-examples-for-blinkstick-pro} """ - def __init__(self, r_led_count=0, g_led_count=0, b_led_count=0, delay=0.002, max_rgb_value=255): + r_led_count: int + g_led_count: int + b_led_count: int + fps_count: int + data_transmission_delay: float + max_rgb_value: int + data: list[list[list[int]]] + bstick: BlinkStick | None + + + def __init__(self, r_led_count: int = 0, g_led_count: int = 0, b_led_count: int = 0, delay: float = 0.002, max_rgb_value: int = 255): """ Initialize BlinkStickPro class. @@ -687,7 +702,7 @@ def __init__(self, r_led_count=0, g_led_count=0, b_led_count=0, delay=0.002, max self.bstick = None - def set_color(self, channel, index, r, g, b, remap_values=True): + def set_color(self, channel: int, index: int, r: int, g: int, b: int, remap_values: bool = True) -> None: """ Set the color of a single pixel @@ -708,7 +723,7 @@ def set_color(self, channel, index, r, g, b, remap_values=True): self.data[channel][index] = [g, r, b] - def get_color(self, channel, index): + def get_color(self, channel: int, index: int) -> tuple[int, int, int]: """ Get the current color of a single pixel. @@ -724,7 +739,7 @@ def get_color(self, channel, index): val = self.data[channel][index] return [val[1], val[0], val[2]] - def clear(self): + def clear(self) -> None: """ Set all pixels to black in the frame buffer. """ @@ -737,14 +752,14 @@ def clear(self): for x in range(0, self.b_led_count): self.set_color(2, x, 0, 0, 0) - def off(self): + def off(self) -> None: """ Set all pixels to black in on the backend. """ self.clear() self.send_data_all() - def connect(self, serial=None): + def connect(self, serial: str | None = None): """ Connect to the first BlinkStick found @@ -759,7 +774,7 @@ def connect(self, serial=None): return self.bstick is not None - def send_data(self, channel): + def send_data(self, channel: int) -> None: """ Send data stored in the internal buffer to the channel. @@ -776,7 +791,7 @@ def send_data(self, channel): except Exception as e: print("Exception: {0}".format(e)) - def send_data_all(self): + def send_data_all(self) -> None: """ Send data to all channels """ @@ -820,7 +835,17 @@ class BlinkStickProMatrix(BlinkStickPro): """ - def __init__(self, r_columns=0, r_rows=0, g_columns=0, g_rows=0, b_columns=0, b_rows=0, delay=0.002, max_rgb_value=255): + r_columns: int + r_rows: int + g_columns: int + g_rows: int + b_columns: int + b_rows: int + rows: int + cols: int + matrix_data: list[list[int]] + + def __init__(self, r_columns: int = 0, r_rows: int = 0, g_columns: int = 0, g_rows: int = 0, b_columns: int = 0, b_rows: int = 0, delay: float = 0.002, max_rgb_value: int = 255): """ Initialize BlinkStickProMatrix class. @@ -857,7 +882,7 @@ def __init__(self, r_columns=0, r_rows=0, g_columns=0, g_rows=0, b_columns=0, b_ for i in range(0, self.rows * self.cols): self.matrix_data.append([0, 0, 0]) - def set_color(self, x, y, r, g, b, remap_values=True): + def set_color(self, x: int, y: int, r: int, g: int, b: int, remap_values: bool = True) -> None: """ Set the color of a single pixel in the internal framebuffer. @@ -880,10 +905,10 @@ def set_color(self, x, y, r, g, b, remap_values=True): self.matrix_data[self._coord_to_index(x, y)] = [g, r, b] - def _coord_to_index(self, x, y): + def _coord_to_index(self, x: int, y: int) -> int: return y * self.cols + x - def get_color(self, x, y): + def get_color(self, x: int, y: int) -> tuple[int, int, int]: """ Get the current color of a single pixel. @@ -899,7 +924,7 @@ def get_color(self, x, y): val = self.matrix_data[self._coord_to_index(x, y)] return [val[1], val[0], val[2]] - def shift_left(self, remove=False): + def shift_left(self, remove: bool = False) -> None: """ Shift all LED values in the matrix to the left @@ -925,7 +950,7 @@ def shift_left(self, remove=False): col = temp[y] self.set_color(self.cols - 1, y, col[0], col[1], col[2], False) - def shift_right(self, remove=False): + def shift_right(self, remove: bool = False) -> None: """ Shift all LED values in the matrix to the right @@ -952,7 +977,7 @@ def shift_right(self, remove=False): col = temp[y] self.set_color(0, y, col[0], col[1], col[2], False) - def shift_down(self, remove=False): + def shift_down(self, remove: bool = False) -> None: """ Shift all LED values in the matrix down @@ -979,7 +1004,7 @@ def shift_down(self, remove=False): col = temp[x] self.set_color(x, 0, col[0], col[1], col[2], False) - def shift_up(self, remove=False): + def shift_up(self, remove: bool = False): """ Shift all LED values in the matrix up @@ -1006,7 +1031,7 @@ def shift_up(self, remove=False): col = temp[x] self.set_color(x, self.rows - 1, col[0], col[1], col[2], False) - def number(self, x, y, n, r, g, b): + def number(self, x: int, y: int, n: int, r: int, g: int, b: int) -> None: """ Render a 3x5 number n at location x,y and r,g,b color @@ -1078,7 +1103,7 @@ def number(self, x, y, n, r, g, b): self.set_color(x + 2, y + 1, r, g, b) self.set_color(x + 2, y + 3, r, g, b) - def rectangle(self, x1, y1, x2, y2, r, g, b): + def rectangle(self, x1: int, y1: int, x2: int, y2: int, r: int, g: int, b: int) -> None: """ Draw a rectangle with it's corners at x1:y1 and x2:y2 @@ -1103,7 +1128,7 @@ def rectangle(self, x1, y1, x2, y2, r, g, b): self.line(x2, y1, x2, y2, r, g, b) self.line(x1, y2, x2, y2, r, g, b) - def line(self, x1, y1, x2, y2, r, g, b): + def line(self, x1: int, y1: int, x2: int, y2: int, r: int, g: int, b: int) -> list[tuple[int, int]]: """ Draw a line from x1:y1 and x2:y2 @@ -1160,7 +1185,7 @@ def line(self, x1, y1, x2, y2, r, g, b): points.reverse() return points - def clear(self): + def clear(self) -> None: """ Set all pixels to black in the cached matrix """ @@ -1168,7 +1193,7 @@ def clear(self): for x in range(0, self.cols): self.set_color(x, y, 0, 0, 0) - def send_data(self, channel): + def send_data(self, channel: int) -> None: """ Send data stored in the internal buffer to the channel. @@ -1204,7 +1229,7 @@ def send_data(self, channel): super(BlinkStickProMatrix, self).send_data(channel) -def _find_blicksticks(find_all=True): +def _find_blicksticks(find_all: bool = True) -> list[BlinkStick] | None: if sys.platform == "win32": devices = hid.HidDeviceFilter(vendor_id =VENDOR_ID, product_id =PRODUCT_ID).get_devices() if find_all: @@ -1218,7 +1243,7 @@ def _find_blicksticks(find_all=True): return usb.core.find(find_all=find_all, idVendor=VENDOR_ID, idProduct=PRODUCT_ID) -def find_all(): +def find_all() -> list[BlinkStick]: """ Find all attached BlinkStick devices. @@ -1232,7 +1257,7 @@ def find_all(): return result -def find_first(): +def find_first() -> BlinkStick | None: """ Find first attached BlinkStick. @@ -1245,7 +1270,7 @@ def find_first(): return BlinkStick(device=d) -def find_by_serial(serial=None): +def find_by_serial(serial: str | None = None) -> BlinkStick | None: """ Find BlinkStick backend based on serial number. @@ -1259,5 +1284,5 @@ def find_by_serial(serial=None): return BlinkStick(device=devices[0]) -def get_blinkstick_package_version(): +def get_blinkstick_package_version() -> str: return version("blinkstick") diff --git a/src/blinkstick/constants.py b/src/blinkstick/constants.py index 8a0c755..8cee3b8 100644 --- a/src/blinkstick/constants.py +++ b/src/blinkstick/constants.py @@ -15,11 +15,11 @@ class BlinkStickVariant(Enum): BLINKSTICK_FLEX = (6, "BlinkStick Flex") @property - def value(self): + def value(self) -> int: return self._value_[0] @property - def description(self): + def description(self) -> str: return self._value_[1] @staticmethod From 2c4173dbae8564885ece7371e08e220a25ca8dcb Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 17 Nov 2024 08:29:44 +0000 Subject: [PATCH 42/86] chore: Apply Black formatting Apply Black formatting to the entire codebase. Note that this touches _everything_, so if you're interested in running `git blame` to see who made chages to files prior to this, it will muddy the water. In the following commit, a `.git-blame-ignore-revs` file will be added, which can be used to configure git to ignore this commit when running git blame. see https://git-scm.com/docs/git-config#Documentation/git-config.txt-blameignoreRevsFile for details of how to enable this behaviour. --- src/blinkstick/__init__.py | 9 +- src/blinkstick/backends/base.py | 9 +- src/blinkstick/backends/unix_like.py | 36 +++- src/blinkstick/backends/win32.py | 33 ++- src/blinkstick/blinkstick.py | 226 ++++++++++++++++---- src/blinkstick/colors.py | 41 ++-- src/blinkstick/constants.py | 11 +- src/blinkstick/main.py | 295 ++++++++++++++++----------- tests/clients/test_blinkstick.py | 206 ++++++++++++------- tests/colors/test_colors.py | 112 +++++++--- tests/conftest.py | 1 + 11 files changed, 687 insertions(+), 292 deletions(-) diff --git a/src/blinkstick/__init__.py b/src/blinkstick/__init__.py index 6d2c3ab..78537bc 100644 --- a/src/blinkstick/__init__.py +++ b/src/blinkstick/__init__.py @@ -1,7 +1,12 @@ from importlib.metadata import version, PackageNotFoundError from .blinkstick import BlinkStick, BlinkStickPro, BlinkStickProMatrix -from .blinkstick import find_all, find_by_serial, find_first, get_blinkstick_package_version +from .blinkstick import ( + find_all, + find_by_serial, + find_first, + get_blinkstick_package_version, +) from .colors import Color, ColorFormat from .constants import BlinkStickVariant from .exceptions import BlinkStickException @@ -9,4 +14,4 @@ try: __version__ = version("blinkstick") except PackageNotFoundError: - __version__ = "BlinkStick package not installed" \ No newline at end of file + __version__ = "BlinkStick package not installed" diff --git a/src/blinkstick/backends/base.py b/src/blinkstick/backends/base.py index 7e0e1ed..9e67007 100644 --- a/src/blinkstick/backends/base.py +++ b/src/blinkstick/backends/base.py @@ -25,7 +25,14 @@ def find_by_serial(serial: str) -> BaseBackend | None: raise NotImplementedError @abstractmethod - def control_transfer(self, bmRequestType: int, bRequest: int, wValue: int, wIndex: int, data_or_wLength: bytes | int): + def control_transfer( + self, + bmRequestType: int, + bRequest: int, + wValue: int, + wIndex: int, + data_or_wLength: bytes | int, + ): raise NotImplementedError @abstractmethod diff --git a/src/blinkstick/backends/unix_like.py b/src/blinkstick/backends/unix_like.py index e2a7172..9310389 100644 --- a/src/blinkstick/backends/unix_like.py +++ b/src/blinkstick/backends/unix_like.py @@ -39,7 +39,9 @@ def _refresh_device(self): @staticmethod def find_blinksticks(find_all: bool = True): - return usb.core.find(find_all=find_all, idVendor=VENDOR_ID, idProduct=PRODUCT_ID) + return usb.core.find( + find_all=find_all, idVendor=VENDOR_ID, idProduct=PRODUCT_ID + ) @staticmethod def find_by_serial(serial: str) -> list | None: @@ -51,23 +53,37 @@ def find_by_serial(serial: str) -> list | None: except Exception as e: print("{0}".format(e)) - def control_transfer(self, bmRequestType: int, bRequest: int, wValue: int, wIndex: int, - data_or_wLength: bytes | int): + def control_transfer( + self, + bmRequestType: int, + bRequest: int, + wValue: int, + wIndex: int, + data_or_wLength: bytes | int, + ): try: - return self.device.ctrl_transfer(bmRequestType, bRequest, wValue, wIndex, data_or_wLength) + return self.device.ctrl_transfer( + bmRequestType, bRequest, wValue, wIndex, data_or_wLength + ) except usb.USBError: # Could not communicate with BlinkStick backend # attempt to find it again based on serial if self._refresh_device(): - return self.device.ctrl_transfer(bmRequestType, bRequest, wValue, wIndex, data_or_wLength) + return self.device.ctrl_transfer( + bmRequestType, bRequest, wValue, wIndex, data_or_wLength + ) else: - raise BlinkStickException("Could not communicate with BlinkStick {0} - it may have been removed".format(self.serial)) + raise BlinkStickException( + "Could not communicate with BlinkStick {0} - it may have been removed".format( + self.serial + ) + ) def get_serial(self) -> str: return self._usb_get_string(3) - def get_manufacturer(self)-> str: + def get_manufacturer(self) -> str: return self._usb_get_string(1) def get_version_attribute(self) -> int: @@ -86,4 +102,8 @@ def _usb_get_string(self, index: int) -> str: if self._refresh_device(): return str(usb.util.get_string(self.device, index, 1033)) else: - raise BlinkStickException("Could not communicate with BlinkStick {0} - it may have been removed".format(self.serial)) \ No newline at end of file + raise BlinkStickException( + "Could not communicate with BlinkStick {0} - it may have been removed".format( + self.serial + ) + ) diff --git a/src/blinkstick/backends/win32.py b/src/blinkstick/backends/win32.py index a383cdc..a41c382 100644 --- a/src/blinkstick/backends/win32.py +++ b/src/blinkstick/backends/win32.py @@ -17,17 +17,17 @@ def __init__(self, device=None): if device: self.device.open() self.reports = self.device.find_feature_reports() - self.serial = self.get_serial() + self.serial = self.get_serial() @staticmethod def find_by_serial(serial: str) -> list | None: - devices = [d for d in Win32Backend.find_blinksticks() - if d.serial_number == serial] + devices = [ + d for d in Win32Backend.find_blinksticks() if d.serial_number == serial + ] if len(devices) > 0: return devices - def _refresh_device(self): # TODO This is weird semantics. fix up return values to be more sensible if not self.serial: @@ -40,7 +40,9 @@ def _refresh_device(self): @staticmethod def find_blinksticks(find_all: bool = True): - devices = hid.HidDeviceFilter(vendor_id =VENDOR_ID, product_id =PRODUCT_ID).get_devices() + devices = hid.HidDeviceFilter( + vendor_id=VENDOR_ID, product_id=PRODUCT_ID + ).get_devices() if find_all: return devices elif len(devices) > 0: @@ -48,19 +50,28 @@ def find_blinksticks(find_all: bool = True): else: return None - - def control_transfer(self, bmRequestType, bRequest, wValue, wIndex, data_or_wLength): + def control_transfer( + self, bmRequestType, bRequest, wValue, wIndex, data_or_wLength + ): if bmRequestType == 0x20: if sys.version_info[0] < 3: - data = (c_ubyte * len(data_or_wLength))(*[c_ubyte(ord(c)) for c in data_or_wLength]) + data = (c_ubyte * len(data_or_wLength))( + *[c_ubyte(ord(c)) for c in data_or_wLength] + ) else: - data = (c_ubyte * len(data_or_wLength))(*[c_ubyte(c) for c in data_or_wLength]) + data = (c_ubyte * len(data_or_wLength))( + *[c_ubyte(c) for c in data_or_wLength] + ) data[0] = wValue if not self.device.send_feature_report(data): if self._refresh_device(): self.device.send_feature_report(data) else: - raise BlinkStickException("Could not communicate with BlinkStick {0} - it may have been removed".format(self.serial)) + raise BlinkStickException( + "Could not communicate with BlinkStick {0} - it may have been removed".format( + self.serial + ) + ) elif bmRequestType == 0x80 | 0x20: return self.reports[wValue - 1].get() @@ -75,4 +86,4 @@ def get_version_attribute(self) -> int: return int(self.device.version_number) def get_description(self) -> str: - return str(self.device.product_name) \ No newline at end of file + return str(self.device.product_name) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 5238fee..5f9079d 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -11,7 +11,7 @@ remap_color, remap_rgb_value, remap_rgb_value_reverse, - ColorFormat + ColorFormat, ) from blinkstick.constants import VENDOR_ID, PRODUCT_ID, BlinkStickVariant from blinkstick.exceptions import BlinkStickException @@ -49,7 +49,7 @@ class BlinkStick: backend: USBBackend bs_serial: str - def __init__(self, device=None, error_reporting: bool=True): + def __init__(self, device=None, error_reporting: bool = True): """ Constructor for the class. @@ -64,7 +64,6 @@ def __init__(self, device=None, error_reporting: bool=True): self.backend = USBBackend(device) self.bs_serial = self.get_serial() - def get_serial(self) -> str: """ Returns the serial number of backend.:: @@ -133,7 +132,16 @@ def set_error_reporting(self, error_reporting: bool) -> None: """ self.error_reporting = error_reporting - def set_color(self, channel: int = 0, index: int = 0, red: int = 0, green: int = 0, blue: int = 0, name: str | None = None, hex: str | None = None) -> None : + def set_color( + self, + channel: int = 0, + index: int = 0, + red: int = 0, + green: int = 0, + blue: int = 0, + name: str | None = None, + hex: str | None = None, + ) -> None: """ Set the color to the backend as RGB @@ -153,7 +161,9 @@ def set_color(self, channel: int = 0, index: int = 0, red: int = 0, green: int = @param hex: Specify color using hexadecimal color value e.g. '#FF3366' """ - red, green, blue = self._determine_rgb(red=red, green=green, blue=blue, name=name, hex=hex) + red, green, blue = self._determine_rgb( + red=red, green=green, blue=blue, name=name, hex=hex + ) r = int(round(red, 3)) g = int(round(green, 3)) @@ -177,7 +187,14 @@ def set_color(self, channel: int = 0, index: int = 0, red: int = 0, green: int = except Exception: pass - def _determine_rgb(self, red: int = 0, green: int = 0, blue: int = 0, name: str | None = None, hex: str | None = None) -> tuple[int, int, int]: + def _determine_rgb( + self, + red: int = 0, + green: int = 0, + blue: int = 0, + name: str | None = None, + hex: str | None = None, + ) -> tuple[int, int, int]: try: if name: @@ -201,9 +218,15 @@ def _determine_rgb(self, red: int = 0, green: int = 0, blue: int = 0, name: str def _get_color_rgb(self, index: int = 0) -> tuple[int, int, int]: if index == 0: - device_bytes = self.backend.control_transfer(0x80 | 0x20, 0x1, 0x0001, 0, 33) + device_bytes = self.backend.control_transfer( + 0x80 | 0x20, 0x1, 0x0001, 0, 33 + ) if self.inverse: - return [255 - device_bytes[1], 255 - device_bytes[2], 255 - device_bytes[3]] + return [ + 255 - device_bytes[1], + 255 - device_bytes[2], + 255 - device_bytes[3], + ] else: return [device_bytes[1], device_bytes[2], device_bytes[3]] else: @@ -213,9 +236,14 @@ def _get_color_rgb(self, index: int = 0) -> tuple[int, int, int]: def _get_color_hex(self, index: int = 0) -> str: r, g, b = self._get_color_rgb(index) - return '#%02x%02x%02x' % (r, g, b) + return "#%02x%02x%02x" % (r, g, b) - def get_color(self, index: int=0, color_mode: ColorFormat = ColorFormat.RGB, color_format: str=None) -> tuple[int, int, int] | str: + def get_color( + self, + index: int = 0, + color_mode: ColorFormat = ColorFormat.RGB, + color_format: str = None, + ) -> tuple[int, int, int] | str: """ Get the current backend color in the defined format. @@ -245,7 +273,10 @@ def get_color(self, index: int=0, color_mode: ColorFormat = ColorFormat.RGB, col # if color_format is specified, then raise a DeprecationWarning, but attempt to convert it to a ColorFormat enum # if it's not possible, then default to ColorFormat.RGB, in line with the previous behavior if color_format: - warnings.warn("color_format is deprecated, please use color_mode instead", DeprecationWarning) + warnings.warn( + "color_format is deprecated, please use color_mode instead", + DeprecationWarning, + ) try: color_mode = ColorFormat.from_name(color_format) except ValueError: @@ -253,7 +284,7 @@ def get_color(self, index: int=0, color_mode: ColorFormat = ColorFormat.RGB, col color_funcs = { ColorFormat.RGB: self._get_color_rgb, - ColorFormat.HEX: self._get_color_hex + ColorFormat.HEX: self._get_color_hex, } return color_funcs.get(color_mode, ColorFormat.RGB)(index) @@ -311,9 +342,11 @@ def get_led_data(self, count: int) -> list[int]: report_id, max_leds = self._determine_report_id(count) - device_bytes = self.backend.control_transfer(0x80 | 0x20, 0x1, report_id, 0, max_leds * 3 + 2) + device_bytes = self.backend.control_transfer( + 0x80 | 0x20, 0x1, report_id, 0, max_leds * 3 + 2 + ) - return device_bytes[2: 2 + count * 3] + return device_bytes[2 : 2 + count * 3] def set_mode(self, mode: int) -> None: """ @@ -368,7 +401,6 @@ def set_led_count(self, count: int) -> None: self.backend.control_transfer(0x20, 0x9, 0x81, 0, control_string) - def get_led_count(self) -> int: """ Get number of LEDs for supported devices @@ -474,7 +506,19 @@ def turn_off(self) -> None: """ self.set_color() - def pulse(self, channel: int = 0, index: int = 0, red: int = 0, green: int = 0, blue: int = 0, name: str | None = None, hex: str | None = None, repeats: int = 1, duration: int = 1000, steps: int = 50) -> None: + def pulse( + self, + channel: int = 0, + index: int = 0, + red: int = 0, + green: int = 0, + blue: int = 0, + name: str | None = None, + hex: str | None = None, + repeats: int = 1, + duration: int = 1000, + steps: int = 50, + ) -> None: """ Morph to the specified color from black and back again. @@ -501,10 +545,39 @@ def pulse(self, channel: int = 0, index: int = 0, red: int = 0, green: int = 0, """ self.turn_off() for x in range(repeats): - self.morph(channel=channel, index=index, red=red, green=green, blue=blue, name=name, hex=hex, duration=duration, steps=steps) - self.morph(channel=channel, index=index, red=0, green=0, blue=0, duration=duration, steps=steps) - - def blink(self, channel: int = 0, index: int = 0, red: int = 0, green: int = 0, blue: int = 0, name: str | None = None, hex: str | None = None, repeats: int = 1, delay: int = 500) -> None: + self.morph( + channel=channel, + index=index, + red=red, + green=green, + blue=blue, + name=name, + hex=hex, + duration=duration, + steps=steps, + ) + self.morph( + channel=channel, + index=index, + red=0, + green=0, + blue=0, + duration=duration, + steps=steps, + ) + + def blink( + self, + channel: int = 0, + index: int = 0, + red: int = 0, + green: int = 0, + blue: int = 0, + name: str | None = None, + hex: str | None = None, + repeats: int = 1, + delay: int = 500, + ) -> None: """ Blink the specified color. @@ -531,11 +604,30 @@ def blink(self, channel: int = 0, index: int = 0, red: int = 0, green: int = 0, for x in range(repeats): if x: time.sleep(ms_delay) - self.set_color(channel=channel, index=index, red=red, green=green, blue=blue, name=name, hex=hex) + self.set_color( + channel=channel, + index=index, + red=red, + green=green, + blue=blue, + name=name, + hex=hex, + ) time.sleep(ms_delay) self.set_color(channel=channel, index=index) - def morph(self, channel: int = 0, index: int = 0, red: int = 0, green: int = 0, blue: int = 0, name: str | None = None, hex: str | None = None, duration: int = 1000, steps: int = 50) -> None: + def morph( + self, + channel: int = 0, + index: int = 0, + red: int = 0, + green: int = 0, + blue: int = 0, + name: str | None = None, + hex: str | None = None, + duration: int = 1000, + steps: int = 50, + ) -> None: """ Morph to the specified color. @@ -559,11 +651,17 @@ def morph(self, channel: int = 0, index: int = 0, red: int = 0, green: int = 0, @param steps: Number of gradient steps (default 50) """ - r_end, g_end, b_end = self._determine_rgb(red=red, green=green, blue=blue, name=name, hex=hex) + r_end, g_end, b_end = self._determine_rgb( + red=red, green=green, blue=blue, name=name, hex=hex + ) # descale the above values - r_end, g_end, b_end = remap_rgb_value_reverse((r_end, g_end, b_end), self.max_rgb_value) + r_end, g_end, b_end = remap_rgb_value_reverse( + (r_end, g_end, b_end), self.max_rgb_value + ) - r_start, g_start, b_start = remap_rgb_value_reverse(self._get_color_rgb(index), self.max_rgb_value) + r_start, g_start, b_start = remap_rgb_value_reverse( + self._get_color_rgb(index), self.max_rgb_value + ) if r_start > 255 or g_start > 255 or b_start > 255: r_start = 0 @@ -583,12 +681,16 @@ def morph(self, channel: int = 0, index: int = 0, red: int = 0, green: int = 0, ms_delay = float(duration) / float(1000 * steps) - self.set_color(channel=channel, index=index, red=r_start, green=g_start, blue=b_start) + self.set_color( + channel=channel, index=index, red=r_start, green=g_start, blue=b_start + ) for grad in gradient: grad_r, grad_g, grad_b = grad - self.set_color(channel=channel, index=index, red=grad_r, green=grad_g, blue=grad_b) + self.set_color( + channel=channel, index=index, red=grad_r, green=grad_g, blue=grad_b + ) time.sleep(ms_delay) self.set_color(channel=channel, index=index, red=r_end, green=g_end, blue=b_end) @@ -675,8 +777,14 @@ class BlinkStickPro: data: list[list[list[int]]] bstick: BlinkStick | None - - def __init__(self, r_led_count: int = 0, g_led_count: int = 0, b_led_count: int = 0, delay: float = 0.002, max_rgb_value: int = 255): + def __init__( + self, + r_led_count: int = 0, + g_led_count: int = 0, + b_led_count: int = 0, + delay: float = 0.002, + max_rgb_value: int = 255, + ): """ Initialize BlinkStickPro class. @@ -718,7 +826,15 @@ def __init__(self, r_led_count: int = 0, g_led_count: int = 0, b_led_count: int self.bstick = None - def set_color(self, channel: int, index: int, r: int, g: int, b: int, remap_values: bool = True) -> None: + def set_color( + self, + channel: int, + index: int, + r: int, + g: int, + b: int, + remap_values: bool = True, + ) -> None: """ Set the color of a single pixel @@ -822,6 +938,7 @@ def send_data_all(self) -> None: if self.b_led_count > 0: self.send_data(2) + class BlinkStickProMatrix(BlinkStickPro): """ BlinkStickProMatrix class is specifically designed to control the individually @@ -863,7 +980,17 @@ class BlinkStickProMatrix(BlinkStickPro): cols: int matrix_data: list[list[int]] - def __init__(self, r_columns: int = 0, r_rows: int = 0, g_columns: int = 0, g_rows: int = 0, b_columns: int = 0, b_rows: int = 0, delay: float = 0.002, max_rgb_value: int = 255): + def __init__( + self, + r_columns: int = 0, + r_rows: int = 0, + g_columns: int = 0, + g_rows: int = 0, + b_columns: int = 0, + b_rows: int = 0, + delay: float = 0.002, + max_rgb_value: int = 255, + ): """ Initialize BlinkStickProMatrix class. @@ -889,7 +1016,13 @@ def __init__(self, r_columns: int = 0, r_rows: int = 0, g_columns: int = 0, g_ro self.b_columns = b_columns self.b_rows = b_rows - super(BlinkStickProMatrix, self).__init__(r_led_count=r_leds, g_led_count=g_leds, b_led_count=b_leds, delay=delay, max_rgb_value=max_rgb_value) + super(BlinkStickProMatrix, self).__init__( + r_led_count=r_leds, + g_led_count=g_leds, + b_led_count=b_leds, + delay=delay, + max_rgb_value=max_rgb_value, + ) self.rows = max(r_rows, g_rows, b_rows) self.cols = r_columns + g_columns + b_columns @@ -900,7 +1033,9 @@ def __init__(self, r_columns: int = 0, r_rows: int = 0, g_columns: int = 0, g_ro for i in range(0, self.rows * self.cols): self.matrix_data.append([0, 0, 0]) - def set_color(self, x: int, y: int, r: int, g: int, b: int, remap_values: bool = True) -> None: + def set_color( + self, x: int, y: int, r: int, g: int, b: int, remap_values: bool = True + ) -> None: """ Set the color of a single pixel in the internal framebuffer. @@ -1121,7 +1256,9 @@ def number(self, x: int, y: int, n: int, r: int, g: int, b: int) -> None: self.set_color(x + 2, y + 1, r, g, b) self.set_color(x + 2, y + 3, r, g, b) - def rectangle(self, x1: int, y1: int, x2: int, y2: int, r: int, g: int, b: int) -> None: + def rectangle( + self, x1: int, y1: int, x2: int, y2: int, r: int, g: int, b: int + ) -> None: """ Draw a rectangle with it's corners at x1:y1 and x2:y2 @@ -1146,7 +1283,9 @@ def rectangle(self, x1: int, y1: int, x2: int, y2: int, r: int, g: int, b: int) self.line(x2, y1, x2, y2, r, g, b) self.line(x1, y2, x2, y2, r, g, b) - def line(self, x1: int, y1: int, x2: int, y2: int, r: int, g: int, b: int) -> list[tuple[int, int]]: + def line( + self, x1: int, y1: int, x2: int, y2: int, r: int, g: int, b: int + ) -> list[tuple[int, int]]: """ Draw a line from x1:y1 and x2:y2 @@ -1187,11 +1326,11 @@ def line(self, x1: int, y1: int, x2: int, y2: int, r: int, g: int, b: int) -> li y_step = -1 for x in range(x1, x2 + 1): if is_steep: - #print y, "~", x + # print y, "~", x self.set_color(y, x, r, g, b) points.append((y, x)) else: - #print x, " ", y + # print x, " ", y self.set_color(x, y, r, g, b) points.append((x, y)) error -= delta_y @@ -1238,18 +1377,21 @@ def send_data(self, channel: int) -> None: self.data[channel] = [] - #slice the huge array to individual packets + # slice the huge array to individual packets for y in range(0, self.rows): start = y * self.cols + start_col end = y * self.cols + end_col - self.data[channel].extend(self.matrix_data[start: end]) + self.data[channel].extend(self.matrix_data[start:end]) super(BlinkStickProMatrix, self).send_data(channel) + def _find_blicksticks(find_all: bool = True) -> list[BlinkStick] | None: if sys.platform == "win32": - devices = hid.HidDeviceFilter(vendor_id =VENDOR_ID, product_id =PRODUCT_ID).get_devices() + devices = hid.HidDeviceFilter( + vendor_id=VENDOR_ID, product_id=PRODUCT_ID + ).get_devices() if find_all: return devices elif len(devices) > 0: @@ -1258,7 +1400,9 @@ def _find_blicksticks(find_all: bool = True) -> list[BlinkStick] | None: return None else: - return usb.core.find(find_all=find_all, idVendor=VENDOR_ID, idProduct=PRODUCT_ID) + return usb.core.find( + find_all=find_all, idVendor=VENDOR_ID, idProduct=PRODUCT_ID + ) def find_all() -> list[BlinkStick]: diff --git a/src/blinkstick/colors.py b/src/blinkstick/colors.py index 325c6fb..12ab0f7 100644 --- a/src/blinkstick/colors.py +++ b/src/blinkstick/colors.py @@ -1,7 +1,8 @@ import re from enum import Enum, auto -HEX_COLOR_RE = re.compile(r'^#([a-fA-F0-9]{3}|[a-fA-F0-9]{6})$') +HEX_COLOR_RE = re.compile(r"^#([a-fA-F0-9]{3}|[a-fA-F0-9]{6})$") + class Color(Enum): ALICEBLUE = "#f0f8ff" @@ -230,8 +231,8 @@ def normalize_hex(hex_value: str) -> str: except AttributeError: raise ValueError("'%s' is not a valid hexadecimal color value." % hex_value) if len(hex_digits) == 3: - hex_digits = ''.join([2 * s for s in hex_digits]) - return '#%s' % hex_digits.lower() + hex_digits = "".join([2 * s for s in hex_digits]) + return "#%s" % hex_digits.lower() def hex_to_rgb(hex_value: str) -> tuple[int, int, int]: @@ -275,7 +276,9 @@ def name_to_rgb(name: str) -> tuple[int, int, int]: return hex_to_rgb(name_to_hex(name)) -def remap(value: int, left_min: int, left_max: int, right_min: int, right_max: int) -> int: +def remap( + value: int, left_min: int, left_max: int, right_min: int, right_max: int +) -> int: """ Remap a value from one range to another. """ @@ -296,17 +299,25 @@ def remap_color(value: int, max_value: int) -> int: return remap(value, 0, 255, 0, max_value) -def remap_color_reverse(value: int, max_value : int) -> int: +def remap_color_reverse(value: int, max_value: int) -> int: return remap(value, 0, max_value, 0, 255) -def remap_rgb_value(rgb_val: tuple[int, int, int], max_value: int) -> tuple[int, int, int]: - return (remap_color(rgb_val[0], max_value), - remap_color(rgb_val[1], max_value), - remap_color(rgb_val[2], max_value)) - - -def remap_rgb_value_reverse(rgb_val: tuple[int, int, int], max_value: int) -> tuple[int, int, int]: - return (remap_color_reverse(rgb_val[0], max_value), - remap_color_reverse(rgb_val[1], max_value), - remap_color_reverse(rgb_val[2], max_value)) +def remap_rgb_value( + rgb_val: tuple[int, int, int], max_value: int +) -> tuple[int, int, int]: + return ( + remap_color(rgb_val[0], max_value), + remap_color(rgb_val[1], max_value), + remap_color(rgb_val[2], max_value), + ) + + +def remap_rgb_value_reverse( + rgb_val: tuple[int, int, int], max_value: int +) -> tuple[int, int, int]: + return ( + remap_color_reverse(rgb_val[0], max_value), + remap_color_reverse(rgb_val[1], max_value), + remap_color_reverse(rgb_val[2], max_value), + ) diff --git a/src/blinkstick/constants.py b/src/blinkstick/constants.py index 8cee3b8..14348d3 100644 --- a/src/blinkstick/constants.py +++ b/src/blinkstick/constants.py @@ -2,8 +2,9 @@ from enum import Enum -VENDOR_ID = 0x20a0 -PRODUCT_ID = 0x41e5 +VENDOR_ID = 0x20A0 +PRODUCT_ID = 0x41E5 + class BlinkStickVariant(Enum): UNKNOWN = (0, "Unknown") @@ -23,7 +24,9 @@ def description(self) -> str: return self._value_[1] @staticmethod - def identify(major_version: int, version_attribute: int | None) -> "BlinkStickVariant": + def identify( + major_version: int, version_attribute: int | None + ) -> "BlinkStickVariant": if major_version == 1: return BlinkStickVariant.BLINKSTICK elif major_version == 2: @@ -37,4 +40,4 @@ def identify(major_version: int, version_attribute: int | None) -> "BlinkStickVa return BlinkStickVariant.BLINKSTICK_NANO elif version_attribute == 0x203: return BlinkStickVariant.BLINKSTICK_FLEX - return BlinkStickVariant.UNKNOWN \ No newline at end of file + return BlinkStickVariant.UNKNOWN diff --git a/src/blinkstick/main.py b/src/blinkstick/main.py index 5d9b87f..62a7c40 100644 --- a/src/blinkstick/main.py +++ b/src/blinkstick/main.py @@ -7,23 +7,25 @@ from .blinkstick import get_blinkstick_package_version, find_all, find_by_serial from .constants import BlinkStickVariant + logging.basicConfig() class IndentedHelpFormatterWithNL(IndentedHelpFormatter): def format_description(self, description): - if not description: return "" + if not description: + return "" desc_width = self.width - self.current_indent indent = " " * self.current_indent # the above is still the same - bits = description.split('\n') + bits = description.split("\n") formatted_bits = [ - textwrap.fill(bit, - desc_width, - initial_indent=indent, - subsequent_indent=indent) - for bit in bits] + textwrap.fill( + bit, desc_width, initial_indent=indent, subsequent_indent=indent + ) + for bit in bits + ] result = "\n".join(formatted_bits) + "\n" return result @@ -62,17 +64,22 @@ def format_option(self, option): for para in help_text.split("\n"): help_lines.extend(textwrap.wrap(para, self.help_width)) # Everything is the same after here - result.append("%*s%s\n" % ( - indent_first, "", help_lines[0])) - result.extend(["%*s%s\n" % (self.help_position, "", line) - for line in help_lines[1:]]) + result.append("%*s%s\n" % (indent_first, "", help_lines[0])) + result.extend( + ["%*s%s\n" % (self.help_position, "", line) for line in help_lines[1:]] + ) elif opts[-1] != "\n": result.append("\n") return "".join(result) def format_usage(self, usage): - return "BlinkStick control script %s\n(c) Agile Innovative Ltd 2013-2014\n\n%s" % ( - get_blinkstick_package_version(), IndentedHelpFormatter.format_usage(self, usage)) + return ( + "BlinkStick control script %s\n(c) Agile Innovative Ltd 2013-2014\n\n%s" + % ( + get_blinkstick_package_version(), + IndentedHelpFormatter.format_usage(self, usage), + ) + ) def print_info(stick): @@ -100,121 +107,169 @@ def main(): global options global sticks - parser = OptionParser(usage="usage: %prog [options] [color]", - formatter=IndentedHelpFormatterWithNL() + parser = OptionParser( + usage="usage: %prog [options] [color]", formatter=IndentedHelpFormatterWithNL() ) - parser.add_option("-i", "--info", - action="store_true", dest="info", - help="Display BlinkStick info") - - parser.add_option("-s", "--serial", - dest="serial", - help="Select backend by serial number. If unspecified, action will be performed on all BlinkSticks.") + parser.add_option( + "-i", "--info", action="store_true", dest="info", help="Display BlinkStick info" + ) - parser.add_option("-v", "--verbose", - action="store_true", dest="verbose", - help="Display debug output") + parser.add_option( + "-s", + "--serial", + dest="serial", + help="Select backend by serial number. If unspecified, action will be performed on all BlinkSticks.", + ) + parser.add_option( + "-v", + "--verbose", + action="store_true", + dest="verbose", + help="Display debug output", + ) - group = OptionGroup(parser, "Change color", - "These options control the color of the backend ") + group = OptionGroup( + parser, "Change color", "These options control the color of the backend " + ) - group.add_option("--channel", - default=0, dest="channel", - help="Select channel. Applies only to BlinkStick Pro.") + group.add_option( + "--channel", + default=0, + dest="channel", + help="Select channel. Applies only to BlinkStick Pro.", + ) - group.add_option("--index", - default=0, dest="index", - help="Select index. Applies only to BlinkStick Pro.") + group.add_option( + "--index", + default=0, + dest="index", + help="Select index. Applies only to BlinkStick Pro.", + ) - group.add_option("--brightness", - default=100, dest="limit", - help="Limit the brightness of the color 0..100") + group.add_option( + "--brightness", + default=100, + dest="limit", + help="Limit the brightness of the color 0..100", + ) - group.add_option("--limit", - default=100, dest="limit", - help="Alias to --brightness option") + group.add_option( + "--limit", default=100, dest="limit", help="Alias to --brightness option" + ) - group.add_option("--set-color", - dest="color", - help="Set the color for the backend. This can also be the last argument for the script. " - "The value can either be a named color, hex value, 'random' or 'off'.\n\n" - "CSS color names are defined http://www.w3.org/TR/css3-color/ e.g. red, green, blue. " - "Specify color using hexadecimal color value e.g. 'FF3366'") - group.add_option("--inverse", - action="store_true", dest="inverse", - help="Control BlinkSticks in inverse mode") + group.add_option( + "--set-color", + dest="color", + help="Set the color for the backend. This can also be the last argument for the script. " + "The value can either be a named color, hex value, 'random' or 'off'.\n\n" + "CSS color names are defined http://www.w3.org/TR/css3-color/ e.g. red, green, blue. " + "Specify color using hexadecimal color value e.g. 'FF3366'", + ) + group.add_option( + "--inverse", + action="store_true", + dest="inverse", + help="Control BlinkSticks in inverse mode", + ) - group.add_option("--set-led-count", - dest="led_count", - help="Set the number of LEDs to control for supported devices.") + group.add_option( + "--set-led-count", + dest="led_count", + help="Set the number of LEDs to control for supported devices.", + ) parser.add_option_group(group) - group = OptionGroup(parser, "Control animations", - "These options will blink, morph or pulse selected color. ") + group = OptionGroup( + parser, + "Control animations", + "These options will blink, morph or pulse selected color. ", + ) - group.add_option("--blink", - dest="blink", - action='store_true', - help="Blink LED (requires --set-color or color set as last argument, and optionally --delay)") + group.add_option( + "--blink", + dest="blink", + action="store_true", + help="Blink LED (requires --set-color or color set as last argument, and optionally --delay)", + ) - group.add_option("--pulse", - dest="pulse", - action='store_true', - help="Pulse LED (requires --set-color or color set as last argument, and optionally --duration).") + group.add_option( + "--pulse", + dest="pulse", + action="store_true", + help="Pulse LED (requires --set-color or color set as last argument, and optionally --duration).", + ) - group.add_option("--morph", - dest="morph", - action='store_true', - help="Morph to specified color (requires --set-color or color set as last argument, and optionally --duration).") + group.add_option( + "--morph", + dest="morph", + action="store_true", + help="Morph to specified color (requires --set-color or color set as last argument, and optionally --duration).", + ) - group.add_option("--duration", - dest="duration", - default=1000, - help="Set duration of transition in milliseconds (use with --morph and --pulse).") + group.add_option( + "--duration", + dest="duration", + default=1000, + help="Set duration of transition in milliseconds (use with --morph and --pulse).", + ) - group.add_option("--delay", - dest="delay", - default=500, - help="Set time in milliseconds to light LED for (use with --blink).") + group.add_option( + "--delay", + dest="delay", + default=500, + help="Set time in milliseconds to light LED for (use with --blink).", + ) - group.add_option("--repeats", - dest="repeats", - default=1, - help="Number of repetitions (use with --blink and --pulse).") + group.add_option( + "--repeats", + dest="repeats", + default=1, + help="Number of repetitions (use with --blink and --pulse).", + ) parser.add_option_group(group) - group = OptionGroup(parser, "Device data and behaviour", - "These options will change backend mode and data stored internally. ") + group = OptionGroup( + parser, + "Device data and behaviour", + "These options will change backend mode and data stored internally. ", + ) - group.add_option("--set-mode", - default=0, dest="mode", - help="Set mode for BlinkStick Pro:\n\n 0 - default\n\n 1 - inverse\n\n 2 - ws2812\n\n 3 - ws2812 mirror") + group.add_option( + "--set-mode", + default=0, + dest="mode", + help="Set mode for BlinkStick Pro:\n\n 0 - default\n\n 1 - inverse\n\n 2 - ws2812\n\n 3 - ws2812 mirror", + ) - group.add_option("--set-infoblock1", - dest="infoblock1", - help="Set the first info block for the backend.") + group.add_option( + "--set-infoblock1", + dest="infoblock1", + help="Set the first info block for the backend.", + ) - group.add_option("--set-infoblock2", - dest="infoblock2", - help="Set the second info block for the backend.") + group.add_option( + "--set-infoblock2", + dest="infoblock2", + help="Set the second info block for the backend.", + ) parser.add_option_group(group) - group = OptionGroup(parser, "Advanced options", - "") + group = OptionGroup(parser, "Advanced options", "") - group.add_option("--add-udev-rule", - action="store_true", dest="udev", - help="Add udev rule to access BlinkSticks without root permissions. Must be run as root.") + group.add_option( + "--add-udev-rule", + action="store_true", + dest="udev", + help="Add udev rule to access BlinkSticks without root permissions. Must be run as root.", + ) parser.add_option_group(group) - - (options, args) = parser.parse_args() if options.serial is None: @@ -226,19 +281,23 @@ def main(): print("BlinkStick with serial number " + options.backend + " not found...") return 64 - #Global action + # Global action if options.udev: try: filename = "/etc/udev/rules.d/85-blinkstick.rules" - file = open(filename, 'w') - file.write('SUBSYSTEM=="usb", ATTR{idVendor}=="20a0", ATTR{idProduct}=="41e5", MODE:="0666"') + file = open(filename, "w") + file.write( + 'SUBSYSTEM=="usb", ATTR{idVendor}=="20a0", ATTR{idProduct}=="41e5", MODE:="0666"' + ) file.close() print("Rule added to {0}".format(filename)) except IOError as e: print(str(e)) - print("Make sure you run this script as root: sudo blinkstick --add-udev-rule") + print( + "Make sure you run this script as root: sudo blinkstick --add-udev-rule" + ) return 64 print("Reboot your computer for changes to take effect") @@ -252,7 +311,7 @@ def main(): stick.set_error_reporting(False) - #Actions here work on all BlinkSticks + # Actions here work on all BlinkSticks for stick in sticks: if options.infoblock1: stick.set_info_block1(options.infoblock1) @@ -261,7 +320,12 @@ def main(): stick.set_info_block2(options.infoblock2) if options.mode: - if options.mode == "0" or options.mode == "1" or options.mode == "2" or options.mode == "3": + if ( + options.mode == "0" + or options.mode == "1" + or options.mode == "2" + or options.mode == "3" + ): stick.set_mode(int(options.mode)) else: print("Error: Invalid mode parameter value") @@ -284,43 +348,42 @@ def main(): # determine color fargs = {} - if color.startswith('#'): - fargs['hex'] = color + if color.startswith("#"): + fargs["hex"] = color elif color == "random": - fargs['name'] = 'random' + fargs["name"] = "random" elif color == "off": - fargs['hex'] = "#000000" + fargs["hex"] = "#000000" else: if len(color) == 6: # If color contains 6 chars check if it's hex try: int(color, 16) - fargs['hex'] = "#" + color + fargs["hex"] = "#" + color except: - fargs['name'] = color + fargs["name"] = color else: - fargs['name'] = color + fargs["name"] = color - fargs['index'] = int(options.index) - fargs['channel'] = int(options.channel) + fargs["index"] = int(options.index) + fargs["channel"] = int(options.channel) # handle blink/pulse/morph func = stick.set_color if options.blink: func = stick.blink - fargs['delay'] = options.delay - fargs['repeats'] = int(options.repeats) + fargs["delay"] = options.delay + fargs["repeats"] = int(options.repeats) elif options.pulse: func = stick.pulse - fargs['duration'] = options.duration - fargs['repeats'] = int(options.repeats) + fargs["duration"] = options.duration + fargs["repeats"] = int(options.repeats) elif options.morph: func = stick.morph - fargs['duration'] = options.duration + fargs["duration"] = options.duration func(**fargs) - else: parser.print_help() return 0 diff --git a/tests/clients/test_blinkstick.py b/tests/clients/test_blinkstick.py index fb3e9ce..ad6fc09 100644 --- a/tests/clients/test_blinkstick.py +++ b/tests/clients/test_blinkstick.py @@ -14,28 +14,45 @@ def test_instantiate(): bs = BlinkStick() assert bs is not None -@pytest.mark.parametrize("serial, version_attribute, expected_variant, expected_variant_value", [ - ("BS12345-1.0", 0x0000, BlinkStickVariant.BLINKSTICK, 1), - ("BS12345-2.0", 0x0000, BlinkStickVariant.BLINKSTICK_PRO, 2), - ("BS12345-3.0", 0x200, BlinkStickVariant.BLINKSTICK_SQUARE, 4), # major version 3, version attribute 0x200 is BlinkStickSquare - ("BS12345-3.0", 0x201, BlinkStickVariant.BLINKSTICK_STRIP, 3), # major version 3 is BlinkStickStrip - ("BS12345-3.0", 0x202, BlinkStickVariant.BLINKSTICK_NANO, 5), - ("BS12345-3.0", 0x203, BlinkStickVariant.BLINKSTICK_FLEX, 6), - ("BS12345-4.0", 0x0000, BlinkStickVariant.UNKNOWN, 0), - ("BS12345-3.0", 0x9999, BlinkStickVariant.UNKNOWN, 0), - ("BS12345-0.0", 0x0000, BlinkStickVariant.UNKNOWN, 0), -], ids=[ - "v1==BlinkStick", - "v2==BlinkStickPro", - "v3,0x200==BlinkStickSquare", - "v3,0x201==BlinkStickStrip", - "v3,0x202==BlinkStickNano", - "v3,0x203==BlinkStickFlex", - "v4==Unknown", - "v3,Unknown==Unknown", - "v0,0==Unknown" -]) -def test_get_variant(make_blinkstick, serial, version_attribute, expected_variant, expected_variant_value): + +@pytest.mark.parametrize( + "serial, version_attribute, expected_variant, expected_variant_value", + [ + ("BS12345-1.0", 0x0000, BlinkStickVariant.BLINKSTICK, 1), + ("BS12345-2.0", 0x0000, BlinkStickVariant.BLINKSTICK_PRO, 2), + ( + "BS12345-3.0", + 0x200, + BlinkStickVariant.BLINKSTICK_SQUARE, + 4, + ), # major version 3, version attribute 0x200 is BlinkStickSquare + ( + "BS12345-3.0", + 0x201, + BlinkStickVariant.BLINKSTICK_STRIP, + 3, + ), # major version 3 is BlinkStickStrip + ("BS12345-3.0", 0x202, BlinkStickVariant.BLINKSTICK_NANO, 5), + ("BS12345-3.0", 0x203, BlinkStickVariant.BLINKSTICK_FLEX, 6), + ("BS12345-4.0", 0x0000, BlinkStickVariant.UNKNOWN, 0), + ("BS12345-3.0", 0x9999, BlinkStickVariant.UNKNOWN, 0), + ("BS12345-0.0", 0x0000, BlinkStickVariant.UNKNOWN, 0), + ], + ids=[ + "v1==BlinkStick", + "v2==BlinkStickPro", + "v3,0x200==BlinkStickSquare", + "v3,0x201==BlinkStickStrip", + "v3,0x202==BlinkStickNano", + "v3,0x203==BlinkStickFlex", + "v4==Unknown", + "v3,Unknown==Unknown", + "v0,0==Unknown", + ], +) +def test_get_variant( + make_blinkstick, serial, version_attribute, expected_variant, expected_variant_value +): bs = make_blinkstick() bs.get_serial = MagicMock(return_value=serial) bs.backend.get_version_attribute = MagicMock(return_value=version_attribute) @@ -43,23 +60,27 @@ def test_get_variant(make_blinkstick, serial, version_attribute, expected_varian assert bs.get_variant().value == expected_variant_value -@pytest.mark.parametrize("expected_variant, expected_name", [ - (BlinkStickVariant.BLINKSTICK, "BlinkStick"), - (BlinkStickVariant.BLINKSTICK_PRO, "BlinkStick Pro"), - (BlinkStickVariant.BLINKSTICK_STRIP, "BlinkStick Strip"), - (BlinkStickVariant.BLINKSTICK_SQUARE, "BlinkStick Square"), - (BlinkStickVariant.BLINKSTICK_NANO, "BlinkStick Nano"), - (BlinkStickVariant.BLINKSTICK_FLEX, "BlinkStick Flex"), - (BlinkStickVariant.UNKNOWN, "Unknown"), -], ids=[ - "1==BlinkStick", - "2==BlinkStickPro", - "3==BlinkStickStrip", - "4==BlinkStickSquare", - "5==BlinkStickNano", - "6==BlinkStickFlex", - "0==Unknown" -]) +@pytest.mark.parametrize( + "expected_variant, expected_name", + [ + (BlinkStickVariant.BLINKSTICK, "BlinkStick"), + (BlinkStickVariant.BLINKSTICK_PRO, "BlinkStick Pro"), + (BlinkStickVariant.BLINKSTICK_STRIP, "BlinkStick Strip"), + (BlinkStickVariant.BLINKSTICK_SQUARE, "BlinkStick Square"), + (BlinkStickVariant.BLINKSTICK_NANO, "BlinkStick Nano"), + (BlinkStickVariant.BLINKSTICK_FLEX, "BlinkStick Flex"), + (BlinkStickVariant.UNKNOWN, "Unknown"), + ], + ids=[ + "1==BlinkStick", + "2==BlinkStickPro", + "3==BlinkStickStrip", + "4==BlinkStickSquare", + "5==BlinkStickNano", + "6==BlinkStickFlex", + "0==Unknown", + ], +) def test_get_variant_string(make_blinkstick, expected_variant, expected_name): """Test get_variant method for version 0 returns BlinkStick.UNKNOWN (0)""" bs = make_blinkstick() @@ -79,9 +100,9 @@ def test_get_color_rgb_color_format(mocker: MockFixture, make_blinkstick): def test_get_color_hex_color_format(mocker: MockFixture, make_blinkstick): """Test get_color with color_format='hex'. We expect it to return the color in hex format.""" bs = make_blinkstick() - mock_get_color_hex = mocker.Mock(return_value='#ff0000') + mock_get_color_hex = mocker.Mock(return_value="#ff0000") bs._get_color_hex = mock_get_color_hex - assert bs.get_color(color_format='hex') == '#ff0000' + assert bs.get_color(color_format="hex") == "#ff0000" assert mock_get_color_hex.call_count == 1 @@ -90,7 +111,7 @@ def test_get_color_invalid_color_format(mocker: MockFixture, make_blinkstick): bs = make_blinkstick() mock_get_color_rgb = mocker.Mock(return_value=(255, 0, 0)) bs._get_color_rgb = mock_get_color_rgb - bs.get_color(color_format='invalid_format') + bs.get_color(color_format="invalid_format") assert mock_get_color_rgb.call_count == 1 @@ -99,11 +120,13 @@ def test_max_rgb_value_default(make_blinkstick): bs = make_blinkstick() assert bs.get_max_rgb_value() == 255 + def test_max_rgb_value_not_class_attribute(make_blinkstick): """Test that the max_rgb_value is not a class attribute.""" bs = make_blinkstick() - assert not hasattr(BlinkStick, 'max_rgb_value') - assert hasattr(bs, 'max_rgb_value') + assert not hasattr(BlinkStick, "max_rgb_value") + assert hasattr(bs, "max_rgb_value") + def test_set_and_get_max_rgb_value(make_blinkstick): """Test that we can set and get the max_rgb_value.""" @@ -139,6 +162,7 @@ def test_set_max_rgb_value_bounds(make_blinkstick): bs.set_max_rgb_value(256) assert bs.get_max_rgb_value() == 255 + def test_set_max_rgb_value_type_checking(make_blinkstick): """Test that set_max_rgb_value performs type checking and coercion.""" bs = make_blinkstick() @@ -169,33 +193,40 @@ def test_inverse_default(make_blinkstick): def test_inverse_not_class_attribute(make_blinkstick): """Test that the inverse is not a class attribute.""" bs = make_blinkstick() - assert not hasattr(BlinkStick, 'inverse') - assert hasattr(bs, 'inverse') + assert not hasattr(BlinkStick, "inverse") + assert hasattr(bs, "inverse") -@pytest.mark.parametrize("input_value, expected_result", [ - pytest.param(True, True, id="True==True"), - pytest.param(False, False, id="False==False"), -]) +@pytest.mark.parametrize( + "input_value, expected_result", + [ + pytest.param(True, True, id="True==True"), + pytest.param(False, False, id="False==False"), + ], +) def test_inverse_set_and_get(make_blinkstick, input_value, expected_result): """Test that we can set and get the inverse.""" bs = make_blinkstick() bs.set_inverse(input_value) assert bs.get_inverse() == expected_result -@pytest.mark.parametrize("input_value, expected_result", [ - pytest.param(True, True, id="True==True"), - pytest.param("True", True, id="StringTrue==True"), - pytest.param(1.0, True, id="1.0==True"), - pytest.param(0, False, id="0==False"), - pytest.param("False", False, id="StringFalse==False"), - pytest.param(False, False, id="False==False"), - pytest.param(0.0, False, id="0.0==False"), - pytest.param("", False, id="EmptyString==False"), - pytest.param([], False, id="EmptyList==False"), - pytest.param({}, False, id="EmptyDict==False"), - pytest.param(None, False, id="None==False"), -]) + +@pytest.mark.parametrize( + "input_value, expected_result", + [ + pytest.param(True, True, id="True==True"), + pytest.param("True", True, id="StringTrue==True"), + pytest.param(1.0, True, id="1.0==True"), + pytest.param(0, False, id="0==False"), + pytest.param("False", False, id="StringFalse==False"), + pytest.param(False, False, id="False==False"), + pytest.param(0.0, False, id="0.0==False"), + pytest.param("", False, id="EmptyString==False"), + pytest.param([], False, id="EmptyList==False"), + pytest.param({}, False, id="EmptyDict==False"), + pytest.param(None, False, id="None==False"), + ], +) def test_set_inverse_type_checking(make_blinkstick, input_value, expected_result): """Test that set_inverse performs type checking and coercion.""" bs = make_blinkstick() @@ -203,13 +234,46 @@ def test_set_inverse_type_checking(make_blinkstick, input_value, expected_result assert bs.get_inverse() == expected_result -@pytest.mark.parametrize("color_mode, ctrl_transfer_bytes, color, inverse, expected_color", [ - pytest.param(ColorFormat.RGB, (0, 255, 0, 0), (255, 0, 0), False, [255, 0, 0], id="RGB, NoInverse"), - pytest.param(ColorFormat.HEX, (0, 255, 0, 0), '#ff0000', False, '#ff0000', id="Hex, NoInverse"), - pytest.param(ColorFormat.RGB, (0, 255, 0, 0), (255, 0, 0), True, [0, 255, 255], id="RGB, Inverse"), - pytest.param(ColorFormat.HEX, (0, 255, 0, 0), '#ff0000', True, '#00ffff', id="Hex, Inverse"), -]) -def test_inverse_correctly_inverts_rgb_color(make_blinkstick, color_mode, ctrl_transfer_bytes, color, inverse, expected_color): +@pytest.mark.parametrize( + "color_mode, ctrl_transfer_bytes, color, inverse, expected_color", + [ + pytest.param( + ColorFormat.RGB, + (0, 255, 0, 0), + (255, 0, 0), + False, + [255, 0, 0], + id="RGB, NoInverse", + ), + pytest.param( + ColorFormat.HEX, + (0, 255, 0, 0), + "#ff0000", + False, + "#ff0000", + id="Hex, NoInverse", + ), + pytest.param( + ColorFormat.RGB, + (0, 255, 0, 0), + (255, 0, 0), + True, + [0, 255, 255], + id="RGB, Inverse", + ), + pytest.param( + ColorFormat.HEX, + (0, 255, 0, 0), + "#ff0000", + True, + "#00ffff", + id="Hex, Inverse", + ), + ], +) +def test_inverse_correctly_inverts_rgb_color( + make_blinkstick, color_mode, ctrl_transfer_bytes, color, inverse, expected_color +): """Test that the color is correctly inverted when the inverse flag is set.""" bs = make_blinkstick() # mock the backend control_transfer method to return the 3 bytes of the color @@ -224,4 +288,4 @@ def test_inverse_does_not_affect_max_rgb_value(make_blinkstick): bs = make_blinkstick() bs.set_max_rgb_value(100) bs.set_inverse(True) - assert bs.get_max_rgb_value() == 100 \ No newline at end of file + assert bs.get_max_rgb_value() == 100 diff --git a/tests/colors/test_colors.py b/tests/colors/test_colors.py index 0c7af05..5d9bf63 100644 --- a/tests/colors/test_colors.py +++ b/tests/colors/test_colors.py @@ -1,156 +1,222 @@ import pytest -from blinkstick.colors import remap, remap_color, remap_color_reverse, remap_rgb_value, remap_rgb_value_reverse, Color, \ - name_to_hex, normalize_hex, hex_to_rgb, name_to_rgb +from blinkstick.colors import ( + remap, + remap_color, + remap_color_reverse, + remap_rgb_value, + remap_rgb_value_reverse, + Color, + name_to_hex, + normalize_hex, + hex_to_rgb, + name_to_rgb, +) def test_remap_value_within_range(): assert remap(5, 0, 10, 0, 100) == 50 + def test_remap_value_at_minimum(): assert remap(0, 0, 10, 0, 100) == 0 + def test_remap_value_at_maximum(): assert remap(10, 0, 10, 0, 100) == 100 + def test_remap_value_below_minimum(): assert remap(-5, 0, 10, 0, 100) == -50 + def test_remap_value_above_maximum(): assert remap(15, 0, 10, 0, 100) == 150 + def test_remap_value_within_negative_range(): assert remap(-5, -10, 0, -100, 0) == -50 + def test_remap_value_within_reverse_range(): assert remap(5, 0, 10, 100, 0) == 50 + def test_remap_color_value_within_range(): assert remap_color(128, 100) == 50 + def test_remap_color_value_at_minimum(): assert remap_color(0, 100) == 0 + def test_remap_color_value_at_maximum(): assert remap_color(255, 100) == 100 + def test_remap_color_value_below_minimum(): # note: this returns -3 because of the way the remap function is implemented using int(), which always rounds down assert remap_color(-10, 100) == -3 + def test_remap_color_value_above_maximum(): assert remap_color(300, 100) == 117 + def test_remap_color_reverse_value_within_range(): assert remap_color_reverse(50, 100) == 127 + def test_remap_color_reverse_value_at_minimum(): assert remap_color_reverse(0, 100) == 0 + def test_remap_color_reverse_value_at_maximum(): assert remap_color_reverse(100, 100) == 255 + def test_remap_color_reverse_value_below_minimum(): assert remap_color_reverse(-10, 100) == -25 + def test_remap_color_reverse_value_above_maximum(): assert remap_color_reverse(150, 100) == 382 + def test_remap_rgb_value_within_range(): assert remap_rgb_value((128, 128, 128), 100) == (50, 50, 50) + def test_remap_rgb_value_at_minimum(): assert remap_rgb_value((0, 0, 0), 100) == (0, 0, 0) + def test_remap_rgb_value_at_maximum(): assert remap_rgb_value((255, 255, 255), 100) == (100, 100, 100) + def test_remap_rgb_value_below_minimum(): assert remap_rgb_value((-10, -10, -10), 100) == (-3, -3, -3) + def test_remap_rgb_value_above_maximum(): assert remap_rgb_value((300, 300, 300), 100) == (117, 117, 117) + def test_remap_rgb_value_reverse_within_range(): assert remap_rgb_value_reverse((50, 50, 50), 100) == (127, 127, 127) + def test_remap_rgb_value_reverse_at_minimum(): assert remap_rgb_value_reverse((0, 0, 0), 100) == (0, 0, 0) + def test_remap_rgb_value_reverse_at_maximum(): assert remap_rgb_value_reverse((100, 100, 100), 100) == (255, 255, 255) + def test_remap_rgb_value_reverse_below_minimum(): assert remap_rgb_value_reverse((-10, -10, -10), 100) == (-25, -25, -25) + def test_remap_rgb_value_reverse_above_maximum(): assert remap_rgb_value_reverse((150, 150, 150), 100) == (382, 382, 382) + def test_all_colors_present(w3c_colors): assert len(w3c_colors) == len(Color) + def test_color_from_name_valid_color(w3c_colors): for color_name, _ in w3c_colors: assert Color.from_name(color_name) == Color[color_name.upper()] + def test_color_from_name_invalid_color(): - with pytest.raises(ValueError, match="'invalidcolor' is not defined as a named color."): - Color.from_name('invalidcolor') + with pytest.raises( + ValueError, match="'invalidcolor' is not defined as a named color." + ): + Color.from_name("invalidcolor") + def test_color_from_name_case_insensitive(w3c_colors): for color_name, _ in w3c_colors: assert Color.from_name(color_name.upper()) == Color[color_name.upper()] assert Color.from_name(color_name.lower()) == Color[color_name.upper()] + def test_color_name_to_hex(w3c_colors): for color_name, color_hex in w3c_colors: assert name_to_hex(color_name) == color_hex + def test_name_to_rgb_white(): - assert name_to_rgb('white') == (255, 255, 255) + assert name_to_rgb("white") == (255, 255, 255) + def test_name_to_rgb_navy(): - assert name_to_rgb('navy') == (0, 0, 128) + assert name_to_rgb("navy") == (0, 0, 128) + def test_name_to_rgb_goldenrod(): - assert name_to_rgb('goldenrod') == (218, 165, 32) + assert name_to_rgb("goldenrod") == (218, 165, 32) + def test_name_to_rgb_invalid_color(): - with pytest.raises(ValueError, match="'invalidcolor' is not defined as a named color."): - name_to_rgb('invalidcolor') + with pytest.raises( + ValueError, match="'invalidcolor' is not defined as a named color." + ): + name_to_rgb("invalidcolor") + def test_normalize_hex_valid_six_digit_lowercase(): - assert normalize_hex('#0099cc') == '#0099cc' + assert normalize_hex("#0099cc") == "#0099cc" + def test_normalize_hex_valid_six_digit_uppercase(): - assert normalize_hex('#0099CC') == '#0099cc' + assert normalize_hex("#0099CC") == "#0099cc" + def test_normalize_hex_valid_three_digit_lowercase(): - assert normalize_hex('#09c') == '#0099cc' + assert normalize_hex("#09c") == "#0099cc" + def test_normalize_hex_valid_three_digit_uppercase(): - assert normalize_hex('#09C') == '#0099cc' + assert normalize_hex("#09C") == "#0099cc" + def test_normalize_hex_missing_hash(): - with pytest.raises(ValueError, match="'0099cc' is not a valid hexadecimal color value."): - normalize_hex('0099cc') + with pytest.raises( + ValueError, match="'0099cc' is not a valid hexadecimal color value." + ): + normalize_hex("0099cc") + def test_hex_to_rgb_valid_six_digit_lowercase(): - assert hex_to_rgb('#0099cc') == (0, 153, 204) + assert hex_to_rgb("#0099cc") == (0, 153, 204) + def test_hex_to_rgb_valid_six_digit_uppercase(): - assert hex_to_rgb('#0099CC') == (0, 153, 204) + assert hex_to_rgb("#0099CC") == (0, 153, 204) + def test_hex_to_rgb_valid_three_digit_lowercase(): - assert hex_to_rgb('#09c') == (0, 153, 204) + assert hex_to_rgb("#09c") == (0, 153, 204) + def test_hex_to_rgb_valid_three_digit_uppercase(): - assert hex_to_rgb('#09C') == (0, 153, 204) + assert hex_to_rgb("#09C") == (0, 153, 204) + def test_hex_to_rgb_missing_hash(): - with pytest.raises(ValueError, match="'0099cc' is not a valid hexadecimal color value."): - hex_to_rgb('0099cc') + with pytest.raises( + ValueError, match="'0099cc' is not a valid hexadecimal color value." + ): + hex_to_rgb("0099cc") + def test_hex_to_rgb_invalid_hex(): - with pytest.raises(ValueError, match="'#xyz' is not a valid hexadecimal color value."): - hex_to_rgb('#xyz') \ No newline at end of file + with pytest.raises( + ValueError, match="'#xyz' is not a valid hexadecimal color value." + ): + hex_to_rgb("#xyz") diff --git a/tests/conftest.py b/tests/conftest.py index fb49c08..bda0985 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,4 +12,5 @@ def _make_blinkstick() -> BlinkStick: bs = BlinkStick() bs.backend = MagicMock() return bs + return _make_blinkstick From a34eb04f77393b774a75099e5472ccc6294036ab Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 17 Nov 2024 08:33:10 +0000 Subject: [PATCH 43/86] chore: Add .git-blame-ignore-revs file Adds a .git-blame-ignore-revs file. In order to use this to ignore the commits listed within, set the config option per the instructions in https://git-scm.com/docs/git-config#Documentation/git-config.txt-blameignoreRevsFile --- .git-blame-ignore-revs | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..2da681d --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# apply Black to all files +2c4173dbae8564885ece7371e08e220a25ca8dcb \ No newline at end of file From 70419623281f3adff0a425207e4fc561acc7b00c Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 17 Nov 2024 08:41:31 +0000 Subject: [PATCH 44/86] ci: Enforce Black formatting Add Github Actions workflow to enforce Black formatting rules --- .github/workflows/black.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/workflows/black.yml diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml new file mode 100644 index 0000000..f382a80 --- /dev/null +++ b/.github/workflows/black.yml @@ -0,0 +1,14 @@ +name: Formatting + +on: [push, pull_request] + +jobs: + black: + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name + steps: + - uses: actions/checkout@v4 + - uses: psf/black@stable + with: + options: "--check --verbose" + src: "./src ./tests" From 2420a4aba23d1a2ba43cdd308993e5ca583c47a4 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 17 Nov 2024 11:10:28 +0000 Subject: [PATCH 45/86] chore: Refactor type hinting in BaseBackend and its subclasses - Use `TypeVar` and `Generic` to define a generic type `T` in `BaseBackend`. - Apply the generic type `T` to methods and attributes in `BaseBackend`. - Update `Win32Backend` and `UnixLikeBackend` to specify the type for `T`. - Reduce repetition of type hinting in constructors and method return types. --- src/blinkstick/backends/base.py | 10 +++++++--- src/blinkstick/backends/unix_like.py | 13 ++++++++----- src/blinkstick/backends/win32.py | 14 ++++++++++---- src/blinkstick/blinkstick.py | 6 ++++-- 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/blinkstick/backends/base.py b/src/blinkstick/backends/base.py index 9e67007..f85fa20 100644 --- a/src/blinkstick/backends/base.py +++ b/src/blinkstick/backends/base.py @@ -2,8 +2,12 @@ from abc import ABC, abstractmethod +from typing import TypeVar, Generic -class BaseBackend(ABC): +T = TypeVar("T") + + +class BaseBackend(ABC, Generic[T]): serial: str | None @@ -16,12 +20,12 @@ def _refresh_device(self): @staticmethod @abstractmethod - def find_blinksticks(find_all: bool = True): + def find_blinksticks(find_all: bool = True) -> list[T] | None: raise NotImplementedError @staticmethod @abstractmethod - def find_by_serial(serial: str) -> BaseBackend | None: + def find_by_serial(serial: str) -> list[T] | None: raise NotImplementedError @abstractmethod diff --git a/src/blinkstick/backends/unix_like.py b/src/blinkstick/backends/unix_like.py index 9310389..b328996 100644 --- a/src/blinkstick/backends/unix_like.py +++ b/src/blinkstick/backends/unix_like.py @@ -1,16 +1,17 @@ from __future__ import annotations -import usb.core -import usb.util +import usb.core # type: ignore +import usb.util # type: ignore from blinkstick.constants import VENDOR_ID, PRODUCT_ID from blinkstick.backends.base import BaseBackend from blinkstick.exceptions import BlinkStickException -class UnixLikeBackend(BaseBackend): +class UnixLikeBackend(BaseBackend[usb.core.Device]): serial: str + device: usb.core.Device def __init__(self, device=None): self.device = device @@ -38,13 +39,13 @@ def _refresh_device(self): return True @staticmethod - def find_blinksticks(find_all: bool = True): + def find_blinksticks(find_all: bool = True) -> list[usb.core.Device] | None: return usb.core.find( find_all=find_all, idVendor=VENDOR_ID, idProduct=PRODUCT_ID ) @staticmethod - def find_by_serial(serial: str) -> list | None: + def find_by_serial(serial: str) -> list[usb.core.Device] | None: for d in UnixLikeBackend.find_blinksticks(): try: if usb.util.get_string(d, 3, 1033) == serial: @@ -53,6 +54,8 @@ def find_by_serial(serial: str) -> list | None: except Exception as e: print("{0}".format(e)) + return None + def control_transfer( self, bmRequestType: int, diff --git a/src/blinkstick/backends/win32.py b/src/blinkstick/backends/win32.py index a41c382..0a3154d 100644 --- a/src/blinkstick/backends/win32.py +++ b/src/blinkstick/backends/win32.py @@ -3,14 +3,18 @@ import sys from ctypes import * -from pywinusb import hid +from pywinusb import hid # type: ignore from blinkstick.constants import VENDOR_ID, PRODUCT_ID from blinkstick.backends.base import BaseBackend from blinkstick.exceptions import BlinkStickException -class Win32Backend(BaseBackend): +class Win32Backend(BaseBackend[hid.HidDevice]): + serial: str + device: hid.HidDevice + reports: list[hid.core.HidReport] + def __init__(self, device=None): super().__init__() self.device = device @@ -20,7 +24,7 @@ def __init__(self, device=None): self.serial = self.get_serial() @staticmethod - def find_by_serial(serial: str) -> list | None: + def find_by_serial(serial: str) -> list[hid.HidDevice] | None: devices = [ d for d in Win32Backend.find_blinksticks() if d.serial_number == serial ] @@ -28,6 +32,8 @@ def find_by_serial(serial: str) -> list | None: if len(devices) > 0: return devices + return None + def _refresh_device(self): # TODO This is weird semantics. fix up return values to be more sensible if not self.serial: @@ -39,7 +45,7 @@ def _refresh_device(self): return True @staticmethod - def find_blinksticks(find_all: bool = True): + def find_blinksticks(find_all: bool = True) -> list[hid.HidDevice] | None: devices = hid.HidDeviceFilter( vendor_id=VENDOR_ID, product_id=PRODUCT_ID ).get_devices() diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 5f9079d..8b6e007 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -1412,8 +1412,10 @@ def find_all() -> list[BlinkStick]: @rtype: BlinkStick[] @return: a list of BlinkStick objects or None if no devices found """ - result = [] - for d in USBBackend.find_blinksticks(): + result: list[BlinkStick] = [] + if (found_devices := USBBackend.find_blinksticks()) is None: + return result + for d in found_devices: result.extend([BlinkStick(device=d)]) return result From 51336bc3dc05e8ffa170058774d3b1655f3298a5 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 16 Nov 2024 16:56:57 +0000 Subject: [PATCH 46/86] chore: add mypy to dev dependencies --- .github/workflows/mypy.yml | 0 pyproject.toml | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 .github/workflows/mypy.yml diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 952f868..b1c239a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ dynamic = ["version"] [project.optional-dependencies] -dev = ["black", "isort"] +dev = ["black", "isort", "mypy"] test = ["coverage", "pytest", "pytest-cov", "pytest-mock"] [project.scripts] From 25fa4b64233afab0c86bf4c695df1ce209d7582f Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 16 Nov 2024 16:57:34 +0000 Subject: [PATCH 47/86] ci: add mypy github action --- .github/workflows/mypy.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index e69de29..d3e06d0 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -0,0 +1,24 @@ +name: Mypy Type Check + +on: [push, pull_request] + +jobs: + mypy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install .[dev,test] + + - name: Run mypy + run: mypy src tests \ No newline at end of file From a5eb51ef6c1e062c9a865658f30f30826d9834e2 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 17 Nov 2024 12:12:27 +0000 Subject: [PATCH 48/86] fix: Handle backend find_blicksticks returning None Handle the case that `USBBackend.find_blinksticks()` returns `None` --- src/blinkstick/backends/unix_like.py | 3 ++- src/blinkstick/backends/win32.py | 5 ++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/blinkstick/backends/unix_like.py b/src/blinkstick/backends/unix_like.py index b328996..e593184 100644 --- a/src/blinkstick/backends/unix_like.py +++ b/src/blinkstick/backends/unix_like.py @@ -46,7 +46,8 @@ def find_blinksticks(find_all: bool = True) -> list[usb.core.Device] | None: @staticmethod def find_by_serial(serial: str) -> list[usb.core.Device] | None: - for d in UnixLikeBackend.find_blinksticks(): + found_devices = UnixLikeBackend.find_blinksticks() or [] + for d in found_devices: try: if usb.util.get_string(d, 3, 1033) == serial: devices = [d] diff --git a/src/blinkstick/backends/win32.py b/src/blinkstick/backends/win32.py index 0a3154d..86a7ca4 100644 --- a/src/blinkstick/backends/win32.py +++ b/src/blinkstick/backends/win32.py @@ -25,9 +25,8 @@ def __init__(self, device=None): @staticmethod def find_by_serial(serial: str) -> list[hid.HidDevice] | None: - devices = [ - d for d in Win32Backend.find_blinksticks() if d.serial_number == serial - ] + found_devices = Win32Backend.find_blinksticks() or [] + devices = [d for d in found_devices if d.serial_number == serial] if len(devices) > 0: return devices From f781ffa239c1ee7faefd45c0032f2d0156c927df Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 17 Nov 2024 12:15:30 +0000 Subject: [PATCH 49/86] fix: ignore type errors from USB driver packages Neither the win32 or unix USB wrapper packages provide type hints or stubs, so just ignore the mypy errors --- src/blinkstick/blinkstick.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 8b6e007..4af8d07 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -18,11 +18,11 @@ if sys.platform == "win32": from blinkstick.backends.win32 import Win32Backend as USBBackend - import pywinusb.hid as hid + import pywinusb.hid as hid # type: ignore else: from blinkstick.backends.unix_like import UnixLikeBackend as USBBackend - import usb.core - import usb.util + import usb.core # type: ignore + import usb.util # type: ignore from random import randint From 6326545c579ccd2f270ce5c501f56b52b413e58a Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 17 Nov 2024 12:17:55 +0000 Subject: [PATCH 50/86] fix: return tuple from _get_color_rgb --- src/blinkstick/blinkstick.py | 6 +++--- tests/clients/test_blinkstick.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 4af8d07..b98785a 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -222,13 +222,13 @@ def _get_color_rgb(self, index: int = 0) -> tuple[int, int, int]: 0x80 | 0x20, 0x1, 0x0001, 0, 33 ) if self.inverse: - return [ + return ( 255 - device_bytes[1], 255 - device_bytes[2], 255 - device_bytes[3], - ] + ) else: - return [device_bytes[1], device_bytes[2], device_bytes[3]] + return (device_bytes[1], device_bytes[2], device_bytes[3]) else: data = self.get_led_data((index + 1) * 3) diff --git a/tests/clients/test_blinkstick.py b/tests/clients/test_blinkstick.py index ad6fc09..fe46c63 100644 --- a/tests/clients/test_blinkstick.py +++ b/tests/clients/test_blinkstick.py @@ -242,7 +242,7 @@ def test_set_inverse_type_checking(make_blinkstick, input_value, expected_result (0, 255, 0, 0), (255, 0, 0), False, - [255, 0, 0], + (255, 0, 0), id="RGB, NoInverse", ), pytest.param( @@ -258,7 +258,7 @@ def test_set_inverse_type_checking(make_blinkstick, input_value, expected_result (0, 255, 0, 0), (255, 0, 0), True, - [0, 255, 255], + (0, 255, 255), id="RGB, Inverse", ), pytest.param( From f10fa55d6b10f263383795c0940d32a342f4e67a Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 17 Nov 2024 12:20:43 +0000 Subject: [PATCH 51/86] fix: allow str or None for color_format param in get_color we're deprecating the use of color_format, so we set the default value to None in order to test whether it is set/truthy. So, update the type hint to allow None as a type. --- src/blinkstick/blinkstick.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index b98785a..cbd1fe9 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -242,7 +242,7 @@ def get_color( self, index: int = 0, color_mode: ColorFormat = ColorFormat.RGB, - color_format: str = None, + color_format: str | None = None, ) -> tuple[int, int, int] | str: """ Get the current backend color in the defined format. From 08bc20332882845748c455a03523bc6eac4fb004 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 17 Nov 2024 12:21:51 +0000 Subject: [PATCH 52/86] fix: Use correct default when getting color_function --- src/blinkstick/blinkstick.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index cbd1fe9..3c5cd6d 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -287,7 +287,7 @@ def get_color( ColorFormat.HEX: self._get_color_hex, } - return color_funcs.get(color_mode, ColorFormat.RGB)(index) + return color_funcs.get(color_mode, self._get_color_rgb)(index) def _determine_report_id(self, led_count: int) -> tuple[int, int]: report_id = 9 From 684c79e2b53c2eb5069f77e83ee32434b0fba079 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 17 Nov 2024 12:29:41 +0000 Subject: [PATCH 53/86] chore: Fix type hinting for get_color type hint the `color_funcs` dict correctly, so that mypy doesn't get confused --- src/blinkstick/blinkstick.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 3c5cd6d..3f01d29 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -4,6 +4,7 @@ import time import warnings from importlib.metadata import version +from typing import Callable from blinkstick.colors import ( hex_to_rgb, @@ -282,7 +283,7 @@ def get_color( except ValueError: color_mode = ColorFormat.RGB - color_funcs = { + color_funcs: dict[ColorFormat, Callable[[int], tuple[int, int, int] | str]] = { ColorFormat.RGB: self._get_color_rgb, ColorFormat.HEX: self._get_color_hex, } From 6847d336d7e999c4e5d01f98ed55ad7a8494c578 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 17 Nov 2024 12:31:53 +0000 Subject: [PATCH 54/86] fix: remove redundant parens --- src/blinkstick/blinkstick.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 3f01d29..601c053 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -229,7 +229,7 @@ def _get_color_rgb(self, index: int = 0) -> tuple[int, int, int]: 255 - device_bytes[3], ) else: - return (device_bytes[1], device_bytes[2], device_bytes[3]) + return device_bytes[1], device_bytes[2], device_bytes[3] else: data = self.get_led_data((index + 1) * 3) From e5676f8ac02cc9cc0a656c9fab584fe941ac1179 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 17 Nov 2024 12:33:29 +0000 Subject: [PATCH 55/86] fix: map floats to ints set_color expects ints for the r,g,b channel values, so cast gradient channel values to ints before calling --- src/blinkstick/blinkstick.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 601c053..abb175b 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -687,7 +687,7 @@ def morph( ) for grad in gradient: - grad_r, grad_g, grad_b = grad + grad_r, grad_g, grad_b = map(int, grad) self.set_color( channel=channel, index=index, red=grad_r, green=grad_g, blue=grad_b From 8923e8e3d665db1ff86975dd6097c0f7e6884749 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 17 Nov 2024 12:35:34 +0000 Subject: [PATCH 56/86] chore: ignore mypy sadness mypy gets sad that value is ever treated as a string, but we need to keep that graceful fallback in there just in case. So just shush mypy --- src/blinkstick/blinkstick.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index abb175b..4fed85a 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -730,7 +730,7 @@ def set_inverse(self, value: bool) -> None: @param value: True/False to set the inverse mode """ if type(value) is str: - value = value.lower() == "true" + value = value.lower() == "true" # type: ignore self.inverse = bool(value) def set_max_rgb_value(self, value: int) -> None: From 054595bcd7cc492d927ccae750d74954e558b812 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 17 Nov 2024 12:36:30 +0000 Subject: [PATCH 57/86] fix: don't send_data if there's no blinkstick --- src/blinkstick/blinkstick.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 4fed85a..2139587 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -918,6 +918,9 @@ def send_data(self, channel: int) -> None: - 1 - G pin on BlinkStick Pro board - 2 - B pin on BlinkStick Pro board """ + if self.bstick is None: + return + packet_data = [item for sublist in self.data[channel] for item in sublist] try: From 45cff9e1d6bd9cffd5e11a334d7e6795e95afb87 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 17 Nov 2024 12:37:59 +0000 Subject: [PATCH 58/86] chore: no implicit returns of None Explicit is better than implicit. --- src/blinkstick/blinkstick.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 2139587..a236a6e 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -1437,6 +1437,7 @@ def find_first() -> BlinkStick | None: if d: return BlinkStick(device=d) + return None def find_by_serial(serial: str | None = None) -> BlinkStick | None: """ @@ -1451,6 +1452,8 @@ def find_by_serial(serial: str | None = None) -> BlinkStick | None: if devices: return BlinkStick(device=devices[0]) + return None + def get_blinkstick_package_version() -> str: return version("blinkstick") From 2b5e8dd101181f6fb89f1de05a80717819ad2d8e Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 17 Nov 2024 12:40:08 +0000 Subject: [PATCH 59/86] fix: Handle HEX_COLOR_RE returns None In the event that we can't match the supplied hex value using the RE, raise a ValueError. Otherwise we risk attempting to access the `groups()` method of `None` --- src/blinkstick/colors.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/blinkstick/colors.py b/src/blinkstick/colors.py index 12ab0f7..5e1d4dd 100644 --- a/src/blinkstick/colors.py +++ b/src/blinkstick/colors.py @@ -226,8 +226,11 @@ def normalize_hex(hex_value: str) -> str: ValueError: '0099cc' is not a valid hexadecimal color value. """ + invalid_hex_value_msg = "'%s' is not a valid hexadecimal color value." + if not (hex_match := HEX_COLOR_RE.match(hex_value)): + raise ValueError(invalid_hex_value_msg % hex_value) try: - hex_digits = HEX_COLOR_RE.match(hex_value).groups()[0] + hex_digits = hex_match.groups()[0] except AttributeError: raise ValueError("'%s' is not a valid hexadecimal color value." % hex_value) if len(hex_digits) == 3: From bfabf6ce71f30044927a61550836df323a86dec6 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 17 Nov 2024 12:41:35 +0000 Subject: [PATCH 60/86] fix: Default serial param to empty string When doing `find_by_serial`, default the `serial` param to an empty string, not None. --- src/blinkstick/blinkstick.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index a236a6e..bb80731 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -1439,7 +1439,8 @@ def find_first() -> BlinkStick | None: return None -def find_by_serial(serial: str | None = None) -> BlinkStick | None: + +def find_by_serial(serial: str = "") -> BlinkStick | None: """ Find BlinkStick backend based on serial number. From ba981c3cfe18d7d651ac8dc8ae1e3156f49fd1e8 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 17 Nov 2024 13:05:06 +0000 Subject: [PATCH 61/86] fix: return bytes from _data_to_message --- src/blinkstick/blinkstick.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index bb80731..1ccbb78 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -454,7 +454,7 @@ def get_info_block2(self) -> str: result += chr(i) return result - def _data_to_message(self, data) -> bytes: + def _data_to_message(self, data: str) -> bytes: """ Helper method to convert a string to byte array of 32 bytes. @@ -464,14 +464,14 @@ def _data_to_message(self, data) -> bytes: @rtype: byte[32] @return: It fills the rest of bytes with zeros. """ - bytes = [1] + byte_array = bytearray([1]) for c in data: - bytes.append(ord(c)) + byte_array.append(ord(c)) for i in range(32 - len(data)): - bytes.append(0) + byte_array.append(0) - return bytes + return bytes(byte_array) def set_info_block1(self, data: str) -> None: """ From 6a9a40b856ab3a824eeffe376d6ede7046d25541 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 17 Nov 2024 16:47:22 +0000 Subject: [PATCH 62/86] refactor: move _data_to_message and rename make `_data_to_message` a standalone function, move it to a `utilities` package, and rename --- src/blinkstick/blinkstick.py | 28 +++++++--------------------- src/blinkstick/utilities.py | 18 ++++++++++++++++++ tests/utilities/__init__.py | 0 3 files changed, 25 insertions(+), 21 deletions(-) create mode 100644 src/blinkstick/utilities.py create mode 100644 tests/utilities/__init__.py diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 1ccbb78..76b036f 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -16,6 +16,7 @@ ) from blinkstick.constants import VENDOR_ID, PRODUCT_ID, BlinkStickVariant from blinkstick.exceptions import BlinkStickException +from blinkstick.utilities import string_to_info_block_data if sys.platform == "win32": from blinkstick.backends.win32 import Win32Backend as USBBackend @@ -454,25 +455,6 @@ def get_info_block2(self) -> str: result += chr(i) return result - def _data_to_message(self, data: str) -> bytes: - """ - Helper method to convert a string to byte array of 32 bytes. - - @type data: str - @param data: The data to convert to byte array - - @rtype: byte[32] - @return: It fills the rest of bytes with zeros. - """ - byte_array = bytearray([1]) - for c in data: - byte_array.append(ord(c)) - - for i in range(32 - len(data)): - byte_array.append(0) - - return bytes(byte_array) - def set_info_block1(self, data: str) -> None: """ Sets the infoblock1 with specified string. @@ -482,7 +464,9 @@ def set_info_block1(self, data: str) -> None: @type data: str @param data: InfoBlock1 for the backend to set """ - self.backend.control_transfer(0x20, 0x9, 0x0002, 0, self._data_to_message(data)) + self.backend.control_transfer( + 0x20, 0x9, 0x0002, 0, string_to_info_block_data(data) + ) def set_info_block2(self, data: str) -> None: """ @@ -493,7 +477,9 @@ def set_info_block2(self, data: str) -> None: @type data: str @param data: InfoBlock2 for the backend to set """ - self.backend.control_transfer(0x20, 0x9, 0x0003, 0, self._data_to_message(data)) + self.backend.control_transfer( + 0x20, 0x9, 0x0003, 0, string_to_info_block_data(data) + ) def set_random_color(self) -> None: """ diff --git a/src/blinkstick/utilities.py b/src/blinkstick/utilities.py new file mode 100644 index 0000000..095c3c0 --- /dev/null +++ b/src/blinkstick/utilities.py @@ -0,0 +1,18 @@ +def string_to_info_block_data(data: str) -> bytes: + """ + Helper method to convert a string to byte array of 32 bytes. + + @type data: str + @param data: The data to convert to byte array + + @rtype: byte[32] + @return: It fills the rest of bytes with zeros. + """ + byte_array = bytearray([1]) + for c in data: + byte_array.append(ord(c)) + + for i in range(32 - len(data)): + byte_array.append(0) + + return bytes(byte_array) diff --git a/tests/utilities/__init__.py b/tests/utilities/__init__.py new file mode 100644 index 0000000..e69de29 From 350b45617ed000c305e8dfe374bf21f1b9cb78a7 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 17 Nov 2024 16:51:07 +0000 Subject: [PATCH 63/86] test: Add tests for `string_to_info_block_data` --- tests/utilities/test_string_to_info_block.py | 29 ++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/utilities/test_string_to_info_block.py diff --git a/tests/utilities/test_string_to_info_block.py b/tests/utilities/test_string_to_info_block.py new file mode 100644 index 0000000..bb0d709 --- /dev/null +++ b/tests/utilities/test_string_to_info_block.py @@ -0,0 +1,29 @@ +from blinkstick.utilities import string_to_info_block_data + + +def test_string_to_info_block_data_converts_string_to_byte_array(): + block_string = "hello" + expected_padding_length = 31 - len(block_string) + result = string_to_info_block_data("hello") + expected = b"\x01hello" + b"\x00" * expected_padding_length + assert result == expected + + +def test_string_to_info_block_data_handles_empty_string(): + result = string_to_info_block_data("") + expected = b"\x01" + b"\x00" * 31 + assert result == expected + + +def test_string_to_info_block_data_truncates_long_string(): + long_string = "a" * 40 + result = string_to_info_block_data(long_string) + expected = b"\x01" + b"a" * 31 + assert result == expected + + +def test_string_to_info_block_data_handles_exact_31_characters(): + exact_string = "a" * 31 + result = string_to_info_block_data(exact_string) + expected = b"\x01" + b"a" * 31 + assert result == expected From ab1d58d49af5889de27f42c20d14281cb518eb13 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 17 Nov 2024 17:06:02 +0000 Subject: [PATCH 64/86] fix: Fix infoblock setting bugs * restrict info block data string to 31 bytes * correctly handle empty string --- src/blinkstick/utilities.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/blinkstick/utilities.py b/src/blinkstick/utilities.py index 095c3c0..a828fc1 100644 --- a/src/blinkstick/utilities.py +++ b/src/blinkstick/utilities.py @@ -8,11 +8,10 @@ def string_to_info_block_data(data: str) -> bytes: @rtype: byte[32] @return: It fills the rest of bytes with zeros. """ - byte_array = bytearray([1]) - for c in data: - byte_array.append(ord(c)) + info_block_data = data[:31] + byte_array = bytearray([1] + [0] * 31) - for i in range(32 - len(data)): - byte_array.append(0) + for i, c in enumerate(info_block_data): + byte_array[i + 1] = ord(c) return bytes(byte_array) From 36601ba64577e72bc1391b3dbc3536e7e07c11c2 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 30 Nov 2024 13:42:41 +0000 Subject: [PATCH 65/86] refactor: rename backend methods for clarity Rename `find_blinksticks` to `get_attached_blinkstick_devices`, and `_refresh_device` to `_refresh_attached_blinkstick_device` --- src/blinkstick/backends/base.py | 4 ++-- src/blinkstick/backends/unix_like.py | 12 +++++++----- src/blinkstick/backends/win32.py | 10 ++++++---- src/blinkstick/blinkstick.py | 4 ++-- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/blinkstick/backends/base.py b/src/blinkstick/backends/base.py index f85fa20..07d4529 100644 --- a/src/blinkstick/backends/base.py +++ b/src/blinkstick/backends/base.py @@ -15,12 +15,12 @@ def __init__(self): self.serial = None @abstractmethod - def _refresh_device(self): + def _refresh_attached_blinkstick_device(self): raise NotImplementedError @staticmethod @abstractmethod - def find_blinksticks(find_all: bool = True) -> list[T] | None: + def get_attached_blinkstick_devices(find_all: bool = True) -> list[T] | None: raise NotImplementedError @staticmethod diff --git a/src/blinkstick/backends/unix_like.py b/src/blinkstick/backends/unix_like.py index e593184..039e2eb 100644 --- a/src/blinkstick/backends/unix_like.py +++ b/src/blinkstick/backends/unix_like.py @@ -30,7 +30,7 @@ def open_device(self) -> None: except usb.core.USBError as e: raise BlinkStickException("Could not detach kernel driver: %s" % str(e)) - def _refresh_device(self): + def _refresh_attached_blinkstick_device(self): if not self.serial: return False if devices := self.find_by_serial(self.serial): @@ -39,14 +39,16 @@ def _refresh_device(self): return True @staticmethod - def find_blinksticks(find_all: bool = True) -> list[usb.core.Device] | None: + def get_attached_blinkstick_devices( + find_all: bool = True, + ) -> list[usb.core.Device] | None: return usb.core.find( find_all=find_all, idVendor=VENDOR_ID, idProduct=PRODUCT_ID ) @staticmethod def find_by_serial(serial: str) -> list[usb.core.Device] | None: - found_devices = UnixLikeBackend.find_blinksticks() or [] + found_devices = UnixLikeBackend.get_attached_blinkstick_devices() or [] for d in found_devices: try: if usb.util.get_string(d, 3, 1033) == serial: @@ -73,7 +75,7 @@ def control_transfer( # Could not communicate with BlinkStick backend # attempt to find it again based on serial - if self._refresh_device(): + if self._refresh_attached_blinkstick_device(): return self.device.ctrl_transfer( bmRequestType, bRequest, wValue, wIndex, data_or_wLength ) @@ -103,7 +105,7 @@ def _usb_get_string(self, index: int) -> str: # Could not communicate with BlinkStick backend # attempt to find it again based on serial - if self._refresh_device(): + if self._refresh_attached_blinkstick_device(): return str(usb.util.get_string(self.device, index, 1033)) else: raise BlinkStickException( diff --git a/src/blinkstick/backends/win32.py b/src/blinkstick/backends/win32.py index 86a7ca4..05a0079 100644 --- a/src/blinkstick/backends/win32.py +++ b/src/blinkstick/backends/win32.py @@ -25,7 +25,7 @@ def __init__(self, device=None): @staticmethod def find_by_serial(serial: str) -> list[hid.HidDevice] | None: - found_devices = Win32Backend.find_blinksticks() or [] + found_devices = Win32Backend.get_attached_blinkstick_devices() or [] devices = [d for d in found_devices if d.serial_number == serial] if len(devices) > 0: @@ -33,7 +33,7 @@ def find_by_serial(serial: str) -> list[hid.HidDevice] | None: return None - def _refresh_device(self): + def _refresh_attached_blinkstick_device(self): # TODO This is weird semantics. fix up return values to be more sensible if not self.serial: return False @@ -44,7 +44,9 @@ def _refresh_device(self): return True @staticmethod - def find_blinksticks(find_all: bool = True) -> list[hid.HidDevice] | None: + def get_attached_blinkstick_devices( + find_all: bool = True, + ) -> list[hid.HidDevice] | None: devices = hid.HidDeviceFilter( vendor_id=VENDOR_ID, product_id=PRODUCT_ID ).get_devices() @@ -69,7 +71,7 @@ def control_transfer( ) data[0] = wValue if not self.device.send_feature_report(data): - if self._refresh_device(): + if self._refresh_attached_blinkstick_device(): self.device.send_feature_report(data) else: raise BlinkStickException( diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 76b036f..d628d46 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -1403,7 +1403,7 @@ def find_all() -> list[BlinkStick]: @return: a list of BlinkStick objects or None if no devices found """ result: list[BlinkStick] = [] - if (found_devices := USBBackend.find_blinksticks()) is None: + if (found_devices := USBBackend.get_attached_blinkstick_devices()) is None: return result for d in found_devices: result.extend([BlinkStick(device=d)]) @@ -1418,7 +1418,7 @@ def find_first() -> BlinkStick | None: @rtype: BlinkStick @return: BlinkStick object or None if no devices are found """ - d = USBBackend.find_blinksticks(find_all=False) + d = USBBackend.get_attached_blinkstick_devices(find_all=False) if d: return BlinkStick(device=d) From 9bb11f4f70ecab08859932c450da9436d7fa20cf Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 30 Nov 2024 14:52:36 +0000 Subject: [PATCH 66/86] refactor: tidy up semantics of `get_attached_blinkstick_devices` Always return a list from the usb backend, so if no blinkstick devices are detected, the list should be empty --- src/blinkstick/backends/base.py | 2 +- src/blinkstick/backends/unix_like.py | 9 +++++---- src/blinkstick/backends/win32.py | 8 +++----- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/blinkstick/backends/base.py b/src/blinkstick/backends/base.py index 07d4529..0b90ef5 100644 --- a/src/blinkstick/backends/base.py +++ b/src/blinkstick/backends/base.py @@ -20,7 +20,7 @@ def _refresh_attached_blinkstick_device(self): @staticmethod @abstractmethod - def get_attached_blinkstick_devices(find_all: bool = True) -> list[T] | None: + def get_attached_blinkstick_devices(find_all: bool = True) -> list[T]: raise NotImplementedError @staticmethod diff --git a/src/blinkstick/backends/unix_like.py b/src/blinkstick/backends/unix_like.py index 039e2eb..335bc83 100644 --- a/src/blinkstick/backends/unix_like.py +++ b/src/blinkstick/backends/unix_like.py @@ -41,14 +41,15 @@ def _refresh_attached_blinkstick_device(self): @staticmethod def get_attached_blinkstick_devices( find_all: bool = True, - ) -> list[usb.core.Device] | None: - return usb.core.find( - find_all=find_all, idVendor=VENDOR_ID, idProduct=PRODUCT_ID + ) -> list[usb.core.Device]: + return ( + usb.core.find(find_all=find_all, idVendor=VENDOR_ID, idProduct=PRODUCT_ID) + or [] ) @staticmethod def find_by_serial(serial: str) -> list[usb.core.Device] | None: - found_devices = UnixLikeBackend.get_attached_blinkstick_devices() or [] + found_devices = UnixLikeBackend.get_attached_blinkstick_devices() for d in found_devices: try: if usb.util.get_string(d, 3, 1033) == serial: diff --git a/src/blinkstick/backends/win32.py b/src/blinkstick/backends/win32.py index 05a0079..390b1ca 100644 --- a/src/blinkstick/backends/win32.py +++ b/src/blinkstick/backends/win32.py @@ -46,16 +46,14 @@ def _refresh_attached_blinkstick_device(self): @staticmethod def get_attached_blinkstick_devices( find_all: bool = True, - ) -> list[hid.HidDevice] | None: + ) -> list[hid.HidDevice]: devices = hid.HidDeviceFilter( vendor_id=VENDOR_ID, product_id=PRODUCT_ID ).get_devices() if find_all: return devices - elif len(devices) > 0: - return devices[0] - else: - return None + + return devices[:1] def control_transfer( self, bmRequestType, bRequest, wValue, wIndex, data_or_wLength From 83f07c8b8217a6c22a41ed5044522872607ed700 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 30 Nov 2024 15:18:17 +0000 Subject: [PATCH 67/86] chore: add BlinkStickDevice dataclass --- src/blinkstick/devices/device.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/blinkstick/devices/device.py diff --git a/src/blinkstick/devices/device.py b/src/blinkstick/devices/device.py new file mode 100644 index 0000000..410f8c9 --- /dev/null +++ b/src/blinkstick/devices/device.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass +from typing import Generic, TypeVar + +T = TypeVar("T") + + +@dataclass +class BlinkStickDevice(Generic[T]): + """A BlinkStick device representation""" + + raw_device: T + serial: str + manufacturer: str + version_attribute: int + description: str From 5a9fdfa94ccd05e7f822bb71a755653296862572 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 30 Nov 2024 15:56:39 +0000 Subject: [PATCH 68/86] refactor: Use BlinkStickDevice class Use the BlinkStickDevice class to retrieve readonly device data up front, and persist it in the BlinkStick wrapper class --- src/blinkstick/backends/base.py | 23 ++++----- src/blinkstick/backends/unix_like.py | 70 +++++++++++++++------------- src/blinkstick/backends/win32.py | 61 ++++++++++++------------ src/blinkstick/blinkstick.py | 11 +++-- 4 files changed, 86 insertions(+), 79 deletions(-) diff --git a/src/blinkstick/backends/base.py b/src/blinkstick/backends/base.py index 0b90ef5..452fe54 100644 --- a/src/blinkstick/backends/base.py +++ b/src/blinkstick/backends/base.py @@ -4,12 +4,15 @@ from typing import TypeVar, Generic +from blinkstick.devices.device import BlinkStickDevice + T = TypeVar("T") class BaseBackend(ABC, Generic[T]): serial: str | None + blinkstick_device: BlinkStickDevice[T] def __init__(self): self.serial = None @@ -20,12 +23,14 @@ def _refresh_attached_blinkstick_device(self): @staticmethod @abstractmethod - def get_attached_blinkstick_devices(find_all: bool = True) -> list[T]: + def get_attached_blinkstick_devices( + find_all: bool = True, + ) -> list[BlinkStickDevice[T]]: raise NotImplementedError @staticmethod @abstractmethod - def find_by_serial(serial: str) -> list[T] | None: + def find_by_serial(serial: str) -> list[BlinkStickDevice[T]] | None: raise NotImplementedError @abstractmethod @@ -39,18 +44,14 @@ def control_transfer( ): raise NotImplementedError - @abstractmethod def get_serial(self) -> str: - raise NotImplementedError + return self.blinkstick_device.serial - @abstractmethod def get_manufacturer(self) -> str: - raise NotImplementedError + return self.blinkstick_device.manufacturer - @abstractmethod def get_version_attribute(self) -> int: - raise NotImplementedError + return self.blinkstick_device.version_attribute - @abstractmethod - def get_description(self) -> str: - raise NotImplementedError + def get_description(self): + return self.blinkstick_device.description diff --git a/src/blinkstick/backends/unix_like.py b/src/blinkstick/backends/unix_like.py index 335bc83..5b600d5 100644 --- a/src/blinkstick/backends/unix_like.py +++ b/src/blinkstick/backends/unix_like.py @@ -5,58 +5,70 @@ from blinkstick.constants import VENDOR_ID, PRODUCT_ID from blinkstick.backends.base import BaseBackend +from blinkstick.devices.device import BlinkStickDevice from blinkstick.exceptions import BlinkStickException class UnixLikeBackend(BaseBackend[usb.core.Device]): serial: str - device: usb.core.Device + blinkstick_device: BlinkStickDevice[usb.core.Device] def __init__(self, device=None): - self.device = device + self.blinkstick_device = device super().__init__() if device: self.open_device() self.serial = self.get_serial() def open_device(self) -> None: - if self.device is None: + if self.blinkstick_device is None: raise BlinkStickException("Could not find BlinkStick...") - if self.device.is_kernel_driver_active(0): + if self.blinkstick_device.raw_device.is_kernel_driver_active(0): try: - self.device.detach_kernel_driver(0) + self.blinkstick_device.raw_device.detach_kernel_driver(0) except usb.core.USBError as e: raise BlinkStickException("Could not detach kernel driver: %s" % str(e)) def _refresh_attached_blinkstick_device(self): - if not self.serial: + if not self.blinkstick_device: return False - if devices := self.find_by_serial(self.serial): - self.device = devices[0] + if devices := self.find_by_serial(self.blinkstick_device.serial): + self.blinkstick_device = devices[0] self.open_device() return True @staticmethod def get_attached_blinkstick_devices( find_all: bool = True, - ) -> list[usb.core.Device]: - return ( + ) -> list[BlinkStickDevice[usb.core.Device]]: + raw_devices = ( usb.core.find(find_all=find_all, idVendor=VENDOR_ID, idProduct=PRODUCT_ID) or [] ) + return [ + # TODO: refactor this to DRY up the usb.util.get_string calls + # note that we can't use _usb_get_string here because we're not in an instance method + # and we don't have a BlinkStickDevice instance to call it on + # until then we'll just have to live with the duplication, and the fact that we're not able + # to handle USB errors in the same way as we do in the instance methods + BlinkStickDevice( + raw_device=device, + serial=str(usb.util.get_string(device, 3, 1033)), + manufacturer=str(usb.util.get_string(device, 1, 1033)), + version_attribute=device.bcdDevice, + description=str(usb.util.get_string(device, 2, 1033)), + ) + for device in raw_devices + ] @staticmethod - def find_by_serial(serial: str) -> list[usb.core.Device] | None: + def find_by_serial(serial: str) -> list[BlinkStickDevice[usb.core.Device]] | None: found_devices = UnixLikeBackend.get_attached_blinkstick_devices() for d in found_devices: - try: - if usb.util.get_string(d, 3, 1033) == serial: - devices = [d] - return devices - except Exception as e: - print("{0}".format(e)) + if d.serial == serial: + return [d] return None @@ -69,7 +81,7 @@ def control_transfer( data_or_wLength: bytes | int, ): try: - return self.device.ctrl_transfer( + return self.blinkstick_device.raw_device.ctrl_transfer( bmRequestType, bRequest, wValue, wIndex, data_or_wLength ) except usb.USBError: @@ -77,7 +89,7 @@ def control_transfer( # attempt to find it again based on serial if self._refresh_attached_blinkstick_device(): - return self.device.ctrl_transfer( + return self.blinkstick_device.raw_device.ctrl_transfer( bmRequestType, bRequest, wValue, wIndex, data_or_wLength ) else: @@ -87,27 +99,19 @@ def control_transfer( ) ) - def get_serial(self) -> str: - return self._usb_get_string(3) - - def get_manufacturer(self) -> str: - return self._usb_get_string(1) - - def get_version_attribute(self) -> int: - return int(self.device.bcdDevice) - - def get_description(self): - return self._usb_get_string(2) - def _usb_get_string(self, index: int) -> str: try: - return str(usb.util.get_string(self.device, index, 1033)) + return str( + usb.util.get_string(self.blinkstick_device.raw_device, index, 1033) + ) except usb.USBError: # Could not communicate with BlinkStick backend # attempt to find it again based on serial if self._refresh_attached_blinkstick_device(): - return str(usb.util.get_string(self.device, index, 1033)) + return str( + usb.util.get_string(self.blinkstick_device.raw_device, index, 1033) + ) else: raise BlinkStickException( "Could not communicate with BlinkStick {0} - it may have been removed".format( diff --git a/src/blinkstick/backends/win32.py b/src/blinkstick/backends/win32.py index 390b1ca..7f4754c 100644 --- a/src/blinkstick/backends/win32.py +++ b/src/blinkstick/backends/win32.py @@ -7,29 +7,29 @@ from blinkstick.constants import VENDOR_ID, PRODUCT_ID from blinkstick.backends.base import BaseBackend +from blinkstick.devices.device import BlinkStickDevice from blinkstick.exceptions import BlinkStickException class Win32Backend(BaseBackend[hid.HidDevice]): serial: str - device: hid.HidDevice + blinkstick_device: BlinkStickDevice[hid.HidDevice] reports: list[hid.core.HidReport] - def __init__(self, device=None): + def __init__(self, device: BlinkStickDevice[hid.HidDevice]): super().__init__() - self.device = device + self.blinkstick_device = device if device: - self.device.open() - self.reports = self.device.find_feature_reports() + self.blinkstick_device.raw_device.open() + self.reports = self.blinkstick_device.raw_device.find_feature_reports() self.serial = self.get_serial() @staticmethod - def find_by_serial(serial: str) -> list[hid.HidDevice] | None: - found_devices = Win32Backend.get_attached_blinkstick_devices() or [] - devices = [d for d in found_devices if d.serial_number == serial] - - if len(devices) > 0: - return devices + def find_by_serial(serial: str) -> list[BlinkStickDevice[hid.HidDevice]] | None: + found_devices = Win32Backend.get_attached_blinkstick_devices() + for d in found_devices: + if d.serial == serial: + return [d] return None @@ -38,22 +38,33 @@ def _refresh_attached_blinkstick_device(self): if not self.serial: return False if devices := self.find_by_serial(self.serial): - self.device = devices[0] - self.device.open() - self.reports = self.device.find_feature_reports() + self.blinkstick_device = devices[0] + self.blinkstick_device.raw_device.open() + self.reports = self.blinkstick_device.raw_device.find_feature_reports() return True @staticmethod def get_attached_blinkstick_devices( find_all: bool = True, - ) -> list[hid.HidDevice]: + ) -> list[BlinkStickDevice[hid.HidDevice]]: devices = hid.HidDeviceFilter( vendor_id=VENDOR_ID, product_id=PRODUCT_ID ).get_devices() + + blinkstick_devices = [ + BlinkStickDevice( + raw_device=device, + serial=device.serial_number, + manufacturer=device.vendor_name, + version_attribute=device.version_number, + description=device.product_name, + ) + for device in devices + ] if find_all: - return devices + return blinkstick_devices - return devices[:1] + return blinkstick_devices[:1] def control_transfer( self, bmRequestType, bRequest, wValue, wIndex, data_or_wLength @@ -68,9 +79,9 @@ def control_transfer( *[c_ubyte(c) for c in data_or_wLength] ) data[0] = wValue - if not self.device.send_feature_report(data): + if not self.blinkstick_device.raw_device.send_feature_report(data): if self._refresh_attached_blinkstick_device(): - self.device.send_feature_report(data) + self.blinkstick_device.raw_device.send_feature_report(data) else: raise BlinkStickException( "Could not communicate with BlinkStick {0} - it may have been removed".format( @@ -80,15 +91,3 @@ def control_transfer( elif bmRequestType == 0x80 | 0x20: return self.reports[wValue - 1].get() - - def get_serial(self) -> str: - return str(self.device.serial_number) - - def get_manufacturer(self) -> str: - return str(self.device.vendor_name) - - def get_version_attribute(self) -> int: - return int(self.device.version_number) - - def get_description(self) -> str: - return str(self.device.product_name) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index d628d46..173f337 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -15,6 +15,7 @@ ColorFormat, ) from blinkstick.constants import VENDOR_ID, PRODUCT_ID, BlinkStickVariant +from blinkstick.devices.device import BlinkStickDevice from blinkstick.exceptions import BlinkStickException from blinkstick.utilities import string_to_info_block_data @@ -51,7 +52,9 @@ class BlinkStick: backend: USBBackend bs_serial: str - def __init__(self, device=None, error_reporting: bool = True): + def __init__( + self, device: BlinkStickDevice | None = None, error_reporting: bool = True + ): """ Constructor for the class. @@ -1418,10 +1421,10 @@ def find_first() -> BlinkStick | None: @rtype: BlinkStick @return: BlinkStick object or None if no devices are found """ - d = USBBackend.get_attached_blinkstick_devices(find_all=False) + blinkstick_devices = USBBackend.get_attached_blinkstick_devices(find_all=False) - if d: - return BlinkStick(device=d) + if blinkstick_devices: + return BlinkStick(device=blinkstick_devices[0]) return None From f06595f9f633bf761cd2db4ca8b0e43deef5d6a7 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 30 Nov 2024 18:41:49 +0000 Subject: [PATCH 69/86] refactor: Use BlinkStickDevice class Use the BlinkStickDevice class to retrieve readonly device data up front, and persist it in the BlinkStick wrapper class --- src/blinkstick/backends/unix_like.py | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/src/blinkstick/backends/unix_like.py b/src/blinkstick/backends/unix_like.py index 5b600d5..fb85cdb 100644 --- a/src/blinkstick/backends/unix_like.py +++ b/src/blinkstick/backends/unix_like.py @@ -49,10 +49,6 @@ def get_attached_blinkstick_devices( ) return [ # TODO: refactor this to DRY up the usb.util.get_string calls - # note that we can't use _usb_get_string here because we're not in an instance method - # and we don't have a BlinkStickDevice instance to call it on - # until then we'll just have to live with the duplication, and the fact that we're not able - # to handle USB errors in the same way as we do in the instance methods BlinkStickDevice( raw_device=device, serial=str(usb.util.get_string(device, 3, 1033)), @@ -98,23 +94,3 @@ def control_transfer( self.serial ) ) - - def _usb_get_string(self, index: int) -> str: - try: - return str( - usb.util.get_string(self.blinkstick_device.raw_device, index, 1033) - ) - except usb.USBError: - # Could not communicate with BlinkStick backend - # attempt to find it again based on serial - - if self._refresh_attached_blinkstick_device(): - return str( - usb.util.get_string(self.blinkstick_device.raw_device, index, 1033) - ) - else: - raise BlinkStickException( - "Could not communicate with BlinkStick {0} - it may have been removed".format( - self.serial - ) - ) From 80bf755dde570a9601bb435ef4e1e468ae0f6f68 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Thu, 28 Nov 2024 19:33:16 +0000 Subject: [PATCH 70/86] chore: Remove unused _find_blinksticks function --- src/blinkstick/blinkstick.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/blinkstick.py index 173f337..42b35d8 100644 --- a/src/blinkstick/blinkstick.py +++ b/src/blinkstick/blinkstick.py @@ -1380,24 +1380,6 @@ def send_data(self, channel: int) -> None: super(BlinkStickProMatrix, self).send_data(channel) -def _find_blicksticks(find_all: bool = True) -> list[BlinkStick] | None: - if sys.platform == "win32": - devices = hid.HidDeviceFilter( - vendor_id=VENDOR_ID, product_id=PRODUCT_ID - ).get_devices() - if find_all: - return devices - elif len(devices) > 0: - return devices[0] - else: - return None - - else: - return usb.core.find( - find_all=find_all, idVendor=VENDOR_ID, idProduct=PRODUCT_ID - ) - - def find_all() -> list[BlinkStick]: """ Find all attached BlinkStick devices. From 169543faadcffeb8c6364070738f102ecc81aab7 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Thu, 28 Nov 2024 19:32:43 +0000 Subject: [PATCH 71/86] refactor: move blinkstick class to new client package --- examples/random_color.py | 2 +- src/blinkstick/__init__.py | 4 ++-- src/blinkstick/clients/__init__.py | 0 src/blinkstick/{ => clients}/blinkstick.py | 0 src/blinkstick/main.py | 6 +++++- tests/clients/test_blinkstick.py | 2 +- tests/clients/test_blinkstick_matrix.py | 2 +- tests/clients/test_blinkstick_pro_matrix.py | 2 +- tests/conftest.py | 2 +- 9 files changed, 12 insertions(+), 8 deletions(-) create mode 100644 src/blinkstick/clients/__init__.py rename src/blinkstick/{ => clients}/blinkstick.py (100%) diff --git a/examples/random_color.py b/examples/random_color.py index cce40f0..e1c9630 100644 --- a/examples/random_color.py +++ b/examples/random_color.py @@ -1,4 +1,4 @@ -from blinkstick import blinkstick +from blinkstick.clients import blinkstick from blinkstick.exceptions import BlinkStickException bs = blinkstick.find_first() diff --git a/src/blinkstick/__init__.py b/src/blinkstick/__init__.py index 78537bc..7c3f0d3 100644 --- a/src/blinkstick/__init__.py +++ b/src/blinkstick/__init__.py @@ -1,7 +1,7 @@ from importlib.metadata import version, PackageNotFoundError -from .blinkstick import BlinkStick, BlinkStickPro, BlinkStickProMatrix -from .blinkstick import ( +from blinkstick.clients.blinkstick import BlinkStick, BlinkStickPro, BlinkStickProMatrix +from blinkstick.clients.blinkstick import ( find_all, find_by_serial, find_first, diff --git a/src/blinkstick/clients/__init__.py b/src/blinkstick/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/blinkstick/blinkstick.py b/src/blinkstick/clients/blinkstick.py similarity index 100% rename from src/blinkstick/blinkstick.py rename to src/blinkstick/clients/blinkstick.py diff --git a/src/blinkstick/main.py b/src/blinkstick/main.py index 62a7c40..c9e977a 100644 --- a/src/blinkstick/main.py +++ b/src/blinkstick/main.py @@ -5,7 +5,11 @@ import sys import logging -from .blinkstick import get_blinkstick_package_version, find_all, find_by_serial +from blinkstick.clients.blinkstick import ( + get_blinkstick_package_version, + find_all, + find_by_serial, +) from .constants import BlinkStickVariant logging.basicConfig() diff --git a/tests/clients/test_blinkstick.py b/tests/clients/test_blinkstick.py index fe46c63..c67477e 100644 --- a/tests/clients/test_blinkstick.py +++ b/tests/clients/test_blinkstick.py @@ -3,7 +3,7 @@ import pytest from blinkstick import ColorFormat -from blinkstick.blinkstick import BlinkStick +from blinkstick.clients.blinkstick import BlinkStick from blinkstick.constants import BlinkStickVariant from pytest_mock import MockFixture diff --git a/tests/clients/test_blinkstick_matrix.py b/tests/clients/test_blinkstick_matrix.py index 987c62e..6e40e53 100644 --- a/tests/clients/test_blinkstick_matrix.py +++ b/tests/clients/test_blinkstick_matrix.py @@ -1,4 +1,4 @@ -from blinkstick.blinkstick import BlinkStickProMatrix +from blinkstick.clients.blinkstick import BlinkStickProMatrix def test_instantiate(): diff --git a/tests/clients/test_blinkstick_pro_matrix.py b/tests/clients/test_blinkstick_pro_matrix.py index 1034acb..2bffb67 100644 --- a/tests/clients/test_blinkstick_pro_matrix.py +++ b/tests/clients/test_blinkstick_pro_matrix.py @@ -1,4 +1,4 @@ -from blinkstick.blinkstick import BlinkStickPro +from blinkstick.clients.blinkstick import BlinkStickPro def test_instantiate(): diff --git a/tests/conftest.py b/tests/conftest.py index bda0985..df9b086 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ import pytest -from blinkstick.blinkstick import BlinkStick +from blinkstick.clients.blinkstick import BlinkStick @pytest.fixture From d16bd245272c4ab596d95917acff527601ee02a8 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 30 Nov 2024 19:24:29 +0000 Subject: [PATCH 72/86] refactor: move client classes to client package move the main client classes (BlinkStick, BlinkStickPro, BlinkStickProMatrix) to a clients package Also, move the core functions (find_first etc) to core module --- examples/random_color.py | 3 +- src/blinkstick/__init__.py | 9 +- src/blinkstick/clients/__init__.py | 4 + src/blinkstick/clients/blinkstick.py | 744 +------------------- src/blinkstick/clients/blinkstick_pro.py | 645 +++++++++++++++++ src/blinkstick/core.py | 69 ++ src/blinkstick/main.py | 6 +- tests/clients/test_blinkstick_matrix.py | 2 +- tests/clients/test_blinkstick_pro_matrix.py | 2 +- 9 files changed, 744 insertions(+), 740 deletions(-) create mode 100644 src/blinkstick/clients/blinkstick_pro.py create mode 100644 src/blinkstick/core.py diff --git a/examples/random_color.py b/examples/random_color.py index e1c9630..c9eb068 100644 --- a/examples/random_color.py +++ b/examples/random_color.py @@ -1,7 +1,8 @@ +import blinkstick.core from blinkstick.clients import blinkstick from blinkstick.exceptions import BlinkStickException -bs = blinkstick.find_first() +bs = blinkstick.core.find_first() if bs is None: print("Could not find any BlinkSticks") diff --git a/src/blinkstick/__init__.py b/src/blinkstick/__init__.py index 7c3f0d3..4b6c522 100644 --- a/src/blinkstick/__init__.py +++ b/src/blinkstick/__init__.py @@ -1,12 +1,7 @@ from importlib.metadata import version, PackageNotFoundError -from blinkstick.clients.blinkstick import BlinkStick, BlinkStickPro, BlinkStickProMatrix -from blinkstick.clients.blinkstick import ( - find_all, - find_by_serial, - find_first, - get_blinkstick_package_version, -) +from blinkstick.clients import BlinkStick, BlinkStickPro, BlinkStickProMatrix +from .core import find_all, find_first, find_by_serial, get_blinkstick_package_version from .colors import Color, ColorFormat from .constants import BlinkStickVariant from .exceptions import BlinkStickException diff --git a/src/blinkstick/clients/__init__.py b/src/blinkstick/clients/__init__.py index e69de29..a6c3e84 100644 --- a/src/blinkstick/clients/__init__.py +++ b/src/blinkstick/clients/__init__.py @@ -0,0 +1,4 @@ +from .blinkstick import BlinkStick +from .blinkstick_pro import BlinkStickPro, BlinkStickProMatrix + +__all__ = ["BlinkStick", "BlinkStickPro", "BlinkStickProMatrix"] diff --git a/src/blinkstick/clients/blinkstick.py b/src/blinkstick/clients/blinkstick.py index 42b35d8..17dddc7 100644 --- a/src/blinkstick/clients/blinkstick.py +++ b/src/blinkstick/clients/blinkstick.py @@ -3,29 +3,24 @@ import sys import time import warnings -from importlib.metadata import version from typing import Callable from blinkstick.colors import ( hex_to_rgb, name_to_rgb, - remap_color, remap_rgb_value, remap_rgb_value_reverse, ColorFormat, ) -from blinkstick.constants import VENDOR_ID, PRODUCT_ID, BlinkStickVariant +from blinkstick.constants import BlinkStickVariant from blinkstick.devices.device import BlinkStickDevice from blinkstick.exceptions import BlinkStickException from blinkstick.utilities import string_to_info_block_data if sys.platform == "win32": from blinkstick.backends.win32 import Win32Backend as USBBackend - import pywinusb.hid as hid # type: ignore else: from blinkstick.backends.unix_like import UnixLikeBackend as USBBackend - import usb.core # type: ignore - import usb.util # type: ignore from random import randint @@ -250,29 +245,29 @@ def get_color( color_format: str | None = None, ) -> tuple[int, int, int] | str: """ - Get the current backend color in the defined format. + Get the current backend color in the defined format. - Currently supported formats: + Currently supported formats: - 1. rgb (default) - Returns values as 3-tuple (r,g,b) - 2. hex - returns current backend color as hexadecimal string + 1. rgb (default) - Returns values as 3-tuple (r,g,b) + 2. hex - returns current backend color as hexadecimal string - >>> b = blinkstick.find_first() - >>> b.set_color(red=255,green=0,blue=0) - >>> (r,g,b) = b.get_color() # Get color as rbg tuple - (255,0,0) - >>> hex = b.get_color(color_mode=ColorFormat.HEX) # Get color as hex string - '#ff0000' + import blinkstick.core >>> b = blinkstick.core.find_first() + >>> b.set_color(red=255,green=0,blue=0) + >>> (r,g,b) = b.get_color() # Get color as rbg tuple + (255,0,0) + >>> hex = b.get_color(color_mode=ColorFormat.HEX) # Get color as hex string + '#ff0000' - @type index: int - @param index: the index of the LED - @type color_mode: ColorFormat - @param color_mode: the format to return the color in (ColorFormat.RGB or ColorFormat.HEX) - defaults to ColorFormat.RGB - @type color_format: str - @param color_format: "rgb" or "hex". Defaults to "rgb". Deprecated, use color_mode instead. + @type index: int + @param index: the index of the LED + @type color_mode: ColorFormat + @param color_mode: the format to return the color in (ColorFormat.RGB or ColorFormat.HEX) - defaults to ColorFormat.RGB + @type color_format: str + @param color_format: "rgb" or "hex". Defaults to "rgb". Deprecated, use color_mode instead. - @rtype: (int, int, int) or str - @return: Either 3-tuple for R, G and B values, or hex string + @rtype: (int, int, int) or str + @return: Either 3-tuple for R, G and B values, or hex string """ # color_format is deprecated, and color_mode should be used instead # if color_format is specified, then raise a DeprecationWarning, but attempt to convert it to a ColorFormat enum @@ -685,21 +680,6 @@ def morph( self.set_color(channel=channel, index=index, red=r_end, green=g_end, blue=b_end) - def open_device(self, d): - """Open backend. - @param d: Device to open - """ - if self.backend is None: - raise BlinkStickException("Could not find BlinkStick...") - - if self.backend.is_kernel_driver_active(0): - try: - self.backend.detach_kernel_driver(0) - except usb.core.USBError as e: - raise BlinkStickException("Could not detach kernel driver: %s" % str(e)) - - return True - def get_inverse(self) -> bool: """ Get the value of inverse mode. This applies only to BlinkStick. Please use L{set_mode} for BlinkStick Pro @@ -743,689 +723,3 @@ def get_max_rgb_value(self) -> int: @return: 0..255 maximum value for each R, G and B color """ return self.max_rgb_value - - -class BlinkStickPro: - """ - BlinkStickPro class is specifically designed to control the individually - addressable LEDs connected to the backend. The tutorials section contains - all the details on how to connect them to BlinkStick Pro. - - U{http://www.blinkstick.com/help/tutorials} - - Code example on how you can use this class are available here: - - U{https://github.com/arvydas/blinkstick-python/wiki#code-examples-for-blinkstick-pro} - """ - - r_led_count: int - g_led_count: int - b_led_count: int - fps_count: int - data_transmission_delay: float - max_rgb_value: int - data: list[list[list[int]]] - bstick: BlinkStick | None - - def __init__( - self, - r_led_count: int = 0, - g_led_count: int = 0, - b_led_count: int = 0, - delay: float = 0.002, - max_rgb_value: int = 255, - ): - """ - Initialize BlinkStickPro class. - - @type r_led_count: int - @param r_led_count: number of LEDs on R channel - @type g_led_count: int - @param g_led_count: number of LEDs on G channel - @type b_led_count: int - @param b_led_count: number of LEDs on B channel - @type delay: int - @param delay: default transmission delay between frames - @type max_rgb_value: int - @param max_rgb_value: maximum color value for RGB channels - """ - - self.r_led_count = r_led_count - self.g_led_count = g_led_count - self.b_led_count = b_led_count - - self.fps_count = -1 - - self.data_transmission_delay = delay - - self.max_rgb_value = max_rgb_value - - # initialise data store for each channel - # pre-populated with zeroes - - self.data = [[], [], []] - - for i in range(0, r_led_count): - self.data[0].append([0, 0, 0]) - - for i in range(0, g_led_count): - self.data[1].append([0, 0, 0]) - - for i in range(0, b_led_count): - self.data[2].append([0, 0, 0]) - - self.bstick = None - - def set_color( - self, - channel: int, - index: int, - r: int, - g: int, - b: int, - remap_values: bool = True, - ) -> None: - """ - Set the color of a single pixel - - @type channel: int - @param channel: R, G or B channel - @type index: int - @param index: the index of LED on the channel - @type r: int - @param r: red color byte - @type g: int - @param g: green color byte - @type b: int - @param b: blue color byte - @type remap_values: bool - @param remap_values: remap the values to maximum set in L{set_max_rgb_value} - """ - - if remap_values: - r, g, b = [remap_color(val, self.max_rgb_value) for val in [r, g, b]] - - self.data[channel][index] = [g, r, b] - - def get_color(self, channel: int, index: int) -> tuple[int, int, int]: - """ - Get the current color of a single pixel. - - @type channel: int - @param channel: the channel of the LED - @type index: int - @param index: the index of the LED - - @rtype: (int, int, int) - @return: 3-tuple for R, G and B values - """ - - val = self.data[channel][index] - return val[1], val[0], val[2] - - def clear(self) -> None: - """ - Set all pixels to black in the frame buffer. - """ - for x in range(0, self.r_led_count): - self.set_color(0, x, 0, 0, 0) - - for x in range(0, self.g_led_count): - self.set_color(1, x, 0, 0, 0) - - for x in range(0, self.b_led_count): - self.set_color(2, x, 0, 0, 0) - - def off(self) -> None: - """ - Set all pixels to black in on the backend. - """ - self.clear() - self.send_data_all() - - def connect(self, serial: str | None = None): - """ - Connect to the first BlinkStick found - - @type serial: str - @param serial: Select the serial number of BlinkStick - """ - - if serial is None: - self.bstick = find_first() - else: - self.bstick = find_by_serial(serial=serial) - - return self.bstick is not None - - def send_data(self, channel: int) -> None: - """ - Send data stored in the internal buffer to the channel. - - @param channel: - - 0 - R pin on BlinkStick Pro board - - 1 - G pin on BlinkStick Pro board - - 2 - B pin on BlinkStick Pro board - """ - if self.bstick is None: - return - - packet_data = [item for sublist in self.data[channel] for item in sublist] - - try: - self.bstick.set_led_data(channel, packet_data) - time.sleep(self.data_transmission_delay) - except Exception as e: - print("Exception: {0}".format(e)) - - def send_data_all(self) -> None: - """ - Send data to all channels - """ - if self.r_led_count > 0: - self.send_data(0) - - if self.g_led_count > 0: - self.send_data(1) - - if self.b_led_count > 0: - self.send_data(2) - - -class BlinkStickProMatrix(BlinkStickPro): - """ - BlinkStickProMatrix class is specifically designed to control the individually - addressable LEDs connected to the backend and arranged in a matrix. The tutorials section contains - all the details on how to connect them to BlinkStick Pro with matrices. - - U{http://www.blinkstick.com/help/tutorials/blinkstick-pro-adafruit-neopixel-matrices} - - Code example on how you can use this class are available here: - - U{https://github.com/arvydas/blinkstick-python/wiki#code-examples-for-blinkstick-pro} - - Matrix is driven by using L{BlinkStickProMatrix.set_color} with [x,y] coordinates and class automatically - divides data into subsets and sends it to the matrices. - - For example, if you have 2 8x8 matrices connected to BlinkStickPro and you initialize - the class with - - >>> matrix = BlinkStickProMatrix(r_columns=8, r_rows=8, g_columns=8, g_rows=8) - - Then you can set the internal framebuffer by using {set_color} command: - - >>> matrix.set_color(x=10, y=5, r=255, g=0, b=0) - >>> matrix.set_color(x=6, y=3, r=0, g=255, b=0) - - And send data to both matrices in one go: - - >>> matrix.send_data_all() - - """ - - r_columns: int - r_rows: int - g_columns: int - g_rows: int - b_columns: int - b_rows: int - rows: int - cols: int - matrix_data: list[list[int]] - - def __init__( - self, - r_columns: int = 0, - r_rows: int = 0, - g_columns: int = 0, - g_rows: int = 0, - b_columns: int = 0, - b_rows: int = 0, - delay: float = 0.002, - max_rgb_value: int = 255, - ): - """ - Initialize BlinkStickProMatrix class. - - @type r_columns: int - @param r_columns: number of matric columns for R channel - @type g_columns: int - @param g_columns: number of matric columns for R channel - @type b_columns: int - @param b_columns: number of matric columns for R channel - @type delay: int - @param delay: default transmission delay between frames - @type max_rgb_value: int - @param max_rgb_value: maximum color value for RGB channels - """ - r_leds = r_columns * r_rows - g_leds = g_columns * g_rows - b_leds = b_columns * b_rows - - self.r_columns = r_columns - self.r_rows = r_rows - self.g_columns = g_columns - self.g_rows = g_rows - self.b_columns = b_columns - self.b_rows = b_rows - - super(BlinkStickProMatrix, self).__init__( - r_led_count=r_leds, - g_led_count=g_leds, - b_led_count=b_leds, - delay=delay, - max_rgb_value=max_rgb_value, - ) - - self.rows = max(r_rows, g_rows, b_rows) - self.cols = r_columns + g_columns + b_columns - - # initialise data store for matrix pre-populated with zeroes - self.matrix_data = [] - - for i in range(0, self.rows * self.cols): - self.matrix_data.append([0, 0, 0]) - - def set_color( - self, x: int, y: int, r: int, g: int, b: int, remap_values: bool = True - ) -> None: - """ - Set the color of a single pixel in the internal framebuffer. - - @type x: int - @param x: the x location in the matrix - @type y: int - @param y: the y location in the matrix - @type r: int - @param r: red color byte - @type g: int - @param g: green color byte - @type b: int - @param b: blue color byte - @type remap_values: bool - @param remap_values: Automatically remap values based on the {max_rgb_value} supplied in the constructor - """ - - if remap_values: - r, g, b = [remap_color(val, self.max_rgb_value) for val in [r, g, b]] - - self.matrix_data[self._coord_to_index(x, y)] = [g, r, b] - - def _coord_to_index(self, x: int, y: int) -> int: - return y * self.cols + x - - def get_color(self, x: int, y: int) -> tuple[int, int, int]: - """ - Get the current color of a single pixel. - - @type x: int - @param x: x coordinate of the internal framebuffer - @type y: int - @param y: y coordinate of the internal framebuffer - - @rtype: (int, int, int) - @return: 3-tuple for R, G and B values - """ - - val = self.matrix_data[self._coord_to_index(x, y)] - return val[1], val[0], val[2] - - def shift_left(self, remove: bool = False) -> None: - """ - Shift all LED values in the matrix to the left - - @type remove: bool - @param remove: whether to remove the pixels on the last column or move the to the first column - """ - if not remove: - temp = [] - for y in range(0, self.rows): - temp.append(self.get_color(0, y)) - - for y in range(0, self.rows): - for x in range(0, self.cols - 1): - r, g, b = self.get_color(x + 1, y) - - self.set_color(x, y, r, g, b, False) - - if remove: - for y in range(0, self.rows): - self.set_color(self.cols - 1, y, 0, 0, 0, False) - else: - for y in range(0, self.rows): - col = temp[y] - self.set_color(self.cols - 1, y, col[0], col[1], col[2], False) - - def shift_right(self, remove: bool = False) -> None: - """ - Shift all LED values in the matrix to the right - - @type remove: bool - @param remove: whether to remove the pixels on the last column or move the to the first column - """ - - if not remove: - temp = [] - for y in range(0, self.rows): - temp.append(self.get_color(self.cols - 1, y)) - - for y in range(0, self.rows): - for x in reversed(range(1, self.cols)): - r, g, b = self.get_color(x - 1, y) - - self.set_color(x, y, r, g, b, False) - - if remove: - for y in range(0, self.rows): - self.set_color(0, y, 0, 0, 0, False) - else: - for y in range(0, self.rows): - col = temp[y] - self.set_color(0, y, col[0], col[1], col[2], False) - - def shift_down(self, remove: bool = False) -> None: - """ - Shift all LED values in the matrix down - - @type remove: bool - @param remove: whether to remove the pixels on the last column or move the to the first column - """ - - if not remove: - temp = [] - for x in range(0, self.cols): - temp.append(self.get_color(x, self.rows - 1)) - - for y in reversed(range(1, self.rows)): - for x in range(0, self.cols): - r, g, b = self.get_color(x, y - 1) - - self.set_color(x, y, r, g, b, False) - - if remove: - for x in range(0, self.cols): - self.set_color(x, 0, 0, 0, 0, False) - else: - for x in range(0, self.cols): - col = temp[x] - self.set_color(x, 0, col[0], col[1], col[2], False) - - def shift_up(self, remove: bool = False): - """ - Shift all LED values in the matrix up - - @type remove: bool - @param remove: whether to remove the pixels on the last column or move the to the first column - """ - - if not remove: - temp = [] - for x in range(0, self.cols): - temp.append(self.get_color(x, 0)) - - for x in range(0, self.cols): - for y in range(0, self.rows - 1): - r, g, b = self.get_color(x, y + 1) - - self.set_color(x, y, r, g, b, False) - - if remove: - for x in range(0, self.cols): - self.set_color(x, self.rows - 1, 0, 0, 0, False) - else: - for x in range(0, self.cols): - col = temp[x] - self.set_color(x, self.rows - 1, col[0], col[1], col[2], False) - - def number(self, x: int, y: int, n: int, r: int, g: int, b: int) -> None: - """ - Render a 3x5 number n at location x,y and r,g,b color - - @type x: int - @param x: the x location in the matrix (left of the number) - @type y: int - @param y: the y location in the matrix (top of the number) - @type n: int - @param n: number digit to render 0..9 - @type r: int - @param r: red color byte - @type g: int - @param g: green color byte - @type b: int - @param b: blue color byte - """ - if n == 0: - self.rectangle(x, y, x + 2, y + 4, r, g, b) - elif n == 1: - self.line(x + 1, y, x + 1, y + 4, r, g, b) - self.line(x, y + 4, x + 2, y + 4, r, g, b) - self.set_color(x, y + 1, r, g, b) - elif n == 2: - self.line(x, y, x + 2, y, r, g, b) - self.line(x, y + 2, x + 2, y + 2, r, g, b) - self.line(x, y + 4, x + 2, y + 4, r, g, b) - self.set_color(x + 2, y + 1, r, g, b) - self.set_color(x, y + 3, r, g, b) - elif n == 3: - self.line(x, y, x + 2, y, r, g, b) - self.line(x, y + 2, x + 2, y + 2, r, g, b) - self.line(x, y + 4, x + 2, y + 4, r, g, b) - self.set_color(x + 2, y + 1, r, g, b) - self.set_color(x + 2, y + 3, r, g, b) - elif n == 4: - self.line(x, y, x, y + 2, r, g, b) - self.line(x + 2, y, x + 2, y + 4, r, g, b) - self.set_color(x + 1, y + 2, r, g, b) - elif n == 5: - self.line(x, y, x + 2, y, r, g, b) - self.line(x, y + 2, x + 2, y + 2, r, g, b) - self.line(x, y + 4, x + 2, y + 4, r, g, b) - self.set_color(x, y + 1, r, g, b) - self.set_color(x + 2, y + 3, r, g, b) - elif n == 6: - self.line(x, y, x + 2, y, r, g, b) - self.line(x, y + 2, x + 2, y + 2, r, g, b) - self.line(x, y + 4, x + 2, y + 4, r, g, b) - self.set_color(x, y + 1, r, g, b) - self.set_color(x + 2, y + 3, r, g, b) - self.set_color(x, y + 3, r, g, b) - elif n == 7: - self.line(x + 1, y + 2, x + 1, y + 4, r, g, b) - self.line(x, y, x + 2, y, r, g, b) - self.set_color(x + 2, y + 1, r, g, b) - elif n == 8: - self.line(x, y, x + 2, y, r, g, b) - self.line(x, y + 2, x + 2, y + 2, r, g, b) - self.line(x, y + 4, x + 2, y + 4, r, g, b) - self.set_color(x, y + 1, r, g, b) - self.set_color(x + 2, y + 1, r, g, b) - self.set_color(x + 2, y + 3, r, g, b) - self.set_color(x, y + 3, r, g, b) - elif n == 9: - self.line(x, y, x + 2, y, r, g, b) - self.line(x, y + 2, x + 2, y + 2, r, g, b) - self.line(x, y + 4, x + 2, y + 4, r, g, b) - self.set_color(x, y + 1, r, g, b) - self.set_color(x + 2, y + 1, r, g, b) - self.set_color(x + 2, y + 3, r, g, b) - - def rectangle( - self, x1: int, y1: int, x2: int, y2: int, r: int, g: int, b: int - ) -> None: - """ - Draw a rectangle with it's corners at x1:y1 and x2:y2 - - @type x1: int - @param x1: the x1 location in the matrix for first corner of the rectangle - @type y1: int - @param y1: the y1 location in the matrix for first corner of the rectangle - @type x2: int - @param x2: the x2 location in the matrix for second corner of the rectangle - @type y2: int - @param y2: the y2 location in the matrix for second corner of the rectangle - @type r: int - @param r: red color byte - @type g: int - @param g: green color byte - @type b: int - @param b: blue color byte - """ - - self.line(x1, y1, x1, y2, r, g, b) - self.line(x1, y1, x2, y1, r, g, b) - self.line(x2, y1, x2, y2, r, g, b) - self.line(x1, y2, x2, y2, r, g, b) - - def line( - self, x1: int, y1: int, x2: int, y2: int, r: int, g: int, b: int - ) -> list[tuple[int, int]]: - """ - Draw a line from x1:y1 and x2:y2 - - @type x1: int - @param x1: the x1 location in the matrix for the start of the line - @type y1: int - @param y1: the y1 location in the matrix for the start of the line - @type x2: int - @param x2: the x2 location in the matrix for the end of the line - @type y2: int - @param y2: the y2 location in the matrix for the end of the line - @type r: int - @param r: red color byte - @type g: int - @param g: green color byte - @type b: int - @param b: blue color byte - """ - points = [] - is_steep = abs(y2 - y1) > abs(x2 - x1) - if is_steep: - x1, y1 = y1, x1 - x2, y2 = y2, x2 - rev = False - if x1 > x2: - x1, x2 = x2, x1 - y1, y2 = y2, y1 - rev = True - delta_x = x2 - x1 - delta_y = abs(y2 - y1) - error = int(delta_x / 2) - y = y1 - y_step = None - - if y1 < y2: - y_step = 1 - else: - y_step = -1 - for x in range(x1, x2 + 1): - if is_steep: - # print y, "~", x - self.set_color(y, x, r, g, b) - points.append((y, x)) - else: - # print x, " ", y - self.set_color(x, y, r, g, b) - points.append((x, y)) - error -= delta_y - if error < 0: - y += y_step - error += delta_x - # Reverse the list if the coordinates were reversed - if rev: - points.reverse() - return points - - def clear(self) -> None: - """ - Set all pixels to black in the cached matrix - """ - for y in range(0, self.rows): - for x in range(0, self.cols): - self.set_color(x, y, 0, 0, 0) - - def send_data(self, channel: int) -> None: - """ - Send data stored in the internal buffer to the channel. - - @param channel: - - 0 - R pin on BlinkStick Pro board - - 1 - G pin on BlinkStick Pro board - - 2 - B pin on BlinkStick Pro board - """ - - start_col = 0 - end_col = 0 - - if channel == 0: - start_col = 0 - end_col = self.r_columns - - if channel == 1: - start_col = self.r_columns - end_col = start_col + self.g_columns - - if channel == 2: - start_col = self.r_columns + self.g_columns - end_col = start_col + self.b_columns - - self.data[channel] = [] - - # slice the huge array to individual packets - for y in range(0, self.rows): - start = y * self.cols + start_col - end = y * self.cols + end_col - - self.data[channel].extend(self.matrix_data[start:end]) - - super(BlinkStickProMatrix, self).send_data(channel) - - -def find_all() -> list[BlinkStick]: - """ - Find all attached BlinkStick devices. - - @rtype: BlinkStick[] - @return: a list of BlinkStick objects or None if no devices found - """ - result: list[BlinkStick] = [] - if (found_devices := USBBackend.get_attached_blinkstick_devices()) is None: - return result - for d in found_devices: - result.extend([BlinkStick(device=d)]) - - return result - - -def find_first() -> BlinkStick | None: - """ - Find first attached BlinkStick. - - @rtype: BlinkStick - @return: BlinkStick object or None if no devices are found - """ - blinkstick_devices = USBBackend.get_attached_blinkstick_devices(find_all=False) - - if blinkstick_devices: - return BlinkStick(device=blinkstick_devices[0]) - - return None - - -def find_by_serial(serial: str = "") -> BlinkStick | None: - """ - Find BlinkStick backend based on serial number. - - @rtype: BlinkStick - @return: BlinkStick object or None if no devices are found - """ - - devices = USBBackend.find_by_serial(serial=serial) - - if devices: - return BlinkStick(device=devices[0]) - - return None - - -def get_blinkstick_package_version() -> str: - return version("blinkstick") diff --git a/src/blinkstick/clients/blinkstick_pro.py b/src/blinkstick/clients/blinkstick_pro.py new file mode 100644 index 0000000..4dbbf56 --- /dev/null +++ b/src/blinkstick/clients/blinkstick_pro.py @@ -0,0 +1,645 @@ +from __future__ import annotations + +import time + +from blinkstick.clients import BlinkStick +from blinkstick.colors import remap_color + + +class BlinkStickPro: + """ + BlinkStickPro class is specifically designed to control the individually + addressable LEDs connected to the backend. The tutorials section contains + all the details on how to connect them to BlinkStick Pro. + + U{http://www.blinkstick.com/help/tutorials} + + Code example on how you can use this class are available here: + + U{https://github.com/arvydas/blinkstick-python/wiki#code-examples-for-blinkstick-pro} + """ + + r_led_count: int + g_led_count: int + b_led_count: int + fps_count: int + data_transmission_delay: float + max_rgb_value: int + data: list[list[list[int]]] + bstick: BlinkStick | None + + def __init__( + self, + r_led_count: int = 0, + g_led_count: int = 0, + b_led_count: int = 0, + delay: float = 0.002, + max_rgb_value: int = 255, + ): + """ + Initialize BlinkStickPro class. + + @type r_led_count: int + @param r_led_count: number of LEDs on R channel + @type g_led_count: int + @param g_led_count: number of LEDs on G channel + @type b_led_count: int + @param b_led_count: number of LEDs on B channel + @type delay: int + @param delay: default transmission delay between frames + @type max_rgb_value: int + @param max_rgb_value: maximum color value for RGB channels + """ + + self.r_led_count = r_led_count + self.g_led_count = g_led_count + self.b_led_count = b_led_count + + self.fps_count = -1 + + self.data_transmission_delay = delay + + self.max_rgb_value = max_rgb_value + + # initialise data store for each channel + # pre-populated with zeroes + + self.data = [[], [], []] + + for i in range(0, r_led_count): + self.data[0].append([0, 0, 0]) + + for i in range(0, g_led_count): + self.data[1].append([0, 0, 0]) + + for i in range(0, b_led_count): + self.data[2].append([0, 0, 0]) + + self.bstick = None + + def set_color( + self, + channel: int, + index: int, + r: int, + g: int, + b: int, + remap_values: bool = True, + ) -> None: + """ + Set the color of a single pixel + + @type channel: int + @param channel: R, G or B channel + @type index: int + @param index: the index of LED on the channel + @type r: int + @param r: red color byte + @type g: int + @param g: green color byte + @type b: int + @param b: blue color byte + @type remap_values: bool + @param remap_values: remap the values to maximum set in L{set_max_rgb_value} + """ + + if remap_values: + r, g, b = [remap_color(val, self.max_rgb_value) for val in [r, g, b]] + + self.data[channel][index] = [g, r, b] + + def get_color(self, channel: int, index: int) -> tuple[int, int, int]: + """ + Get the current color of a single pixel. + + @type channel: int + @param channel: the channel of the LED + @type index: int + @param index: the index of the LED + + @rtype: (int, int, int) + @return: 3-tuple for R, G and B values + """ + + val = self.data[channel][index] + return val[1], val[0], val[2] + + def clear(self) -> None: + """ + Set all pixels to black in the frame buffer. + """ + for x in range(0, self.r_led_count): + self.set_color(0, x, 0, 0, 0) + + for x in range(0, self.g_led_count): + self.set_color(1, x, 0, 0, 0) + + for x in range(0, self.b_led_count): + self.set_color(2, x, 0, 0, 0) + + def off(self) -> None: + """ + Set all pixels to black in on the backend. + """ + self.clear() + self.send_data_all() + + def connect(self, serial: str | None = None): + """ + Connect to the first BlinkStick found + + @type serial: str + @param serial: Select the serial number of BlinkStick + """ + + if serial is None: + from blinkstick import find_first + + self.bstick = find_first() + else: + from blinkstick import find_by_serial + + self.bstick = find_by_serial(serial=serial) + + return self.bstick is not None + + def send_data(self, channel: int) -> None: + """ + Send data stored in the internal buffer to the channel. + + @param channel: + - 0 - R pin on BlinkStick Pro board + - 1 - G pin on BlinkStick Pro board + - 2 - B pin on BlinkStick Pro board + """ + if self.bstick is None: + return + + packet_data = [item for sublist in self.data[channel] for item in sublist] + + try: + self.bstick.set_led_data(channel, packet_data) + time.sleep(self.data_transmission_delay) + except Exception as e: + print("Exception: {0}".format(e)) + + def send_data_all(self) -> None: + """ + Send data to all channels + """ + if self.r_led_count > 0: + self.send_data(0) + + if self.g_led_count > 0: + self.send_data(1) + + if self.b_led_count > 0: + self.send_data(2) + + +class BlinkStickProMatrix(BlinkStickPro): + """ + BlinkStickProMatrix class is specifically designed to control the individually + addressable LEDs connected to the backend and arranged in a matrix. The tutorials section contains + all the details on how to connect them to BlinkStick Pro with matrices. + + U{http://www.blinkstick.com/help/tutorials/blinkstick-pro-adafruit-neopixel-matrices} + + Code example on how you can use this class are available here: + + U{https://github.com/arvydas/blinkstick-python/wiki#code-examples-for-blinkstick-pro} + + Matrix is driven by using L{BlinkStickProMatrix.set_color} with [x,y] coordinates and class automatically + divides data into subsets and sends it to the matrices. + + For example, if you have 2 8x8 matrices connected to BlinkStickPro and you initialize + the class with + + >>> matrix = BlinkStickProMatrix(r_columns=8, r_rows=8, g_columns=8, g_rows=8) + + Then you can set the internal framebuffer by using {set_color} command: + + >>> matrix.set_color(x=10, y=5, r=255, g=0, b=0) + >>> matrix.set_color(x=6, y=3, r=0, g=255, b=0) + + And send data to both matrices in one go: + + >>> matrix.send_data_all() + + """ + + r_columns: int + r_rows: int + g_columns: int + g_rows: int + b_columns: int + b_rows: int + rows: int + cols: int + matrix_data: list[list[int]] + + def __init__( + self, + r_columns: int = 0, + r_rows: int = 0, + g_columns: int = 0, + g_rows: int = 0, + b_columns: int = 0, + b_rows: int = 0, + delay: float = 0.002, + max_rgb_value: int = 255, + ): + """ + Initialize BlinkStickProMatrix class. + + @type r_columns: int + @param r_columns: number of matric columns for R channel + @type g_columns: int + @param g_columns: number of matric columns for R channel + @type b_columns: int + @param b_columns: number of matric columns for R channel + @type delay: int + @param delay: default transmission delay between frames + @type max_rgb_value: int + @param max_rgb_value: maximum color value for RGB channels + """ + r_leds = r_columns * r_rows + g_leds = g_columns * g_rows + b_leds = b_columns * b_rows + + self.r_columns = r_columns + self.r_rows = r_rows + self.g_columns = g_columns + self.g_rows = g_rows + self.b_columns = b_columns + self.b_rows = b_rows + + super(BlinkStickProMatrix, self).__init__( + r_led_count=r_leds, + g_led_count=g_leds, + b_led_count=b_leds, + delay=delay, + max_rgb_value=max_rgb_value, + ) + + self.rows = max(r_rows, g_rows, b_rows) + self.cols = r_columns + g_columns + b_columns + + # initialise data store for matrix pre-populated with zeroes + self.matrix_data = [] + + for i in range(0, self.rows * self.cols): + self.matrix_data.append([0, 0, 0]) + + def set_color( + self, x: int, y: int, r: int, g: int, b: int, remap_values: bool = True + ) -> None: + """ + Set the color of a single pixel in the internal framebuffer. + + @type x: int + @param x: the x location in the matrix + @type y: int + @param y: the y location in the matrix + @type r: int + @param r: red color byte + @type g: int + @param g: green color byte + @type b: int + @param b: blue color byte + @type remap_values: bool + @param remap_values: Automatically remap values based on the {max_rgb_value} supplied in the constructor + """ + + if remap_values: + r, g, b = [remap_color(val, self.max_rgb_value) for val in [r, g, b]] + + self.matrix_data[self._coord_to_index(x, y)] = [g, r, b] + + def _coord_to_index(self, x: int, y: int) -> int: + return y * self.cols + x + + def get_color(self, x: int, y: int) -> tuple[int, int, int]: + """ + Get the current color of a single pixel. + + @type x: int + @param x: x coordinate of the internal framebuffer + @type y: int + @param y: y coordinate of the internal framebuffer + + @rtype: (int, int, int) + @return: 3-tuple for R, G and B values + """ + + val = self.matrix_data[self._coord_to_index(x, y)] + return val[1], val[0], val[2] + + def shift_left(self, remove: bool = False) -> None: + """ + Shift all LED values in the matrix to the left + + @type remove: bool + @param remove: whether to remove the pixels on the last column or move the to the first column + """ + if not remove: + temp = [] + for y in range(0, self.rows): + temp.append(self.get_color(0, y)) + + for y in range(0, self.rows): + for x in range(0, self.cols - 1): + r, g, b = self.get_color(x + 1, y) + + self.set_color(x, y, r, g, b, False) + + if remove: + for y in range(0, self.rows): + self.set_color(self.cols - 1, y, 0, 0, 0, False) + else: + for y in range(0, self.rows): + col = temp[y] + self.set_color(self.cols - 1, y, col[0], col[1], col[2], False) + + def shift_right(self, remove: bool = False) -> None: + """ + Shift all LED values in the matrix to the right + + @type remove: bool + @param remove: whether to remove the pixels on the last column or move the to the first column + """ + + if not remove: + temp = [] + for y in range(0, self.rows): + temp.append(self.get_color(self.cols - 1, y)) + + for y in range(0, self.rows): + for x in reversed(range(1, self.cols)): + r, g, b = self.get_color(x - 1, y) + + self.set_color(x, y, r, g, b, False) + + if remove: + for y in range(0, self.rows): + self.set_color(0, y, 0, 0, 0, False) + else: + for y in range(0, self.rows): + col = temp[y] + self.set_color(0, y, col[0], col[1], col[2], False) + + def shift_down(self, remove: bool = False) -> None: + """ + Shift all LED values in the matrix down + + @type remove: bool + @param remove: whether to remove the pixels on the last column or move the to the first column + """ + + if not remove: + temp = [] + for x in range(0, self.cols): + temp.append(self.get_color(x, self.rows - 1)) + + for y in reversed(range(1, self.rows)): + for x in range(0, self.cols): + r, g, b = self.get_color(x, y - 1) + + self.set_color(x, y, r, g, b, False) + + if remove: + for x in range(0, self.cols): + self.set_color(x, 0, 0, 0, 0, False) + else: + for x in range(0, self.cols): + col = temp[x] + self.set_color(x, 0, col[0], col[1], col[2], False) + + def shift_up(self, remove: bool = False): + """ + Shift all LED values in the matrix up + + @type remove: bool + @param remove: whether to remove the pixels on the last column or move the to the first column + """ + + if not remove: + temp = [] + for x in range(0, self.cols): + temp.append(self.get_color(x, 0)) + + for x in range(0, self.cols): + for y in range(0, self.rows - 1): + r, g, b = self.get_color(x, y + 1) + + self.set_color(x, y, r, g, b, False) + + if remove: + for x in range(0, self.cols): + self.set_color(x, self.rows - 1, 0, 0, 0, False) + else: + for x in range(0, self.cols): + col = temp[x] + self.set_color(x, self.rows - 1, col[0], col[1], col[2], False) + + def number(self, x: int, y: int, n: int, r: int, g: int, b: int) -> None: + """ + Render a 3x5 number n at location x,y and r,g,b color + + @type x: int + @param x: the x location in the matrix (left of the number) + @type y: int + @param y: the y location in the matrix (top of the number) + @type n: int + @param n: number digit to render 0..9 + @type r: int + @param r: red color byte + @type g: int + @param g: green color byte + @type b: int + @param b: blue color byte + """ + if n == 0: + self.rectangle(x, y, x + 2, y + 4, r, g, b) + elif n == 1: + self.line(x + 1, y, x + 1, y + 4, r, g, b) + self.line(x, y + 4, x + 2, y + 4, r, g, b) + self.set_color(x, y + 1, r, g, b) + elif n == 2: + self.line(x, y, x + 2, y, r, g, b) + self.line(x, y + 2, x + 2, y + 2, r, g, b) + self.line(x, y + 4, x + 2, y + 4, r, g, b) + self.set_color(x + 2, y + 1, r, g, b) + self.set_color(x, y + 3, r, g, b) + elif n == 3: + self.line(x, y, x + 2, y, r, g, b) + self.line(x, y + 2, x + 2, y + 2, r, g, b) + self.line(x, y + 4, x + 2, y + 4, r, g, b) + self.set_color(x + 2, y + 1, r, g, b) + self.set_color(x + 2, y + 3, r, g, b) + elif n == 4: + self.line(x, y, x, y + 2, r, g, b) + self.line(x + 2, y, x + 2, y + 4, r, g, b) + self.set_color(x + 1, y + 2, r, g, b) + elif n == 5: + self.line(x, y, x + 2, y, r, g, b) + self.line(x, y + 2, x + 2, y + 2, r, g, b) + self.line(x, y + 4, x + 2, y + 4, r, g, b) + self.set_color(x, y + 1, r, g, b) + self.set_color(x + 2, y + 3, r, g, b) + elif n == 6: + self.line(x, y, x + 2, y, r, g, b) + self.line(x, y + 2, x + 2, y + 2, r, g, b) + self.line(x, y + 4, x + 2, y + 4, r, g, b) + self.set_color(x, y + 1, r, g, b) + self.set_color(x + 2, y + 3, r, g, b) + self.set_color(x, y + 3, r, g, b) + elif n == 7: + self.line(x + 1, y + 2, x + 1, y + 4, r, g, b) + self.line(x, y, x + 2, y, r, g, b) + self.set_color(x + 2, y + 1, r, g, b) + elif n == 8: + self.line(x, y, x + 2, y, r, g, b) + self.line(x, y + 2, x + 2, y + 2, r, g, b) + self.line(x, y + 4, x + 2, y + 4, r, g, b) + self.set_color(x, y + 1, r, g, b) + self.set_color(x + 2, y + 1, r, g, b) + self.set_color(x + 2, y + 3, r, g, b) + self.set_color(x, y + 3, r, g, b) + elif n == 9: + self.line(x, y, x + 2, y, r, g, b) + self.line(x, y + 2, x + 2, y + 2, r, g, b) + self.line(x, y + 4, x + 2, y + 4, r, g, b) + self.set_color(x, y + 1, r, g, b) + self.set_color(x + 2, y + 1, r, g, b) + self.set_color(x + 2, y + 3, r, g, b) + + def rectangle( + self, x1: int, y1: int, x2: int, y2: int, r: int, g: int, b: int + ) -> None: + """ + Draw a rectangle with it's corners at x1:y1 and x2:y2 + + @type x1: int + @param x1: the x1 location in the matrix for first corner of the rectangle + @type y1: int + @param y1: the y1 location in the matrix for first corner of the rectangle + @type x2: int + @param x2: the x2 location in the matrix for second corner of the rectangle + @type y2: int + @param y2: the y2 location in the matrix for second corner of the rectangle + @type r: int + @param r: red color byte + @type g: int + @param g: green color byte + @type b: int + @param b: blue color byte + """ + + self.line(x1, y1, x1, y2, r, g, b) + self.line(x1, y1, x2, y1, r, g, b) + self.line(x2, y1, x2, y2, r, g, b) + self.line(x1, y2, x2, y2, r, g, b) + + def line( + self, x1: int, y1: int, x2: int, y2: int, r: int, g: int, b: int + ) -> list[tuple[int, int]]: + """ + Draw a line from x1:y1 and x2:y2 + + @type x1: int + @param x1: the x1 location in the matrix for the start of the line + @type y1: int + @param y1: the y1 location in the matrix for the start of the line + @type x2: int + @param x2: the x2 location in the matrix for the end of the line + @type y2: int + @param y2: the y2 location in the matrix for the end of the line + @type r: int + @param r: red color byte + @type g: int + @param g: green color byte + @type b: int + @param b: blue color byte + """ + points = [] + is_steep = abs(y2 - y1) > abs(x2 - x1) + if is_steep: + x1, y1 = y1, x1 + x2, y2 = y2, x2 + rev = False + if x1 > x2: + x1, x2 = x2, x1 + y1, y2 = y2, y1 + rev = True + delta_x = x2 - x1 + delta_y = abs(y2 - y1) + error = int(delta_x / 2) + y = y1 + y_step = None + + if y1 < y2: + y_step = 1 + else: + y_step = -1 + for x in range(x1, x2 + 1): + if is_steep: + # print y, "~", x + self.set_color(y, x, r, g, b) + points.append((y, x)) + else: + # print x, " ", y + self.set_color(x, y, r, g, b) + points.append((x, y)) + error -= delta_y + if error < 0: + y += y_step + error += delta_x + # Reverse the list if the coordinates were reversed + if rev: + points.reverse() + return points + + def clear(self) -> None: + """ + Set all pixels to black in the cached matrix + """ + for y in range(0, self.rows): + for x in range(0, self.cols): + self.set_color(x, y, 0, 0, 0) + + def send_data(self, channel: int) -> None: + """ + Send data stored in the internal buffer to the channel. + + @param channel: + - 0 - R pin on BlinkStick Pro board + - 1 - G pin on BlinkStick Pro board + - 2 - B pin on BlinkStick Pro board + """ + + start_col = 0 + end_col = 0 + + if channel == 0: + start_col = 0 + end_col = self.r_columns + + if channel == 1: + start_col = self.r_columns + end_col = start_col + self.g_columns + + if channel == 2: + start_col = self.r_columns + self.g_columns + end_col = start_col + self.b_columns + + self.data[channel] = [] + + # slice the huge array to individual packets + for y in range(0, self.rows): + start = y * self.cols + start_col + end = y * self.cols + end_col + + self.data[channel].extend(self.matrix_data[start:end]) + + super(BlinkStickProMatrix, self).send_data(channel) diff --git a/src/blinkstick/core.py b/src/blinkstick/core.py new file mode 100644 index 0000000..8aee9ca --- /dev/null +++ b/src/blinkstick/core.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import sys +from importlib.metadata import version +from typing import TYPE_CHECKING + + +if sys.platform == "win32": + from blinkstick.backends.win32 import Win32Backend as USBBackend +else: + from blinkstick.backends.unix_like import UnixLikeBackend as USBBackend + +if TYPE_CHECKING: + from blinkstick.clients import BlinkStick + + +def find_all() -> list[BlinkStick]: + """ + Find all attached BlinkStick devices. + + @rtype: BlinkStick[] + @return: a list of BlinkStick objects or None if no devices found + """ + from blinkstick.clients import BlinkStick + + result: list[BlinkStick] = [] + if (found_devices := USBBackend.get_attached_blinkstick_devices()) is None: + return result + for d in found_devices: + result.extend([BlinkStick(device=d)]) + + return result + + +def find_first() -> BlinkStick | None: + """ + Find first attached BlinkStick. + + @rtype: BlinkStick + @return: BlinkStick object or None if no devices are found + """ + from blinkstick.clients import BlinkStick + + blinkstick_devices = USBBackend.get_attached_blinkstick_devices(find_all=False) + + if blinkstick_devices: + return BlinkStick(device=blinkstick_devices[0]) + + return None + + +def find_by_serial(serial: str = "") -> BlinkStick | None: + """ + Find BlinkStick backend based on serial number. + + @rtype: BlinkStick + @return: BlinkStick object or None if no devices are found + """ + + devices = USBBackend.find_by_serial(serial=serial) + + if devices: + return BlinkStick(device=devices[0]) + + return None + + +def get_blinkstick_package_version() -> str: + return version("blinkstick") diff --git a/src/blinkstick/main.py b/src/blinkstick/main.py index c9e977a..cb67344 100644 --- a/src/blinkstick/main.py +++ b/src/blinkstick/main.py @@ -5,11 +5,7 @@ import sys import logging -from blinkstick.clients.blinkstick import ( - get_blinkstick_package_version, - find_all, - find_by_serial, -) +from blinkstick import find_all, find_by_serial, get_blinkstick_package_version from .constants import BlinkStickVariant logging.basicConfig() diff --git a/tests/clients/test_blinkstick_matrix.py b/tests/clients/test_blinkstick_matrix.py index 6e40e53..4ca747a 100644 --- a/tests/clients/test_blinkstick_matrix.py +++ b/tests/clients/test_blinkstick_matrix.py @@ -1,4 +1,4 @@ -from blinkstick.clients.blinkstick import BlinkStickProMatrix +from blinkstick.clients import BlinkStickProMatrix def test_instantiate(): diff --git a/tests/clients/test_blinkstick_pro_matrix.py b/tests/clients/test_blinkstick_pro_matrix.py index 2bffb67..53d7820 100644 --- a/tests/clients/test_blinkstick_pro_matrix.py +++ b/tests/clients/test_blinkstick_pro_matrix.py @@ -1,4 +1,4 @@ -from blinkstick.clients.blinkstick import BlinkStickPro +from blinkstick.clients import BlinkStickPro def test_instantiate(): From e3cfbb3cd6182be5f245bf7267ab8aeb22fc6cc1 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 30 Nov 2024 20:10:49 +0000 Subject: [PATCH 73/86] refactor: move entrypoint script move the entrypoint script into a scripts folder to keep things tidy. --- pyproject.toml | 2 +- src/{blinkstick => scripts}/main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/{blinkstick => scripts}/main.py (99%) diff --git a/pyproject.toml b/pyproject.toml index b1c239a..4723486 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dev = ["black", "isort", "mypy"] test = ["coverage", "pytest", "pytest-cov", "pytest-mock"] [project.scripts] -blinkstick = "blinkstick.main:main" +blinkstick = "scripts.main:main" [project.urls] homepage = "https://pypi.python.org/pypi/blinkstick/" diff --git a/src/blinkstick/main.py b/src/scripts/main.py similarity index 99% rename from src/blinkstick/main.py rename to src/scripts/main.py index cb67344..ed0d6bb 100644 --- a/src/blinkstick/main.py +++ b/src/scripts/main.py @@ -6,7 +6,7 @@ import logging from blinkstick import find_all, find_by_serial, get_blinkstick_package_version -from .constants import BlinkStickVariant +from blinkstick.constants import BlinkStickVariant logging.basicConfig() From 429e47f1db6eb6a25608bfd661f00ef1e00d2c58 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 30 Nov 2024 20:16:42 +0000 Subject: [PATCH 74/86] chore: add py.typed file to package add a py.typed file to the package to indicate to mypy that it uses type hinting --- src/blinkstick/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/blinkstick/py.typed diff --git a/src/blinkstick/py.typed b/src/blinkstick/py.typed new file mode 100644 index 0000000..e69de29 From 3d5965fe25ce2c732e55c10bcad269ce3ef7eb0a Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 30 Nov 2024 20:23:07 +0000 Subject: [PATCH 75/86] refactor: devices should be a proper package --- src/blinkstick/backends/base.py | 2 +- src/blinkstick/backends/unix_like.py | 2 +- src/blinkstick/backends/win32.py | 2 +- src/blinkstick/clients/blinkstick.py | 2 +- src/blinkstick/devices/__init__.py | 3 +++ 5 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 src/blinkstick/devices/__init__.py diff --git a/src/blinkstick/backends/base.py b/src/blinkstick/backends/base.py index 452fe54..c907a7b 100644 --- a/src/blinkstick/backends/base.py +++ b/src/blinkstick/backends/base.py @@ -4,7 +4,7 @@ from typing import TypeVar, Generic -from blinkstick.devices.device import BlinkStickDevice +from blinkstick.devices import BlinkStickDevice T = TypeVar("T") diff --git a/src/blinkstick/backends/unix_like.py b/src/blinkstick/backends/unix_like.py index fb85cdb..d30f443 100644 --- a/src/blinkstick/backends/unix_like.py +++ b/src/blinkstick/backends/unix_like.py @@ -5,7 +5,7 @@ from blinkstick.constants import VENDOR_ID, PRODUCT_ID from blinkstick.backends.base import BaseBackend -from blinkstick.devices.device import BlinkStickDevice +from blinkstick.devices import BlinkStickDevice from blinkstick.exceptions import BlinkStickException diff --git a/src/blinkstick/backends/win32.py b/src/blinkstick/backends/win32.py index 7f4754c..9399951 100644 --- a/src/blinkstick/backends/win32.py +++ b/src/blinkstick/backends/win32.py @@ -7,7 +7,7 @@ from blinkstick.constants import VENDOR_ID, PRODUCT_ID from blinkstick.backends.base import BaseBackend -from blinkstick.devices.device import BlinkStickDevice +from blinkstick.devices import BlinkStickDevice from blinkstick.exceptions import BlinkStickException diff --git a/src/blinkstick/clients/blinkstick.py b/src/blinkstick/clients/blinkstick.py index 17dddc7..8c0fd7d 100644 --- a/src/blinkstick/clients/blinkstick.py +++ b/src/blinkstick/clients/blinkstick.py @@ -13,7 +13,7 @@ ColorFormat, ) from blinkstick.constants import BlinkStickVariant -from blinkstick.devices.device import BlinkStickDevice +from blinkstick.devices import BlinkStickDevice from blinkstick.exceptions import BlinkStickException from blinkstick.utilities import string_to_info_block_data diff --git a/src/blinkstick/devices/__init__.py b/src/blinkstick/devices/__init__.py new file mode 100644 index 0000000..da0f84c --- /dev/null +++ b/src/blinkstick/devices/__init__.py @@ -0,0 +1,3 @@ +from .device import BlinkStickDevice + +__all__ = ["BlinkStickDevice"] From b4cf8147ca737d724944f30aacbcdeaa258530aa Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 30 Nov 2024 20:23:31 +0000 Subject: [PATCH 76/86] chore: remove unused import --- src/blinkstick/clients/blinkstick.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/blinkstick/clients/blinkstick.py b/src/blinkstick/clients/blinkstick.py index 8c0fd7d..3efd004 100644 --- a/src/blinkstick/clients/blinkstick.py +++ b/src/blinkstick/clients/blinkstick.py @@ -14,7 +14,6 @@ ) from blinkstick.constants import BlinkStickVariant from blinkstick.devices import BlinkStickDevice -from blinkstick.exceptions import BlinkStickException from blinkstick.utilities import string_to_info_block_data if sys.platform == "win32": From 8d04fabd889ce9de35466ca2ca9c845ab5454742 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 1 Dec 2024 08:17:02 +0000 Subject: [PATCH 77/86] refactor: move BlinkStickVariant to enums module --- src/blinkstick/__init__.py | 2 +- src/blinkstick/clients/blinkstick.py | 2 +- src/blinkstick/constants.py | 41 ---------------------------- src/blinkstick/enums.py | 40 +++++++++++++++++++++++++++ src/scripts/main.py | 8 ++++-- 5 files changed, 48 insertions(+), 45 deletions(-) create mode 100644 src/blinkstick/enums.py diff --git a/src/blinkstick/__init__.py b/src/blinkstick/__init__.py index 4b6c522..7053da9 100644 --- a/src/blinkstick/__init__.py +++ b/src/blinkstick/__init__.py @@ -3,7 +3,7 @@ from blinkstick.clients import BlinkStick, BlinkStickPro, BlinkStickProMatrix from .core import find_all, find_first, find_by_serial, get_blinkstick_package_version from .colors import Color, ColorFormat -from .constants import BlinkStickVariant +from .enums import BlinkStickVariant from .exceptions import BlinkStickException try: diff --git a/src/blinkstick/clients/blinkstick.py b/src/blinkstick/clients/blinkstick.py index 3efd004..697540a 100644 --- a/src/blinkstick/clients/blinkstick.py +++ b/src/blinkstick/clients/blinkstick.py @@ -12,7 +12,7 @@ remap_rgb_value_reverse, ColorFormat, ) -from blinkstick.constants import BlinkStickVariant +from blinkstick.enums import BlinkStickVariant from blinkstick.devices import BlinkStickDevice from blinkstick.utilities import string_to_info_block_data diff --git a/src/blinkstick/constants.py b/src/blinkstick/constants.py index 14348d3..4c6e431 100644 --- a/src/blinkstick/constants.py +++ b/src/blinkstick/constants.py @@ -1,43 +1,2 @@ -from __future__ import annotations - -from enum import Enum - VENDOR_ID = 0x20A0 PRODUCT_ID = 0x41E5 - - -class BlinkStickVariant(Enum): - UNKNOWN = (0, "Unknown") - BLINKSTICK = (1, "BlinkStick") - BLINKSTICK_PRO = (2, "BlinkStick Pro") - BLINKSTICK_STRIP = (3, "BlinkStick Strip") - BLINKSTICK_SQUARE = (4, "BlinkStick Square") - BLINKSTICK_NANO = (5, "BlinkStick Nano") - BLINKSTICK_FLEX = (6, "BlinkStick Flex") - - @property - def value(self) -> int: - return self._value_[0] - - @property - def description(self) -> str: - return self._value_[1] - - @staticmethod - def identify( - major_version: int, version_attribute: int | None - ) -> "BlinkStickVariant": - if major_version == 1: - return BlinkStickVariant.BLINKSTICK - elif major_version == 2: - return BlinkStickVariant.BLINKSTICK_PRO - elif major_version == 3: - if version_attribute == 0x200: - return BlinkStickVariant.BLINKSTICK_SQUARE - elif version_attribute == 0x201: - return BlinkStickVariant.BLINKSTICK_STRIP - elif version_attribute == 0x202: - return BlinkStickVariant.BLINKSTICK_NANO - elif version_attribute == 0x203: - return BlinkStickVariant.BLINKSTICK_FLEX - return BlinkStickVariant.UNKNOWN diff --git a/src/blinkstick/enums.py b/src/blinkstick/enums.py new file mode 100644 index 0000000..f56d719 --- /dev/null +++ b/src/blinkstick/enums.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from enum import Enum + + +class BlinkStickVariant(Enum): + UNKNOWN = (0, "Unknown") + BLINKSTICK = (1, "BlinkStick") + BLINKSTICK_PRO = (2, "BlinkStick Pro") + BLINKSTICK_STRIP = (3, "BlinkStick Strip") + BLINKSTICK_SQUARE = (4, "BlinkStick Square") + BLINKSTICK_NANO = (5, "BlinkStick Nano") + BLINKSTICK_FLEX = (6, "BlinkStick Flex") + + @property + def value(self) -> int: + return self._value_[0] + + @property + def description(self) -> str: + return self._value_[1] + + @staticmethod + def identify( + major_version: int, version_attribute: int | None + ) -> "BlinkStickVariant": + if major_version == 1: + return BlinkStickVariant.BLINKSTICK + elif major_version == 2: + return BlinkStickVariant.BLINKSTICK_PRO + elif major_version == 3: + if version_attribute == 0x200: + return BlinkStickVariant.BLINKSTICK_SQUARE + elif version_attribute == 0x201: + return BlinkStickVariant.BLINKSTICK_STRIP + elif version_attribute == 0x202: + return BlinkStickVariant.BLINKSTICK_NANO + elif version_attribute == 0x203: + return BlinkStickVariant.BLINKSTICK_FLEX + return BlinkStickVariant.UNKNOWN diff --git a/src/scripts/main.py b/src/scripts/main.py index ed0d6bb..2240b57 100644 --- a/src/scripts/main.py +++ b/src/scripts/main.py @@ -5,8 +5,12 @@ import sys import logging -from blinkstick import find_all, find_by_serial, get_blinkstick_package_version -from blinkstick.constants import BlinkStickVariant +from blinkstick import ( + find_all, + find_by_serial, + get_blinkstick_package_version, + BlinkStickVariant, +) logging.basicConfig() From 3fa16bb0a99cabb59163b4902528703c1707ac59 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 1 Dec 2024 08:50:29 +0000 Subject: [PATCH 78/86] refactor: get variant from backend --- src/blinkstick/backends/base.py | 3 +++ src/blinkstick/clients/blinkstick.py | 7 +------ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/blinkstick/backends/base.py b/src/blinkstick/backends/base.py index c907a7b..ac50d20 100644 --- a/src/blinkstick/backends/base.py +++ b/src/blinkstick/backends/base.py @@ -55,3 +55,6 @@ def get_version_attribute(self) -> int: def get_description(self): return self.blinkstick_device.description + + def get_variant(self): + return self.blinkstick_device.variant diff --git a/src/blinkstick/clients/blinkstick.py b/src/blinkstick/clients/blinkstick.py index 697540a..14fedf5 100644 --- a/src/blinkstick/clients/blinkstick.py +++ b/src/blinkstick/clients/blinkstick.py @@ -97,12 +97,7 @@ def get_variant(self) -> BlinkStickVariant: @return: BlinkStickVariant.UNKNOWN, BlinkStickVariant.BLINKSTICK, BlinkStickVariant.BLINKSTICK_PRO and etc """ - serial = self.get_serial() - major = serial[-3] - - version_attribute = self.backend.get_version_attribute() - - return BlinkStickVariant.identify(int(major), version_attribute) + return self.backend.get_variant() def get_variant_string(self) -> str: """ From 679441eda16d4f2d9d4e980f810ace5f002995ae Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 1 Dec 2024 09:09:05 +0000 Subject: [PATCH 79/86] test: fix up blinkstick variant tests --- tests/clients/test_blinkstick.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/clients/test_blinkstick.py b/tests/clients/test_blinkstick.py index c67477e..166e9fb 100644 --- a/tests/clients/test_blinkstick.py +++ b/tests/clients/test_blinkstick.py @@ -2,9 +2,9 @@ import pytest -from blinkstick import ColorFormat +from blinkstick.colors import ColorFormat +from blinkstick.enums import BlinkStickVariant from blinkstick.clients.blinkstick import BlinkStick -from blinkstick.constants import BlinkStickVariant from pytest_mock import MockFixture from tests.conftest import make_blinkstick @@ -54,8 +54,8 @@ def test_get_variant( make_blinkstick, serial, version_attribute, expected_variant, expected_variant_value ): bs = make_blinkstick() - bs.get_serial = MagicMock(return_value=serial) - bs.backend.get_version_attribute = MagicMock(return_value=version_attribute) + synthesised_variant = BlinkStickVariant.identify(int(serial[-3]), version_attribute) + bs.backend.get_variant = MagicMock(return_value=synthesised_variant) assert bs.get_variant() == expected_variant assert bs.get_variant().value == expected_variant_value From a75e3bbc15f212b9869a5d9dfcebb809a4eb9496 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 1 Dec 2024 09:21:52 +0000 Subject: [PATCH 80/86] refactor: rename identify class method Rename BlinkStickVariant.identify() to .from_version_attrs(), for added clarity. --- src/blinkstick/enums.py | 2 +- tests/clients/test_blinkstick.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/blinkstick/enums.py b/src/blinkstick/enums.py index f56d719..fcc3e2e 100644 --- a/src/blinkstick/enums.py +++ b/src/blinkstick/enums.py @@ -21,7 +21,7 @@ def description(self) -> str: return self._value_[1] @staticmethod - def identify( + def from_version_attrs( major_version: int, version_attribute: int | None ) -> "BlinkStickVariant": if major_version == 1: diff --git a/tests/clients/test_blinkstick.py b/tests/clients/test_blinkstick.py index 166e9fb..eb7d007 100644 --- a/tests/clients/test_blinkstick.py +++ b/tests/clients/test_blinkstick.py @@ -54,7 +54,9 @@ def test_get_variant( make_blinkstick, serial, version_attribute, expected_variant, expected_variant_value ): bs = make_blinkstick() - synthesised_variant = BlinkStickVariant.identify(int(serial[-3]), version_attribute) + synthesised_variant = BlinkStickVariant.from_version_attrs( + int(serial[-3]), version_attribute + ) bs.backend.get_variant = MagicMock(return_value=synthesised_variant) assert bs.get_variant() == expected_variant assert bs.get_variant().value == expected_variant_value From dd51c29d87ad09a85215574833c07f1a56b7c84f Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 1 Dec 2024 08:31:51 +0000 Subject: [PATCH 81/86] chore: add serialnumber model Add a serial number model so that it's easier to decompose th estring into constituent parts --- src/blinkstick/models.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/blinkstick/models.py diff --git a/src/blinkstick/models.py b/src/blinkstick/models.py new file mode 100644 index 0000000..c858eb3 --- /dev/null +++ b/src/blinkstick/models.py @@ -0,0 +1,32 @@ +import re +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class SerialDetails: + """ + A BlinkStick serial number representation. + + BSnnnnnn-1.0 + || | | |- Software minor version + || | |--- Software major version + || |-------- Denotes sequential number + ||----------- Denotes BlinkStick backend + + + """ + + serial: str + major_version: int = field(init=False) + minor_version: int = field(init=False) + sequence_number: int = field(init=False) + + def __post_init__(self): + serial_number_regex = r"BS(\d+)-(\d+)\.(\d+)" + match = re.match(serial_number_regex, self.serial) + if not match: + raise ValueError(f"Invalid serial number: {self.serial}") + + object.__setattr__(self, "sequence_number", int(match.group(1))) + object.__setattr__(self, "major_version", int(match.group(2))) + object.__setattr__(self, "minor_version", int(match.group(3))) From 714fbe0e0e0f7a04c352c63a010bfd2c65e035b0 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 1 Dec 2024 08:34:36 +0000 Subject: [PATCH 82/86] test: add tests for serial number model --- tests/models/__init__.py | 0 tests/models/serial_number.py | 17 +++++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 tests/models/__init__.py create mode 100644 tests/models/serial_number.py diff --git a/tests/models/__init__.py b/tests/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/models/serial_number.py b/tests/models/serial_number.py new file mode 100644 index 0000000..c5dbb3c --- /dev/null +++ b/tests/models/serial_number.py @@ -0,0 +1,17 @@ +import pytest +from blinkstick.models import SerialDetails + + +def test_serial_number_initialization(): + serial = "BS123456-1.0" + serial_number = SerialDetails(serial=serial) + + assert serial_number.serial == serial + assert serial_number.sequence_number == 123456 + assert serial_number.major_version == 1 + assert serial_number.minor_version == 0 + + +def test_serial_number_invalid_serial(): + with pytest.raises(ValueError, match="Invalid serial number: BS123456"): + SerialDetails(serial="BS123456") From ffce44511045df7a103b92c23d723676b9932dbe Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sat, 30 Nov 2024 20:43:39 +0000 Subject: [PATCH 83/86] chore: expose major version and variant on BlinkStickDeviceClass --- src/blinkstick/devices/device.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/blinkstick/devices/device.py b/src/blinkstick/devices/device.py index 410f8c9..fa9c1f2 100644 --- a/src/blinkstick/devices/device.py +++ b/src/blinkstick/devices/device.py @@ -1,6 +1,8 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Generic, TypeVar +from blinkstick.enums import BlinkStickVariant + T = TypeVar("T") @@ -13,3 +15,11 @@ class BlinkStickDevice(Generic[T]): manufacturer: str version_attribute: int description: str + major_version: int = field(init=False) + variant: BlinkStickVariant = field(init=False) + + def __post_init__(self): + self.major_version = int(self.serial[-3]) + self.variant = BlinkStickVariant.from_version_attrs( + major_version=self.major_version, version_attribute=self.version_attribute + ) From 2df7344d7a4751ed72d53599973c6ad10a0f4d16 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 1 Dec 2024 15:42:24 +0000 Subject: [PATCH 84/86] refactor: access serial through blinkstick_device --- src/blinkstick/backends/base.py | 7 +++---- src/blinkstick/backends/unix_like.py | 21 +++++++++------------ src/blinkstick/backends/win32.py | 14 ++++++-------- src/blinkstick/devices/device.py | 7 ++++--- 4 files changed, 22 insertions(+), 27 deletions(-) diff --git a/src/blinkstick/backends/base.py b/src/blinkstick/backends/base.py index ac50d20..1f1e028 100644 --- a/src/blinkstick/backends/base.py +++ b/src/blinkstick/backends/base.py @@ -11,11 +11,10 @@ class BaseBackend(ABC, Generic[T]): - serial: str | None blinkstick_device: BlinkStickDevice[T] - def __init__(self): - self.serial = None + def __init__(self, device: BlinkStickDevice[T]): + self.blinkstick_device = device @abstractmethod def _refresh_attached_blinkstick_device(self): @@ -45,7 +44,7 @@ def control_transfer( raise NotImplementedError def get_serial(self) -> str: - return self.blinkstick_device.serial + return self.blinkstick_device.serial_details.serial def get_manufacturer(self) -> str: return self.blinkstick_device.manufacturer diff --git a/src/blinkstick/backends/unix_like.py b/src/blinkstick/backends/unix_like.py index d30f443..052d9f1 100644 --- a/src/blinkstick/backends/unix_like.py +++ b/src/blinkstick/backends/unix_like.py @@ -7,19 +7,14 @@ from blinkstick.backends.base import BaseBackend from blinkstick.devices import BlinkStickDevice from blinkstick.exceptions import BlinkStickException +from blinkstick.models import SerialDetails class UnixLikeBackend(BaseBackend[usb.core.Device]): - - serial: str - blinkstick_device: BlinkStickDevice[usb.core.Device] - - def __init__(self, device=None): - self.blinkstick_device = device - super().__init__() + def __init__(self, device: BlinkStickDevice[usb.core.Device]): + super().__init__(device=device) if device: self.open_device() - self.serial = self.get_serial() def open_device(self) -> None: if self.blinkstick_device is None: @@ -34,7 +29,7 @@ def open_device(self) -> None: def _refresh_attached_blinkstick_device(self): if not self.blinkstick_device: return False - if devices := self.find_by_serial(self.blinkstick_device.serial): + if devices := self.find_by_serial(self.blinkstick_device.serial_details.serial): self.blinkstick_device = devices[0] self.open_device() return True @@ -51,7 +46,9 @@ def get_attached_blinkstick_devices( # TODO: refactor this to DRY up the usb.util.get_string calls BlinkStickDevice( raw_device=device, - serial=str(usb.util.get_string(device, 3, 1033)), + serial_details=SerialDetails( + serial=str(usb.util.get_string(device, 3, 1033)) + ), manufacturer=str(usb.util.get_string(device, 1, 1033)), version_attribute=device.bcdDevice, description=str(usb.util.get_string(device, 2, 1033)), @@ -63,7 +60,7 @@ def get_attached_blinkstick_devices( def find_by_serial(serial: str) -> list[BlinkStickDevice[usb.core.Device]] | None: found_devices = UnixLikeBackend.get_attached_blinkstick_devices() for d in found_devices: - if d.serial == serial: + if d.serial_details.serial == serial: return [d] return None @@ -91,6 +88,6 @@ def control_transfer( else: raise BlinkStickException( "Could not communicate with BlinkStick {0} - it may have been removed".format( - self.serial + self.blinkstick_device.serial_details.serial ) ) diff --git a/src/blinkstick/backends/win32.py b/src/blinkstick/backends/win32.py index 9399951..c0e9c5c 100644 --- a/src/blinkstick/backends/win32.py +++ b/src/blinkstick/backends/win32.py @@ -9,16 +9,14 @@ from blinkstick.backends.base import BaseBackend from blinkstick.devices import BlinkStickDevice from blinkstick.exceptions import BlinkStickException +from blinkstick.models import SerialDetails class Win32Backend(BaseBackend[hid.HidDevice]): - serial: str - blinkstick_device: BlinkStickDevice[hid.HidDevice] reports: list[hid.core.HidReport] def __init__(self, device: BlinkStickDevice[hid.HidDevice]): - super().__init__() - self.blinkstick_device = device + super().__init__(device=device) if device: self.blinkstick_device.raw_device.open() self.reports = self.blinkstick_device.raw_device.find_feature_reports() @@ -28,16 +26,16 @@ def __init__(self, device: BlinkStickDevice[hid.HidDevice]): def find_by_serial(serial: str) -> list[BlinkStickDevice[hid.HidDevice]] | None: found_devices = Win32Backend.get_attached_blinkstick_devices() for d in found_devices: - if d.serial == serial: + if d.serial_details.serial == serial: return [d] return None def _refresh_attached_blinkstick_device(self): # TODO This is weird semantics. fix up return values to be more sensible - if not self.serial: + if not self.blinkstick_device: return False - if devices := self.find_by_serial(self.serial): + if devices := self.find_by_serial(self.blinkstick_device.serial_details.serial): self.blinkstick_device = devices[0] self.blinkstick_device.raw_device.open() self.reports = self.blinkstick_device.raw_device.find_feature_reports() @@ -54,7 +52,7 @@ def get_attached_blinkstick_devices( blinkstick_devices = [ BlinkStickDevice( raw_device=device, - serial=device.serial_number, + serial_details=SerialDetails(serial=device.serial_number), manufacturer=device.vendor_name, version_attribute=device.version_number, description=device.product_name, diff --git a/src/blinkstick/devices/device.py b/src/blinkstick/devices/device.py index fa9c1f2..dc13792 100644 --- a/src/blinkstick/devices/device.py +++ b/src/blinkstick/devices/device.py @@ -2,6 +2,7 @@ from typing import Generic, TypeVar from blinkstick.enums import BlinkStickVariant +from blinkstick.models import SerialDetails T = TypeVar("T") @@ -11,7 +12,7 @@ class BlinkStickDevice(Generic[T]): """A BlinkStick device representation""" raw_device: T - serial: str + serial_details: SerialDetails manufacturer: str version_attribute: int description: str @@ -19,7 +20,7 @@ class BlinkStickDevice(Generic[T]): variant: BlinkStickVariant = field(init=False) def __post_init__(self): - self.major_version = int(self.serial[-3]) self.variant = BlinkStickVariant.from_version_attrs( - major_version=self.major_version, version_attribute=self.version_attribute + major_version=self.serial_details.major_version, + version_attribute=self.version_attribute, ) From 1aa1b0f97dcc23c7ad2d2b804b6e172d2ec38485 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 1 Dec 2024 16:04:24 +0000 Subject: [PATCH 85/86] test: add tests for BlinkStickDevice class --- tests/devices/__init__.py | 0 tests/devices/test_blinkstick_device.py | 60 +++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 tests/devices/__init__.py create mode 100644 tests/devices/test_blinkstick_device.py diff --git a/tests/devices/__init__.py b/tests/devices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/devices/test_blinkstick_device.py b/tests/devices/test_blinkstick_device.py new file mode 100644 index 0000000..ad8ad3f --- /dev/null +++ b/tests/devices/test_blinkstick_device.py @@ -0,0 +1,60 @@ +import pytest +from blinkstick.enums import BlinkStickVariant +from blinkstick.models import SerialDetails +from blinkstick.devices.device import BlinkStickDevice + + +@pytest.fixture +def make_serial_details(): + def _serial_details(serial: str = "BS123456-1.0") -> SerialDetails: + return SerialDetails(serial=serial) + + return _serial_details + + +@pytest.fixture +def make_blinkstick_device(mocker, make_serial_details): + def _make_blinkstick_device( + manufacturer: str = "Test Manufacturer", + version_attribute: int = 1, + description: str = "Test Description", + serial_number: str = "BS123456-1.0", + ): + return BlinkStickDevice( + raw_device=mocker.MagicMock(), + serial_details=make_serial_details(serial=serial_number), + manufacturer=manufacturer, + version_attribute=version_attribute, + description=description, + ) + + return _make_blinkstick_device + + +def test_blinkstick_device_initialization(make_blinkstick_device): + blinkstick_device = make_blinkstick_device( + manufacturer="Test Manufacturer", + version_attribute=1, + description="Test Description", + ) + assert blinkstick_device.manufacturer == "Test Manufacturer" + assert blinkstick_device.version_attribute == 1 + assert blinkstick_device.description == "Test Description" + assert blinkstick_device.serial_details.serial == "BS123456-1.0" + + +def test_blinkstick_device_variant(make_blinkstick_device): + manufacturer = "Test Manufacturer" + version_attribute = 1 + description = "Test Description" + serial_number = "BS123456-1.0" + + blinkstick_device = make_blinkstick_device( + manufacturer=manufacturer, + version_attribute=version_attribute, + description=description, + ) + assert blinkstick_device.variant == BlinkStickVariant.from_version_attrs( + major_version=1, # major version is 1 from the serial number + version_attribute=version_attribute, + ) From 3fdef2189bcba1ed56bf5b1889d4faf191b8cf56 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 1 Dec 2024 16:45:09 +0000 Subject: [PATCH 86/86] chore: update random color example --- examples/random_color.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/examples/random_color.py b/examples/random_color.py index c9eb068..19f8d41 100644 --- a/examples/random_color.py +++ b/examples/random_color.py @@ -1,8 +1,7 @@ -import blinkstick.core -from blinkstick.clients import blinkstick +from blinkstick import find_first from blinkstick.exceptions import BlinkStickException -bs = blinkstick.core.find_first() +bs = find_first() if bs is None: print("Could not find any BlinkSticks")