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 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" diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml new file mode 100644 index 0000000..d3e06d0 --- /dev/null +++ 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 diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 0000000..5a7ee8f --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,35 @@ +name: Run Pytest + +on: + pull_request: + branches: + - master + - feature/* + - release/* + +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 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 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/bin/blinkstick b/bin/blinkstick deleted file mode 100755 index a6786d3..0000000 --- a/bin/blinkstick +++ /dev/null @@ -1,330 +0,0 @@ -#!/usr/bin/env python3 - -from optparse import OptionParser, IndentedHelpFormatter, OptionGroup -from blinkstick import blinkstick -import textwrap -import sys -import time -import logging -logging.basicConfig() - - -class IndentedHelpFormatterWithNL(IndentedHelpFormatter): - def format_description(self, description): - 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') - formatted_bits = [ - textwrap.fill(bit, - desc_width, - initial_indent=indent, - subsequent_indent=indent) - for bit in bits] - result = "\n".join(formatted_bits) + "\n" - return result - - def format_option(self, option): - # The help for each option consists of two parts: - # * the opt strings and metavars - # eg. ("-x", or "-fFILENAME, --file=FILENAME") - # * the user-supplied help string - # eg. ("turn on expert mode", "read data from FILENAME") - # - # If possible, we write both of these on the same line: - # -x turn on expert mode - # - # But if the opt string list is too long, we put the help - # string on a second line, indented to the same column it would - # start in if it fit on the first line. - # -fFILENAME, --file=FILENAME - # read data from FILENAME - result = [] - opts = self.option_strings[option] - opt_width = self.help_position - self.current_indent - 2 - - if len(opts) > opt_width: - opts = "%*s%s\n" % (self.current_indent, "", opts) - indent_first = self.help_position - else: # start help on same line as opts - opts = "%*s%-*s " % (self.current_indent, "", opt_width, opts) - indent_first = 0 - - result.append(opts) - - if option.help: - help_text = self.expand_default(option) - # Everything is the same up through here - help_lines = [] - 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:]]) - 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" % (blinkstick.get_blinkstick_package_version(), IndentedHelpFormatter.format_usage(self, usage)) - - -def print_info(stick): - print("Found device:") - 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() == blinkstick.BlinkStick.BLINKSTICK_FLEX: - try: - count = stick.get_led_count() - except: - count = -1 - - if count == -1: - count = "Error" - print(" LED conf: {0}".format(count)) - print(" Info Block 1: {0}".format(stick.get_info_block1())) - print(" Info Block 2: {0}".format(stick.get_info_block2())) - - -def main(): - global options - global sticks - - 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 device 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 device ") - - 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("--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("--set-color", - dest="color", - help="Set the color for the device. 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.") - - parser.add_option_group(group) - - 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("--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("--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("--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 device 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-infoblock1", - dest="infoblock1", - help="Set the first info block for the device.") - - group.add_option("--set-infoblock2", - dest="infoblock2", - help="Set the second info block for the device.") - - parser.add_option_group(group) - - 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.") - - parser.add_option_group(group) - - - - (options, args) = parser.parse_args() - - if options.serial is None: - sticks = blinkstick.find_all() - else: - sticks = [blinkstick.find_by_serial(options.serial)] - - if len(sticks) == 0: - print("BlinkStick with serial number " + options.device + " not found...") - return 64 - - #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.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") - return 64 - - print("Reboot your computer for changes to take effect") - return 0 - - for stick in sticks: - if options.inverse: - stick.set_inverse(True) - - stick.set_max_rgb_value(int(float(options.limit) / 100.0 * 255)) - - stick.set_error_reporting(False) - - #Actions here work on all BlinkSticks - for stick in sticks: - if options.infoblock1: - stick.set_info_block1(options.infoblock1) - - if options.infoblock2: - 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": - stick.set_mode(int(options.mode)) - else: - print("Error: Invalid mode parameter value") - - elif options.led_count: - led_count = int(options.led_count) - - if led_count > 0 and led_count <= 32: - stick.set_led_count(led_count) - else: - print("Error: Invalid led-count parameter value") - - elif options.info: - print_info(stick) - elif options.color or len(args) > 0: - if options.color: - color = options.color - else: - color = args[0] - - # determine color - fargs = {} - if color.startswith('#'): - fargs['hex'] = color - elif color == "random": - fargs['name'] = 'random' - elif color == "off": - 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 - except: - fargs['name'] = color - else: - fargs['name'] = color - - 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) - elif options.pulse: - func = stick.pulse - fargs['duration'] = options.duration - fargs['repeats'] = int(options.repeats) - elif options.morph: - func = stick.morph - fargs['duration'] = options.duration - - func(**fargs) - - - else: - parser.print_help() - return 0 - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) 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/blinkstick/blinkstick.py b/blinkstick/blinkstick.py deleted file mode 100644 index bc2040b..0000000 --- a/blinkstick/blinkstick.py +++ /dev/null @@ -1,1642 +0,0 @@ -from ._version import __version__ -import time -import sys -import re -try: - from collections.abc import Callable -except ImportError: - from collections import Callable - -if sys.platform == "win32": - import pywinusb.hid as hid - from ctypes import * -else: - import usb.core - import usb.util - -from random import randint - -""" -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. - - Code examples on how you can use this class are available here: - - 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 - BLINKSTICK_PRO = 2 - BLINKSTICK_STRIP = 3 - BLINKSTICK_SQUARE = 4 - BLINKSTICK_NANO = 5 - BLINKSTICK_FLEX = 6 - - inverse = False - error_reporting = True - max_rgb_value = 255 - - 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 - """ - 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.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.:: - - BSnnnnnn-1.0 - || | | |- Software minor version - || | |--- Software major version - || |-------- Denotes sequential number - ||----------- Denotes BlinkStick device - - Software version defines the capabilities of the device - - @rtype: str - @return: Serial number of the device - """ - if sys.platform == "win32": - return self.device.serial_number - else: - return self._usb_get_string(self.device, 3) - - def get_manufacturer(self): - """ - Get the manufacturer of the device - - @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) - - def get_variant(self): - """ - Get the product variant of the device. - - @rtype: int - @return: BlinkStick.UNKNOWN, BlinkStick.BLINKSTICK, BlinkStick.BLINKSTICK_PRO and etc - """ - - serial = self.get_serial() - major = serial[-3] - minor = serial[-1] - - if sys.platform == "win32": - version_attribute = self.device.version_number - else: - version_attribute = self.device.bcdDevice - - 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 - - def get_variant_string(self): - """ - Get the product variant of the device 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" - - def get_description(self): - """ - Get the description of the device - - @rtype: str - @return: Device description - """ - if sys.platform == "win32": - return self.device.product_name - else: - return self._usb_get_string(self.device, 2) - - 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 - """ - 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 - - @type red: int - @param red: Red color intensity 0 is off, 255 is full red intensity - @type green: int - @param green: Green color intensity 0 is off, 255 is full green intensity - @type blue: int - @param blue: Blue color intensity 0 is off, 255 is full blue intensity - @type name: str - @param name: Use CSS color name as defined here: U{http://www.w3.org/TR/css3-color/} - @type hex: str - @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) - - r = int(round(red, 3)) - g = int(round(green, 3)) - b = int(round(blue, 3)) - - if self.inverse: - r, g, b = 255 - r, 255 - g, 255 - b - - if index == 0 and channel == 0: - control_string = bytes(bytearray([0, r, g, b])) - report_id = 0x0001 - else: - control_string = bytes(bytearray([5, channel, index, r, g, b])) - report_id = 0x0005 - - if self.error_reporting: - self._usb_ctrl_transfer(0x20, 0x9, report_id, 0, control_string) - else: - try: - self._usb_ctrl_transfer(0x20, 0x9, report_id, 0, control_string) - except Exception: - pass - - def _determine_rgb(self, red=0, green=0, blue=0, name=None, hex=None): - - try: - if name: - # Special case for name="random" - if name == "random": - red = randint(0, 255) - green = randint(0, 255) - blue = randint(0, 255) - else: - red, green, blue = self._name_to_rgb(name) - elif hex: - red, green, blue = self._hex_to_rgb(hex) - except ValueError: - red = green = blue = 0 - - 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 - - return red, green, blue - - def _get_color_rgb(self, index=0): - if index == 0: - device_bytes = self._usb_ctrl_transfer(0x80 | 0x20, 0x1, 0x0001, 0, 33) - if self.inverse: - 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: - data = self.get_led_data((index + 1) * 3) - - 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) - return '#%02x%02x%02x' % (r, g, b) - - def get_color(self, index=0, color_format='rgb'): - """ - Get the current device 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 - - >>> 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_format='hex') # Get color as hex string - '#ff0000' - - @type index: int - @param index: the index of the LED - @type color_format: str - @param color_format: "rgb" or "hex". Defaults to "rgb". - - @rtype: (int, int, int) or str - @return: Either 3-tuple for R, G and B values, or hex string - """ - - # 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): - return get_color_func(index) - else: - # Should never get here, as we should always default to self._get_color_rgb - raise BlinkStickException("Could not return current color in format %s" % color_format) - - def _determine_report_id(self, led_count): - report_id = 9 - max_leds = 64 - - if led_count <= 8 * 3: - max_leds = 8 - report_id = 6 - elif led_count <= 16 * 3: - max_leds = 16 - report_id = 7 - elif led_count <= 32 * 3: - max_leds = 32 - report_id = 8 - elif led_count <= 64 * 3: - max_leds = 64 - report_id = 9 - - return report_id, max_leds - - def set_led_data(self, channel, data): - """ - Send LED data frame. - - @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 - """ - - report_id, max_leds = self._determine_report_id(len(data)) - - report = [0, channel] - - for i in range(0, max_leds * 3): - if len(data) > i: - report.append(data[i]) - else: - report.append(0) - - self._usb_ctrl_transfer(0x20, 0x9, report_id, 0, bytes(bytearray(report))) - - def get_led_data(self, count): - """ - Get LED data frame on the device. - - @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 - """ - - 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) - - return device_bytes[2: 2 + count * 3] - - def set_mode(self, mode): - """ - Set device 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 - - 2 - control up to 64 WS2812 individual LEDs per each R, G and B channel - - You can find out more about BlinkStick Pro modes: - - U{http://www.blinkstick.com/help/tutorials/blinkstick-pro-modes} - - @type mode: int - @param mode: Device mode to set - """ - control_string = bytes(bytearray([4, mode])) - - self._usb_ctrl_transfer(0x20, 0x9, 0x0004, 0, control_string) - - def get_mode(self): - """ - Get BlinkStick Pro mode. 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 - - 2 - control up to 64 WS2812 individual LEDs per each R, G and B channel - - You can find out more about BlinkStick Pro modes: - - U{http://www.blinkstick.com/help/tutorials/blinkstick-pro-modes} - - @rtype: int - @return: Device mode - """ - - device_bytes = self._usb_ctrl_transfer(0x80 | 0x20, 0x1, 0x0004, 0, 2) - - if len(device_bytes) >= 2: - return device_bytes[1] - else: - return -1 - - def set_led_count(self, count): - """ - Set number of LEDs for supported devices - - @type count: int - @param count: number of LEDs to control - """ - control_string = bytes(bytearray([0x81, count])) - - self._usb_ctrl_transfer(0x20, 0x9, 0x81, 0, control_string) - - - def get_led_count(self): - """ - Get number of LEDs for supported devices - - @rtype: int - @return: Number of LEDs - """ - - device_bytes = self._usb_ctrl_transfer(0x80 | 0x20, 0x1, 0x81, 0, 2) - - if len(device_bytes) >= 2: - return device_bytes[1] - else: - return -1 - - def get_info_block1(self): - """ - Get the infoblock1 of the device. - - 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 - a serial number. - - @rtype: str - @return: InfoBlock1 currently stored on the device - """ - - device_bytes = self._usb_ctrl_transfer(0x80 | 0x20, 0x1, 0x0002, 0, 33) - result = "" - for i in device_bytes[1:]: - if i == 0: - break - result += chr(i) - return result - - def get_info_block2(self): - """ - Get the infoblock2 of the device. - - This is a 32 byte array that can contain any data. - - @rtype: str - @return: InfoBlock2 currently stored on the device - """ - device_bytes = self._usb_ctrl_transfer(0x80 | 0x20, 0x1, 0x0003, 0, 33) - result = "" - for i in device_bytes[1:]: - if i == 0: - break - result += chr(i) - return result - - def _data_to_message(self, data): - """ - 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. - """ - bytes = [1] - for c in data: - bytes.append(ord(c)) - - for i in range(32 - len(data)): - bytes.append(0) - - return bytes - - def set_info_block1(self, data): - """ - Sets the infoblock1 with specified string. - - It fills the rest of 32 bytes with zeros. - - @type data: str - @param data: InfoBlock1 for the device to set - """ - self._usb_ctrl_transfer(0x20, 0x9, 0x0002, 0, self._data_to_message(data)) - - def set_info_block2(self, data): - """ - Sets the infoblock2 with specified string. - - It fills the rest of 32 bytes with zeros. - - @type data: str - @param data: InfoBlock2 for the device to set - """ - self._usb_ctrl_transfer(0x20, 0x9, 0x0003, 0, self._data_to_message(data)) - - def set_random_color(self): - """ - Sets random color to the device. - """ - self.set_color(name="random") - - def turn_off(self): - """ - 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): - """ - Morph to the specified color from black and back again. - - @type red: int - @param red: Red color intensity 0 is off, 255 is full red intensity - @type green: int - @param green: Green color intensity 0 is off, 255 is full green intensity - @type blue: int - @param blue: Blue color intensity 0 is off, 255 is full blue intensity - @type name: str - @param name: Use CSS color name as defined here: U{http://www.w3.org/TR/css3-color/} - @type hex: str - @param hex: Specify color using hexadecimal color value e.g. '#FF3366' - @type repeats: int - @param repeats: Number of times to pulse the LED - @type duration: int - @param duration: Duration for pulse in milliseconds - @type steps: int - @param steps: Number of gradient steps - """ - 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=0, index=0, red=0, green=0, blue=0, name=None, hex=None, repeats=1, delay=500): - """ - Blink the specified color. - - @type red: int - @param red: Red color intensity 0 is off, 255 is full red intensity - @type green: int - @param green: Green color intensity 0 is off, 255 is full green intensity - @type blue: int - @param blue: Blue color intensity 0 is off, 255 is full blue intensity - @type name: str - @param name: Use CSS color name as defined here: U{http://www.w3.org/TR/css3-color/} - @type hex: str - @param hex: Specify color using hexadecimal color value e.g. '#FF3366' - @type repeats: int - @param repeats: Number of times to pulse the LED - @type delay: int - @param delay: time in milliseconds to light LED for, and also between blinks - """ - ms_delay = float(delay) / float(1000) - 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) - 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): - """ - Morph to the specified color. - - @type red: int - @param red: Red color intensity 0 is off, 255 is full red intensity - @type green: int - @param green: Green color intensity 0 is off, 255 is full green intensity - @type blue: int - @param blue: Blue color intensity 0 is off, 255 is full blue intensity - @type name: str - @param name: Use CSS color name as defined here: U{http://www.w3.org/TR/css3-color/} - @type hex: str - @param hex: Specify color using hexadecimal color value e.g. '#FF3366' - @type duration: int - @param duration: Duration for morph in milliseconds - @type steps: int - @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) - # 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_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 - g_start = 0 - b_start = 0 - - gradient = [] - - steps += 1 - for n in range(1, steps): - d = 1.0 * n / steps - r = (r_start * (1 - d)) + (r_end * d) - g = (g_start * (1 - d)) + (g_end * d) - b = (b_start * (1 - d)) + (b_end * d) - - gradient.append((r, g, b)) - - ms_delay = float(duration) / float(1000 * steps) - - 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) - time.sleep(ms_delay) - - self.set_color(channel=channel, index=index, red=r_end, green=g_end, blue=b_end) - - def open_device(self, d): - """Open device. - @param d: Device to open - """ - 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 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. - - @rtype: bool - @return: True if inverse mode, otherwise false - """ - return self.inverse - - 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. - - @type value: bool - @param value: True/False to set the inverse mode - """ - self.inverse = value - - def set_max_rgb_value(self, value): - """ - Set RGB color limit. {set_color} function will automatically remap - the values to maximum supplied. - - @type value: int - @param value: 0..255 maximum value for each R, G and B color - """ - self.max_rgb_value = value - - def get_max_rgb_value(self, max_rgb_value): - """ - Get RGB color limit. {set_color} function will automatically remap - the values to maximum set. - - @rtype: int - @return: 0..255 maximum value for each R, G and B color - """ - 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' - """ - 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 - - 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 = self._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 = self.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 - 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 self._hex_to_rgb(self._name_to_hex(name)) - -class BlinkStickPro(object): - """ - BlinkStickPro class is specifically designed to control the individually - addressable LEDs connected to the device. 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} - """ - - def __init__(self, r_led_count=0, g_led_count=0, b_led_count=0, delay=0.002, max_rgb_value=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, index, r, g, b, remap_values=True): - """ - 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 - """ - - 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, index): - """ - 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): - """ - 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): - """ - Set all pixels to black in on the device. - """ - self.clear() - self.send_data_all() - - def connect(self, serial=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): - """ - 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 - """ - 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): - """ - 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 device 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() - - """ - - 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): - """ - 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, y, r, g, b, remap_values=True): - """ - 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, y): - return y * self.cols + x - - def get_color(self, x, y): - """ - 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=False): - """ - 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=False): - """ - 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=False): - """ - 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=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, y, n, r, g, b): - """ - 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, y1, x2, y2, r, g, b): - """ - 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, y1, x2, y2, r, g, b): - """ - 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): - """ - 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): - """ - 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_blicksticks(find_all=True): - 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(): - """ - Find all attached BlinkStick devices. - - @rtype: BlinkStick[] - @return: a list of BlinkStick objects or None if no devices found - """ - result = [] - for d in _find_blicksticks(): - result.extend([BlinkStick(device=d)]) - - return result - - -def find_first(): - """ - Find first attached BlinkStick. - - @rtype: BlinkStick - @return: BlinkStick object or None if no devices are found - """ - d = _find_blicksticks(find_all=False) - - if d: - return BlinkStick(device=d) - - -def find_by_serial(serial=None): - """ - Find BlinkStick device 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)) - - if devices: - 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__ - diff --git a/examples/random_color.py b/examples/random_color.py new file mode 100644 index 0000000..19f8d41 --- /dev/null +++ b/examples/random_color.py @@ -0,0 +1,25 @@ +from blinkstick import find_first +from blinkstick.exceptions import BlinkStickException + +bs = 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") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4723486 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[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.optional-dependencies] +dev = ["black", "isort", "mypy"] +test = ["coverage", "pytest", "pytest-cov", "pytest-mock"] + +[project.scripts] +blinkstick = "scripts.main: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] + +[tool.black] +line-length = 88 +target-version = ['py39'] + +[tool.isort] +profile = "black" \ No newline at end of file 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/blinkstick/__init__.py b/src/blinkstick/__init__.py new file mode 100644 index 0000000..7053da9 --- /dev/null +++ b/src/blinkstick/__init__.py @@ -0,0 +1,12 @@ +from importlib.metadata import version, PackageNotFoundError + +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 .enums import BlinkStickVariant +from .exceptions import BlinkStickException + +try: + __version__ = version("blinkstick") +except PackageNotFoundError: + __version__ = "BlinkStick package not installed" 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..1f1e028 --- /dev/null +++ b/src/blinkstick/backends/base.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + +from typing import TypeVar, Generic + +from blinkstick.devices import BlinkStickDevice + +T = TypeVar("T") + + +class BaseBackend(ABC, Generic[T]): + + blinkstick_device: BlinkStickDevice[T] + + def __init__(self, device: BlinkStickDevice[T]): + self.blinkstick_device = device + + @abstractmethod + def _refresh_attached_blinkstick_device(self): + raise NotImplementedError + + @staticmethod + @abstractmethod + def get_attached_blinkstick_devices( + find_all: bool = True, + ) -> list[BlinkStickDevice[T]]: + raise NotImplementedError + + @staticmethod + @abstractmethod + def find_by_serial(serial: str) -> list[BlinkStickDevice[T]] | None: + raise NotImplementedError + + @abstractmethod + def control_transfer( + self, + bmRequestType: int, + bRequest: int, + wValue: int, + wIndex: int, + data_or_wLength: bytes | int, + ): + raise NotImplementedError + + def get_serial(self) -> str: + return self.blinkstick_device.serial_details.serial + + def get_manufacturer(self) -> str: + return self.blinkstick_device.manufacturer + + def get_version_attribute(self) -> int: + return self.blinkstick_device.version_attribute + + def get_description(self): + return self.blinkstick_device.description + + def get_variant(self): + return self.blinkstick_device.variant diff --git a/src/blinkstick/backends/unix_like.py b/src/blinkstick/backends/unix_like.py new file mode 100644 index 0000000..052d9f1 --- /dev/null +++ b/src/blinkstick/backends/unix_like.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +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.devices import BlinkStickDevice +from blinkstick.exceptions import BlinkStickException +from blinkstick.models import SerialDetails + + +class UnixLikeBackend(BaseBackend[usb.core.Device]): + def __init__(self, device: BlinkStickDevice[usb.core.Device]): + super().__init__(device=device) + if device: + self.open_device() + + def open_device(self) -> None: + if self.blinkstick_device is None: + raise BlinkStickException("Could not find BlinkStick...") + + if self.blinkstick_device.raw_device.is_kernel_driver_active(0): + try: + 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.blinkstick_device: + return False + if devices := self.find_by_serial(self.blinkstick_device.serial_details.serial): + self.blinkstick_device = devices[0] + self.open_device() + return True + + @staticmethod + def get_attached_blinkstick_devices( + find_all: bool = True, + ) -> 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 + BlinkStickDevice( + raw_device=device, + 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)), + ) + for device in raw_devices + ] + + @staticmethod + 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_details.serial == serial: + return [d] + + return None + + def control_transfer( + self, + bmRequestType: int, + bRequest: int, + wValue: int, + wIndex: int, + data_or_wLength: bytes | int, + ): + try: + return self.blinkstick_device.raw_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_attached_blinkstick_device(): + return self.blinkstick_device.raw_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.blinkstick_device.serial_details.serial + ) + ) diff --git a/src/blinkstick/backends/win32.py b/src/blinkstick/backends/win32.py new file mode 100644 index 0000000..c0e9c5c --- /dev/null +++ b/src/blinkstick/backends/win32.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import sys +from ctypes import * + +from pywinusb import hid # type: ignore + +from blinkstick.constants import VENDOR_ID, PRODUCT_ID +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]): + reports: list[hid.core.HidReport] + + def __init__(self, device: BlinkStickDevice[hid.HidDevice]): + super().__init__(device=device) + if device: + 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[BlinkStickDevice[hid.HidDevice]] | None: + found_devices = Win32Backend.get_attached_blinkstick_devices() + for d in found_devices: + 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.blinkstick_device: + return False + 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() + return True + + @staticmethod + def get_attached_blinkstick_devices( + find_all: bool = True, + ) -> list[BlinkStickDevice[hid.HidDevice]]: + devices = hid.HidDeviceFilter( + vendor_id=VENDOR_ID, product_id=PRODUCT_ID + ).get_devices() + + blinkstick_devices = [ + BlinkStickDevice( + raw_device=device, + serial_details=SerialDetails(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 blinkstick_devices + + return blinkstick_devices[:1] + + 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.blinkstick_device.raw_device.send_feature_report(data): + if self._refresh_attached_blinkstick_device(): + self.blinkstick_device.raw_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() diff --git a/src/blinkstick/clients/__init__.py b/src/blinkstick/clients/__init__.py new file mode 100644 index 0000000..a6c3e84 --- /dev/null +++ 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 new file mode 100644 index 0000000..14fedf5 --- /dev/null +++ b/src/blinkstick/clients/blinkstick.py @@ -0,0 +1,719 @@ +from __future__ import annotations + +import sys +import time +import warnings +from typing import Callable + +from blinkstick.colors import ( + hex_to_rgb, + name_to_rgb, + remap_rgb_value, + remap_rgb_value_reverse, + ColorFormat, +) +from blinkstick.enums import BlinkStickVariant +from blinkstick.devices import BlinkStickDevice +from blinkstick.utilities import string_to_info_block_data + +if sys.platform == "win32": + from blinkstick.backends.win32 import Win32Backend as USBBackend +else: + from blinkstick.backends.unix_like import UnixLikeBackend as USBBackend + +from random import randint + +""" +Main module to control BlinkStick and BlinkStick Pro devices. +""" + + +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 + about BlinkStick Pro backend modes. + + Code examples on how you can use this class are available here: + + U{https://github.com/arvydas/blinkstick-python/wiki} + """ + + inverse: bool + error_reporting = True + max_rgb_value: int + + backend: USBBackend + bs_serial: str + + def __init__( + self, device: BlinkStickDevice | None = None, error_reporting: bool = True + ): + """ + Constructor for the class. + + @type error_reporting: Boolean + @param error_reporting: display errors if they occur during communication with the backend + """ + self.error_reporting = error_reporting + self.max_rgb_value = 255 + self.inverse = False + + if device: + self.backend = USBBackend(device) + self.bs_serial = self.get_serial() + + def get_serial(self) -> str: + """ + Returns the serial number of backend.:: + + BSnnnnnn-1.0 + || | | |- Software minor version + || | |--- Software major version + || |-------- Denotes sequential number + ||----------- Denotes BlinkStick backend + + Software version defines the capabilities of the backend + + @rtype: str + @return: Serial number of the backend + """ + return self.backend.get_serial() + + def get_manufacturer(self) -> str: + """ + Get the manufacturer of the backend + + @rtype: str + @return: Device manufacturer's name + """ + return self.backend.get_manufacturer() + + def get_variant(self) -> BlinkStickVariant: + """ + Get the product variant of the backend. + + @rtype: int + @return: BlinkStickVariant.UNKNOWN, BlinkStickVariant.BLINKSTICK, BlinkStickVariant.BLINKSTICK_PRO and etc + """ + + return self.backend.get_variant() + + def get_variant_string(self) -> str: + """ + Get the product variant of the backend as string. + + @rtype: string + @return: "BlinkStick", "BlinkStick Pro", etc + """ + return self.get_variant().description + + def get_description(self) -> str: + """ + Get the description of the backend + + @rtype: str + @return: Device description + """ + return self.backend.get_description() + + def set_error_reporting(self, error_reporting: bool) -> None: + """ + Enable or disable error reporting + + @type error_reporting: Boolean + @param error_reporting: display errors if they occur during communication with the backend + """ + 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: + """ + 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 + @param green: Green color intensity 0 is off, 255 is full green intensity + @type blue: int + @param blue: Blue color intensity 0 is off, 255 is full blue intensity + @type name: str + @param name: Use CSS color name as defined here: U{http://www.w3.org/TR/css3-color/} + @type hex: str + @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 + ) + + r = int(round(red, 3)) + g = int(round(green, 3)) + b = int(round(blue, 3)) + + if self.inverse: + r, g, b = 255 - r, 255 - g, 255 - b + + if index == 0 and channel == 0: + control_string = bytes(bytearray([0, r, g, b])) + report_id = 0x0001 + else: + control_string = bytes(bytearray([5, channel, index, r, g, b])) + report_id = 0x0005 + + if self.error_reporting: + self.backend.control_transfer(0x20, 0x9, report_id, 0, control_string) + else: + try: + self.backend.control_transfer(0x20, 0x9, report_id, 0, control_string) + 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]: + + try: + if name: + # Special case for name="random" + if name == "random": + red = randint(0, 255) + green = randint(0, 255) + blue = randint(0, 255) + else: + red, green, blue = name_to_rgb(name) + elif hex: + red, green, blue = hex_to_rgb(hex) + except ValueError: + red = green = blue = 0 + + 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 + + return red, green, blue + + 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: + 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: + data = self.get_led_data((index + 1) * 3) + + return data[index * 3 + 1], data[index * 3], data[index * 3 + 2] + + 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 = None, + ) -> tuple[int, int, int] | str: + """ + 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 backend color as hexadecimal string + + 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. + + @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 + + color_funcs: dict[ColorFormat, Callable[[int], tuple[int, int, int] | str]] = { + ColorFormat.RGB: self._get_color_rgb, + ColorFormat.HEX: self._get_color_hex, + } + + 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 + max_leds = 64 + + if led_count <= 8 * 3: + max_leds = 8 + report_id = 6 + elif led_count <= 16 * 3: + max_leds = 16 + report_id = 7 + elif led_count <= 32 * 3: + max_leds = 32 + report_id = 8 + elif led_count <= 64 * 3: + max_leds = 64 + report_id = 9 + + return report_id, max_leds + + def set_led_data(self, channel: int, data: list[int]) -> None: + """ + Send LED data frame. + + @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 color_mode + """ + + report_id, max_leds = self._determine_report_id(len(data)) + + report = [0, channel] + + for i in range(0, max_leds * 3): + if len(data) > i: + report.append(data[i]) + else: + report.append(0) + + self.backend.control_transfer(0x20, 0x9, report_id, 0, bytes(bytearray(report))) + + def get_led_data(self, count: int) -> list[int]: + """ + 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 backend + """ + + 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 + ) + + return device_bytes[2 : 2 + count * 3] + + def set_mode(self, mode: int) -> None: + """ + 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 + - 2 - control up to 64 WS2812 individual LEDs per each R, G and B channel + + You can find out more about BlinkStick Pro modes: + + U{http://www.blinkstick.com/help/tutorials/blinkstick-pro-modes} + + @type mode: int + @param mode: Device mode to set + """ + control_string = bytes(bytearray([4, mode])) + + self.backend.control_transfer(0x20, 0x9, 0x0004, 0, control_string) + + def get_mode(self) -> int: + """ + Get BlinkStick Pro mode. 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 + - 2 - control up to 64 WS2812 individual LEDs per each R, G and B channel + + You can find out more about BlinkStick Pro modes: + + U{http://www.blinkstick.com/help/tutorials/blinkstick-pro-modes} + + @rtype: int + @return: Device mode + """ + + device_bytes = self.backend.control_transfer(0x80 | 0x20, 0x1, 0x0004, 0, 2) + + if len(device_bytes) >= 2: + return device_bytes[1] + else: + return -1 + + def set_led_count(self, count: int) -> None: + """ + Set number of LEDs for supported devices + + @type count: int + @param count: number of LEDs to control + """ + control_string = bytes(bytearray([0x81, count])) + + self.backend.control_transfer(0x20, 0x9, 0x81, 0, control_string) + + def get_led_count(self) -> int: + """ + Get number of LEDs for supported devices + + @rtype: int + @return: Number of LEDs + """ + + device_bytes = self.backend.control_transfer(0x80 | 0x20, 0x1, 0x81, 0, 2) + + if len(device_bytes) >= 2: + return device_bytes[1] + else: + return -1 + + def get_info_block1(self) -> str: + """ + 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 backend making it easier to identify rather than + a serial number. + + @rtype: str + @return: InfoBlock1 currently stored on the backend + """ + + device_bytes = self.backend.control_transfer(0x80 | 0x20, 0x1, 0x0002, 0, 33) + result = "" + for i in device_bytes[1:]: + if i == 0: + break + result += chr(i) + return result + + def get_info_block2(self) -> str: + """ + 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 backend + """ + device_bytes = self.backend.control_transfer(0x80 | 0x20, 0x1, 0x0003, 0, 33) + result = "" + for i in device_bytes[1:]: + if i == 0: + break + result += chr(i) + return result + + def set_info_block1(self, data: str) -> None: + """ + Sets the infoblock1 with specified string. + + It fills the rest of 32 bytes with zeros. + + @type data: str + @param data: InfoBlock1 for the backend to set + """ + self.backend.control_transfer( + 0x20, 0x9, 0x0002, 0, string_to_info_block_data(data) + ) + + def set_info_block2(self, data: str) -> None: + """ + Sets the infoblock2 with specified string. + + It fills the rest of 32 bytes with zeros. + + @type data: str + @param data: InfoBlock2 for the backend to set + """ + self.backend.control_transfer( + 0x20, 0x9, 0x0003, 0, string_to_info_block_data(data) + ) + + def set_random_color(self) -> None: + """ + Sets random color to the backend. + """ + self.set_color(name="random") + + def turn_off(self) -> None: + """ + Turns off LED. + """ + 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: + """ + 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 + @param green: Green color intensity 0 is off, 255 is full green intensity + @type blue: int + @param blue: Blue color intensity 0 is off, 255 is full blue intensity + @type name: str + @param name: Use CSS color name as defined here: U{http://www.w3.org/TR/css3-color/} + @type hex: str + @param hex: Specify color using hexadecimal color value e.g. '#FF3366' + @type repeats: int + @param repeats: Number of times to pulse the LED + @type duration: int + @param duration: Duration for pulse in milliseconds + @type steps: int + @param steps: Number of gradient steps + """ + 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: + """ + 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 + @param green: Green color intensity 0 is off, 255 is full green intensity + @type blue: int + @param blue: Blue color intensity 0 is off, 255 is full blue intensity + @type name: str + @param name: Use CSS color name as defined here: U{http://www.w3.org/TR/css3-color/} + @type hex: str + @param hex: Specify color using hexadecimal color value e.g. '#FF3366' + @type repeats: int + @param repeats: Number of times to pulse the LED + @type delay: int + @param delay: time in milliseconds to light LED for, and also between blinks + """ + ms_delay = float(delay) / float(1000) + 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, + ) + 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: + """ + 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 + @param green: Green color intensity 0 is off, 255 is full green intensity + @type blue: int + @param blue: Blue color intensity 0 is off, 255 is full blue intensity + @type name: str + @param name: Use CSS color name as defined here: U{http://www.w3.org/TR/css3-color/} + @type hex: str + @param hex: Specify color using hexadecimal color value e.g. '#FF3366' + @type duration: int + @param duration: Duration for morph in milliseconds + @type steps: int + @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 + ) + # 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_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 + g_start = 0 + b_start = 0 + + gradient = [] + + steps += 1 + for n in range(1, steps): + d = 1.0 * n / steps + r = (r_start * (1 - d)) + (r_end * d) + g = (g_start * (1 - d)) + (g_end * d) + b = (b_start * (1 - d)) + (b_end * d) + + gradient.append((r, g, b)) + + ms_delay = float(duration) / float(1000 * steps) + + 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 = map(int, grad) + + 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) + + 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. + + @rtype: bool + @return: True if inverse mode, otherwise false + """ + return self.inverse + + 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. + + @type value: bool + @param value: True/False to set the inverse mode + """ + if type(value) is str: + value = value.lower() == "true" # type: ignore + self.inverse = bool(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. + + @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) -> int: + """ + Get RGB color limit. {set_color} function will automatically remap + the values to maximum set. + + @rtype: int + @return: 0..255 maximum value for each R, G and B color + """ + return self.max_rgb_value 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/colors.py b/src/blinkstick/colors.py new file mode 100644 index 0000000..5e1d4dd --- /dev/null +++ b/src/blinkstick/colors.py @@ -0,0 +1,326 @@ +import re +from enum import Enum, auto + +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.") + + +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 + + +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 + 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. + + """ + 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_match.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 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) + + +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)) + + +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), + ) diff --git a/src/blinkstick/constants.py b/src/blinkstick/constants.py new file mode 100644 index 0000000..4c6e431 --- /dev/null +++ b/src/blinkstick/constants.py @@ -0,0 +1,2 @@ +VENDOR_ID = 0x20A0 +PRODUCT_ID = 0x41E5 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/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"] diff --git a/src/blinkstick/devices/device.py b/src/blinkstick/devices/device.py new file mode 100644 index 0000000..dc13792 --- /dev/null +++ b/src/blinkstick/devices/device.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass, field +from typing import Generic, TypeVar + +from blinkstick.enums import BlinkStickVariant +from blinkstick.models import SerialDetails + +T = TypeVar("T") + + +@dataclass +class BlinkStickDevice(Generic[T]): + """A BlinkStick device representation""" + + raw_device: T + serial_details: SerialDetails + manufacturer: str + version_attribute: int + description: str + major_version: int = field(init=False) + variant: BlinkStickVariant = field(init=False) + + def __post_init__(self): + self.variant = BlinkStickVariant.from_version_attrs( + major_version=self.serial_details.major_version, + version_attribute=self.version_attribute, + ) diff --git a/src/blinkstick/enums.py b/src/blinkstick/enums.py new file mode 100644 index 0000000..fcc3e2e --- /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 from_version_attrs( + 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/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/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))) diff --git a/src/blinkstick/py.typed b/src/blinkstick/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/blinkstick/utilities.py b/src/blinkstick/utilities.py new file mode 100644 index 0000000..a828fc1 --- /dev/null +++ b/src/blinkstick/utilities.py @@ -0,0 +1,17 @@ +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. + """ + info_block_data = data[:31] + byte_array = bytearray([1] + [0] * 31) + + for i, c in enumerate(info_block_data): + byte_array[i + 1] = ord(c) + + return bytes(byte_array) diff --git a/src/scripts/main.py b/src/scripts/main.py new file mode 100644 index 0000000..2240b57 --- /dev/null +++ b/src/scripts/main.py @@ -0,0 +1,399 @@ +#!/usr/bin/env python3 + +from optparse import OptionParser, IndentedHelpFormatter, OptionGroup +import textwrap +import sys +import logging + +from blinkstick import ( + find_all, + find_by_serial, + get_blinkstick_package_version, + BlinkStickVariant, +) + +logging.basicConfig() + + +class IndentedHelpFormatterWithNL(IndentedHelpFormatter): + def format_description(self, description): + 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") + formatted_bits = [ + textwrap.fill( + bit, desc_width, initial_indent=indent, subsequent_indent=indent + ) + for bit in bits + ] + result = "\n".join(formatted_bits) + "\n" + return result + + def format_option(self, option): + # The help for each option consists of two parts: + # * the opt strings and metavars + # eg. ("-x", or "-fFILENAME, --file=FILENAME") + # * the user-supplied help string + # eg. ("turn on expert mode", "read data from FILENAME") + # + # If possible, we write both of these on the same line: + # -x turn on expert mode + # + # But if the opt string list is too long, we put the help + # string on a second line, indented to the same column it would + # start in if it fit on the first line. + # -fFILENAME, --file=FILENAME + # read data from FILENAME + result = [] + opts = self.option_strings[option] + opt_width = self.help_position - self.current_indent - 2 + + if len(opts) > opt_width: + opts = "%*s%s\n" % (self.current_indent, "", opts) + indent_first = self.help_position + else: # start help on same line as opts + opts = "%*s%-*s " % (self.current_indent, "", opt_width, opts) + indent_first = 0 + + result.append(opts) + + if option.help: + help_text = self.expand_default(option) + # Everything is the same up through here + help_lines = [] + 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:]] + ) + 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), + ) + ) + + +def print_info(stick): + 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: + count = stick.get_led_count() + except: + count = -1 + + if count == -1: + count = "Error" + print(" LED conf: {0}".format(count)) + print(" Info Block 1: {0}".format(stick.get_info_block1())) + print(" Info Block 2: {0}".format(stick.get_info_block2())) + + +def main(): + global options + global sticks + + 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( + "-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.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( + "--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( + "--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.", + ) + + parser.add_option_group(group) + + 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( + "--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( + "--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( + "--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.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-infoblock2", + dest="infoblock2", + help="Set the second info block for the backend.", + ) + + parser.add_option_group(group) + + 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.", + ) + + parser.add_option_group(group) + + (options, args) = parser.parse_args() + + if options.serial is None: + sticks = find_all() + else: + sticks = [find_by_serial(options.serial)] + + if len(sticks) == 0: + print("BlinkStick with serial number " + options.backend + " not found...") + return 64 + + # 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.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" + ) + return 64 + + print("Reboot your computer for changes to take effect") + return 0 + + for stick in sticks: + if options.inverse: + stick.set_inverse(True) + + stick.set_max_rgb_value(int(float(options.limit) / 100.0 * 255)) + + stick.set_error_reporting(False) + + # Actions here work on all BlinkSticks + for stick in sticks: + if options.infoblock1: + stick.set_info_block1(options.infoblock1) + + if options.infoblock2: + 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" + ): + stick.set_mode(int(options.mode)) + else: + print("Error: Invalid mode parameter value") + + elif options.led_count: + led_count = int(options.led_count) + + if led_count > 0 and led_count <= 32: + stick.set_led_count(led_count) + else: + print("Error: Invalid led-count parameter value") + + elif options.info: + print_info(stick) + elif options.color or len(args) > 0: + if options.color: + color = options.color + else: + color = args[0] + + # determine color + fargs = {} + if color.startswith("#"): + fargs["hex"] = color + elif color == "random": + fargs["name"] = "random" + elif color == "off": + 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 + except: + fargs["name"] = color + else: + fargs["name"] = color + + 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) + elif options.pulse: + func = stick.pulse + fargs["duration"] = options.duration + fargs["repeats"] = int(options.repeats) + elif options.morph: + func = stick.morph + fargs["duration"] = options.duration + + func(**fargs) + + else: + parser.print_help() + return 0 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/clients/__init__.py b/tests/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/clients/test_blinkstick.py b/tests/clients/test_blinkstick.py new file mode 100644 index 0000000..eb7d007 --- /dev/null +++ b/tests/clients/test_blinkstick.py @@ -0,0 +1,293 @@ +from unittest.mock import MagicMock + +import pytest + +from blinkstick.colors import ColorFormat +from blinkstick.enums import BlinkStickVariant +from blinkstick.clients.blinkstick import BlinkStick +from pytest_mock import MockFixture + +from tests.conftest import make_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( + make_blinkstick, serial, version_attribute, expected_variant, expected_variant_value +): + bs = make_blinkstick() + 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 + + +@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() + bs.get_variant = MagicMock(return_value=expected_variant) + assert bs.get_variant_string() == expected_name + + +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)) + 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, 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") + 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, 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)) + 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 + + +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 diff --git a/tests/clients/test_blinkstick_matrix.py b/tests/clients/test_blinkstick_matrix.py new file mode 100644 index 0000000..4ca747a --- /dev/null +++ b/tests/clients/test_blinkstick_matrix.py @@ -0,0 +1,6 @@ +from blinkstick.clients import BlinkStickProMatrix + + +def test_instantiate(): + bs = BlinkStickProMatrix() + assert bs is not None diff --git a/tests/clients/test_blinkstick_pro_matrix.py b/tests/clients/test_blinkstick_pro_matrix.py new file mode 100644 index 0000000..53d7820 --- /dev/null +++ b/tests/clients/test_blinkstick_pro_matrix.py @@ -0,0 +1,6 @@ +from blinkstick.clients import BlinkStickPro + + +def test_instantiate(): + bs = BlinkStickPro() + assert bs is not None 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..5d9bf63 --- /dev/null +++ b/tests/colors/test_colors.py @@ -0,0 +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, +) + + +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") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..df9b086 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,16 @@ +from typing import Callable +from unittest.mock import MagicMock + +import pytest + +from blinkstick.clients.blinkstick import BlinkStick + + +@pytest.fixture +def make_blinkstick() -> Callable[[], BlinkStick]: + def _make_blinkstick() -> BlinkStick: + bs = BlinkStick() + bs.backend = MagicMock() + return bs + + return _make_blinkstick 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, + ) 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") diff --git a/tests/utilities/__init__.py b/tests/utilities/__init__.py new file mode 100644 index 0000000..e69de29 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