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) 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]) 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 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 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: diff --git a/tests/clients/test_blinkstick.py b/tests/clients/test_blinkstick.py index b5c1244..23f35d8 100644 --- a/tests/clients/test_blinkstick.py +++ b/tests/clients/test_blinkstick.py @@ -1,37 +1,39 @@ 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 +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( @@ -312,6 +314,52 @@ def test_inverse_does_not_affect_max_rgb_value(make_blinkstick): assert bs.get_max_rgb_value() == 100 +@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() + + @pytest.mark.parametrize( "mode, is_valid", [ @@ -325,9 +373,11 @@ def test_inverse_does_not_affect_max_rgb_value(make_blinkstick): (Mode.ADDRESSABLE, True), ], ) -def test_set_mode_raises_on_invalid_mode(make_blinkstick, mode, is_valid): +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: