From db4f18a3015a143ea2d83e6cc3bf7402142972fb Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 16 Feb 2025 09:05:09 +0000 Subject: [PATCH 1/9] feat: add Configuration dataclass for BlinkStick configuration representation --- src/blinkstick/models.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/blinkstick/models.py b/src/blinkstick/models.py index c858eb3..38673bf 100644 --- a/src/blinkstick/models.py +++ b/src/blinkstick/models.py @@ -30,3 +30,21 @@ def __post_init__(self): 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))) + + +@dataclass(frozen=True) +class Configuration: + """ + A BlinkStick configuration representation. + + This is used to capture the configuration of a BlinkStick variant, and the capabilities of the device. + + e.g. + * BlinkStickPro supports mode changes, while BlinkStick does not. + * BlinkStickSquare has a fixed number of LEDs and channels, while BlinkStickPro has a max of 64 LEDs and 3 channels. + + Currently only mode_change_support is supported. + + """ + + mode_change_support: bool From e8faa715f663dfe7f5cd690f95d4f65f796624ed Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 16 Feb 2025 08:59:20 +0000 Subject: [PATCH 2/9] feat: add configuration mappings for BlinkStick variants --- src/blinkstick/configs.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/blinkstick/configs.py diff --git a/src/blinkstick/configs.py b/src/blinkstick/configs.py new file mode 100644 index 0000000..e086fda --- /dev/null +++ b/src/blinkstick/configs.py @@ -0,0 +1,31 @@ +from blinkstick.enums import BlinkStickVariant +from blinkstick.models import Configuration + +_VARIANT_CONFIGS: dict[BlinkStickVariant, Configuration] = { + BlinkStickVariant.BLINKSTICK: Configuration( + mode_change_support=False, + ), + BlinkStickVariant.BLINKSTICK_PRO: Configuration( + mode_change_support=True, + ), + BlinkStickVariant.BLINKSTICK_NANO: Configuration( + mode_change_support=True, + ), + BlinkStickVariant.BLINKSTICK_SQUARE: Configuration( + mode_change_support=True, + ), + BlinkStickVariant.BLINKSTICK_STRIP: Configuration( + mode_change_support=True, + ), + BlinkStickVariant.BLINKSTICK_FLEX: Configuration( + mode_change_support=True, + ), + BlinkStickVariant.UNKNOWN: Configuration( + mode_change_support=False, + ), +} + + +def _get_device_config(variant: BlinkStickVariant) -> Configuration: + """Get the configuration for a BlinkStick variant""" + return _VARIANT_CONFIGS.get(variant, _VARIANT_CONFIGS[BlinkStickVariant.UNKNOWN]) From fcb60f5105f8d72a8b6d162457592fa62d8ff37d Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 16 Feb 2025 09:13:57 +0000 Subject: [PATCH 3/9] feat: add UnsupportedOperation exception for BlinkStick operations --- src/blinkstick/exceptions.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/blinkstick/exceptions.py b/src/blinkstick/exceptions.py index 1cc2d84..7875c1a 100644 --- a/src/blinkstick/exceptions.py +++ b/src/blinkstick/exceptions.py @@ -11,3 +11,7 @@ class NotConnected(BlinkStickException): class USBBackendNotAvailable(BlinkStickException): pass + + +class UnsupportedOperation(BlinkStickException): + pass From 349f13fff51f3054a1c103189e57e666bd047967 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 16 Feb 2025 09:15:04 +0000 Subject: [PATCH 4/9] test: add tests for set_mode and get_mode on supported BlinkStick variants --- tests/clients/test_blinkstick.py | 50 +++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/tests/clients/test_blinkstick.py b/tests/clients/test_blinkstick.py index b5c1244..2ae7b55 100644 --- a/tests/clients/test_blinkstick.py +++ b/tests/clients/test_blinkstick.py @@ -1,13 +1,15 @@ from unittest.mock import MagicMock import pytest +from pytest_mock import MockFixture +from blinkstick.clients.blinkstick import BlinkStick from blinkstick.colors import ColorFormat from blinkstick.enums import BlinkStickVariant, Mode from blinkstick.clients.blinkstick import BlinkStick from pytest_mock import MockFixture -from blinkstick.exceptions import NotConnected +from blinkstick.exceptions import NotConnected, UnsupportedOperation from tests.conftest import make_blinkstick @@ -333,3 +335,49 @@ def test_set_mode_raises_on_invalid_mode(make_blinkstick, mode, is_valid): else: with pytest.raises(ValueError): bs.set_mode("invalid_mode") # noqa + + +@pytest.mark.parametrize( + "variant, is_supported", + [ + pytest.param(BlinkStickVariant.BLINKSTICK, False, id="BlinkStick"), + pytest.param(BlinkStickVariant.BLINKSTICK_PRO, True, id="BlinkStickPro"), + pytest.param(BlinkStickVariant.BLINKSTICK_STRIP, True, id="BlinkStickStrip"), + pytest.param(BlinkStickVariant.BLINKSTICK_SQUARE, True, id="BlinkStickSquare"), + pytest.param(BlinkStickVariant.BLINKSTICK_NANO, True, id="BlinkStickNano"), + pytest.param(BlinkStickVariant.BLINKSTICK_FLEX, True, id="BlinkStickFlex"), + pytest.param(BlinkStickVariant.UNKNOWN, False, id="Unknown"), + ], +) +def test_set_mode_supported_variants(mocker, make_blinkstick, variant, is_supported): + """Test that set_mode is supported only for BlinkstickPro. Other variants should raise an exception.""" + bs = make_blinkstick() + bs.get_variant = mocker.Mock(return_value=variant) + if not is_supported: + with pytest.raises(UnsupportedOperation): + bs.set_mode(2) + else: + bs.set_mode(2) + + +@pytest.mark.parametrize( + "variant, is_supported", + [ + pytest.param(BlinkStickVariant.BLINKSTICK, False, id="BlinkStick"), + pytest.param(BlinkStickVariant.BLINKSTICK_PRO, True, id="BlinkStickPro"), + pytest.param(BlinkStickVariant.BLINKSTICK_STRIP, True, id="BlinkStickStrip"), + pytest.param(BlinkStickVariant.BLINKSTICK_SQUARE, True, id="BlinkStickSquare"), + pytest.param(BlinkStickVariant.BLINKSTICK_NANO, True, id="BlinkStickNano"), + pytest.param(BlinkStickVariant.BLINKSTICK_FLEX, True, id="BlinkStickFlex"), + pytest.param(BlinkStickVariant.UNKNOWN, False, id="Unknown"), + ], +) +def test_get_mode_supported_variants(mocker, make_blinkstick, variant, is_supported): + """Test that get_mode is supported only for BlinkstickPro. Other variants should raise an exception.""" + bs = make_blinkstick() + bs.get_variant = mocker.Mock(return_value=variant) + if not is_supported: + with pytest.raises(UnsupportedOperation): + bs.get_mode() + else: + bs.get_mode() From 64484029a7607c069412eb38d8190081eae9b8c8 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 16 Feb 2025 10:34:36 +0000 Subject: [PATCH 5/9] test: refactor test_all_methods_require_backend to use parameterized tests --- tests/clients/test_blinkstick.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/tests/clients/test_blinkstick.py b/tests/clients/test_blinkstick.py index 2ae7b55..d078208 100644 --- a/tests/clients/test_blinkstick.py +++ b/tests/clients/test_blinkstick.py @@ -13,27 +13,30 @@ from tests.conftest import make_blinkstick +def get_blinkstick_methods(): + """Get all public methods from BlinkStick class.""" + return [ + method + for method in dir(BlinkStick) + if callable(getattr(BlinkStick, method)) and not method.startswith("__") + ] + + def test_instantiate(): """Test that we can instantiate a BlinkStick object.""" bs = BlinkStick() assert bs is not None -def test_all_methods_require_backend(): +@pytest.mark.parametrize("method_name", get_blinkstick_methods()) +def test_all_methods_require_backend(method_name): """Test that all methods require a backend.""" # Create an instance of BlinkStick. Note that we do not use the mock, or pass a device. # This is deliberate, as we want to test that all methods raise an exception when the backend is not set. bs = BlinkStick() - - class_methods = ( - method - for method in dir(BlinkStick) - if callable(getattr(bs, method)) and not method.startswith("__") - ) - for method_name in class_methods: - method = getattr(bs, method_name) - with pytest.raises(NotConnected): - method() + method = getattr(bs, method_name) + with pytest.raises(NotConnected): + method() @pytest.mark.parametrize( From 26eeadeb0cd221d80094f1a3fd6f78f02f457130 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 16 Feb 2025 10:41:44 +0000 Subject: [PATCH 6/9] feat: add cached property for device configuration and handle UnsupportedOperation in mode change methods --- src/blinkstick/clients/blinkstick.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/blinkstick/clients/blinkstick.py b/src/blinkstick/clients/blinkstick.py index a3545fb..77ca21d 100644 --- a/src/blinkstick/clients/blinkstick.py +++ b/src/blinkstick/clients/blinkstick.py @@ -3,6 +3,7 @@ import sys import time import warnings +from functools import cached_property from typing import Callable from blinkstick.colors import ( @@ -12,10 +13,11 @@ remap_rgb_value_reverse, ColorFormat, ) -from blinkstick.decorators import no_backend_required +from blinkstick.configs import _get_device_config from blinkstick.devices import BlinkStickDevice from blinkstick.enums import BlinkStickVariant, Mode -from blinkstick.exceptions import NotConnected +from blinkstick.exceptions import NotConnected, UnsupportedOperation +from blinkstick.models import Configuration from blinkstick.utilities import string_to_info_block_data if sys.platform == "win32": @@ -94,6 +96,15 @@ def __str__(self): return "Blinkstick - Not connected" return f"{variant} ({serial})" + @cached_property + def _config(self) -> Configuration: + """ + Get the hardware configuration of the connected device, using the reported variant. + + @rtype: Configuration + """ + return _get_device_config(self.get_variant()) + def get_serial(self) -> str: """ Returns the serial number of backend.:: @@ -388,6 +399,11 @@ def set_mode(self, mode: Mode | int) -> None: @type mode: int @param mode: Device mode to set """ + if not self._config.mode_change_support: + raise UnsupportedOperation( + "This operation is only supported on BlinkStick Pro devices" + ) + # If mode is an enum, get the value # this will allow the user to pass in the enum directly, and also gate the value to the enum values if not isinstance(mode, int): @@ -411,6 +427,10 @@ def get_mode(self) -> int: @rtype: int @return: Device mode """ + if not self._config.mode_change_support: + raise UnsupportedOperation( + "This operation is only supported on BlinkStick Pro devices" + ) device_bytes = self.backend.control_transfer(0x80 | 0x20, 0x1, 0x0004, 0, 2) From f16cc14af6690d532b1cf117986bfb18155e1cb6 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 16 Feb 2025 10:41:58 +0000 Subject: [PATCH 7/9] feat: handle UnsupportedOperation in print_info for BlinkStick mode retrieval --- src/scripts/main.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/scripts/main.py b/src/scripts/main.py index 2240b57..b6f04ea 100644 --- a/src/scripts/main.py +++ b/src/scripts/main.py @@ -11,6 +11,7 @@ get_blinkstick_package_version, BlinkStickVariant, ) +from blinkstick.exceptions import UnsupportedOperation logging.basicConfig() @@ -87,14 +88,18 @@ def format_usage(self, usage): def print_info(stick): + variant = stick.get_variant() 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())) 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() == BlinkStickVariant.BLINKSTICK_FLEX: + try: + print(" Mode: {0}".format(stick.get_mode())) + except UnsupportedOperation: + print(" Mode: Not supported") + if variant == BlinkStickVariant.BLINKSTICK_FLEX: try: count = stick.get_led_count() except: From a463b96c5f77b5a5d9f22ec1e5b38b077fc99967 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 16 Feb 2025 10:56:27 +0000 Subject: [PATCH 8/9] refactor: remove unused imports in test_blinkstick.py --- tests/clients/test_blinkstick.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/clients/test_blinkstick.py b/tests/clients/test_blinkstick.py index d078208..773435a 100644 --- a/tests/clients/test_blinkstick.py +++ b/tests/clients/test_blinkstick.py @@ -6,9 +6,6 @@ from blinkstick.clients.blinkstick import BlinkStick from blinkstick.colors import ColorFormat from blinkstick.enums import BlinkStickVariant, Mode -from blinkstick.clients.blinkstick import BlinkStick -from pytest_mock import MockFixture - from blinkstick.exceptions import NotConnected, UnsupportedOperation from tests.conftest import make_blinkstick From 9b42a2bb330a378851b73477145c2426d8f427c1 Mon Sep 17 00:00:00 2001 From: Rob Berwick Date: Sun, 16 Feb 2025 13:58:12 +0000 Subject: [PATCH 9/9] test: refactor test_set_mode_raises_on_invalid_mode to use mock for variant support --- tests/clients/test_blinkstick.py | 48 +++++++++++++++++--------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/tests/clients/test_blinkstick.py b/tests/clients/test_blinkstick.py index 773435a..23f35d8 100644 --- a/tests/clients/test_blinkstick.py +++ b/tests/clients/test_blinkstick.py @@ -314,29 +314,6 @@ def test_inverse_does_not_affect_max_rgb_value(make_blinkstick): assert bs.get_max_rgb_value() == 100 -@pytest.mark.parametrize( - "mode, is_valid", - [ - (1, True), - (2, True), - (3, True), - (4, False), - (-1, False), - (Mode.RGB, True), - (Mode.RGB_INVERSE, True), - (Mode.ADDRESSABLE, True), - ], -) -def test_set_mode_raises_on_invalid_mode(make_blinkstick, mode, is_valid): - """Test that set_mode raises an exception when an invalid mode is passed.""" - bs = make_blinkstick() - if is_valid: - bs.set_mode(mode) - else: - with pytest.raises(ValueError): - bs.set_mode("invalid_mode") # noqa - - @pytest.mark.parametrize( "variant, is_supported", [ @@ -381,3 +358,28 @@ def test_get_mode_supported_variants(mocker, make_blinkstick, variant, is_suppor bs.get_mode() else: bs.get_mode() + + +@pytest.mark.parametrize( + "mode, is_valid", + [ + (1, True), + (2, True), + (3, True), + (4, False), + (-1, False), + (Mode.RGB, True), + (Mode.RGB_INVERSE, True), + (Mode.ADDRESSABLE, True), + ], +) +def test_set_mode_raises_on_invalid_mode(mocker, make_blinkstick, mode, is_valid): + """Test that set_mode raises an exception when an invalid mode is passed.""" + bs = make_blinkstick() + # set_mode is only supported for BlinkStickPro + bs.get_variant = mocker.Mock(return_value=BlinkStickVariant.BLINKSTICK_PRO) + if is_valid: + bs.set_mode(mode) + else: + with pytest.raises(ValueError): + bs.set_mode("invalid_mode") # noqa