Skip to content

Restrict set_mode/get_mode to supported variants only #121

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: release/2.0-dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions src/blinkstick/clients/blinkstick.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import sys
import time
import warnings
from functools import cached_property
from typing import Callable

from blinkstick.colors import (
Expand All @@ -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":
Expand Down Expand Up @@ -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.::
Expand Down Expand Up @@ -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):
Expand All @@ -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)

Expand Down
31 changes: 31 additions & 0 deletions src/blinkstick/configs.py
Original file line number Diff line number Diff line change
@@ -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,
),
}
Comment on lines +1 to +26
Copy link
Collaborator Author

@robberwick robberwick Feb 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@arvydas Is this correct? It wasn't clear to me if the non-pro multi addressable LED models (e.g. square, strip etc.) also supported mode change.. I figure they can't do mode 0 for a single RGB LED, but I wasn't clear on whether mode 1 (inverse mode) was supported in addressable mode, or what modes were available for variants that were not BS/BSPro.



def _get_device_config(variant: BlinkStickVariant) -> Configuration:
"""Get the configuration for a BlinkStick variant"""
return _VARIANT_CONFIGS.get(variant, _VARIANT_CONFIGS[BlinkStickVariant.UNKNOWN])
4 changes: 4 additions & 0 deletions src/blinkstick/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@ class NotConnected(BlinkStickException):

class USBBackendNotAvailable(BlinkStickException):
pass


class UnsupportedOperation(BlinkStickException):
pass
18 changes: 18 additions & 0 deletions src/blinkstick/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 7 additions & 2 deletions src/scripts/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
get_blinkstick_package_version,
BlinkStickVariant,
)
from blinkstick.exceptions import UnsupportedOperation

logging.basicConfig()

Expand Down Expand Up @@ -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:
Expand Down
82 changes: 66 additions & 16 deletions tests/clients/test_blinkstick.py
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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",
[
Expand All @@ -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:
Expand Down