pax_global_header00006660000000000000000000000064150330026570014513gustar00rootroot0000000000000052 comment=443a8369f9fc66333fb70b229ee64ea06b4dbc3c pykulersky-0.6.0/000077500000000000000000000000001503300265700137405ustar00rootroot00000000000000pykulersky-0.6.0/.coveragerc000066400000000000000000000001271503300265700160610ustar00rootroot00000000000000[run] branch = True source = pykulersky omit = pykulersky/cli.py relative_files = True pykulersky-0.6.0/.editorconfig000066400000000000000000000004751503300265700164230ustar00rootroot00000000000000# http://editorconfig.org root = true [*] indent_style = space indent_size = 4 trim_trailing_whitespace = true insert_final_newline = true charset = utf-8 end_of_line = lf [*.bat] indent_style = tab end_of_line = crlf [LICENSE] insert_final_newline = false [Makefile] indent_style = tab [*.yml] indent_size = 2 pykulersky-0.6.0/.github/000077500000000000000000000000001503300265700153005ustar00rootroot00000000000000pykulersky-0.6.0/.github/ISSUE_TEMPLATE.md000066400000000000000000000005001503300265700200000ustar00rootroot00000000000000* pyzerproc version: * Python version: * Operating System: ### Description Describe what you were trying to get done. Tell us what happened, what went wrong, and what you expected to happen. ### What I Did ``` Paste the command(s) you ran and the output. If there was a crash, please include the traceback here. ``` pykulersky-0.6.0/.github/workflows/000077500000000000000000000000001503300265700173355ustar00rootroot00000000000000pykulersky-0.6.0/.github/workflows/main.yml000066400000000000000000000024161503300265700210070ustar00rootroot00000000000000name: tests on: push: pull_request: jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: [3.9, "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements_dev.txt - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 pykulersky tests --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 pykulersky tests - name: Test with pytest run: | pytest --cov --cov-report term-missing - name: Coveralls uses: AndreMiras/coveralls-python-action@develop with: parallel: true coveralls_finish: needs: test runs-on: ubuntu-latest steps: - name: Coveralls Finished uses: AndreMiras/coveralls-python-action@develop with: parallel-finished: true pykulersky-0.6.0/.gitignore000066400000000000000000000022541503300265700157330ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # dotenv .env # virtualenv .venv venv/ ENV/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ # IDE settings .vscode/pykulersky-0.6.0/CONTRIBUTING.rst000066400000000000000000000057111503300265700164050ustar00rootroot00000000000000.. highlight:: shell ============ Contributing ============ Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. You can contribute in many ways: Types of Contributions ---------------------- Report Bugs ~~~~~~~~~~~ Report bugs at https://github.com/emlove/pykulersky/issues. If you are reporting a bug, please include: * Your operating system name and version. * Any details about your local setup that might be helpful in troubleshooting. * Detailed steps to reproduce the bug. Fix Bugs ~~~~~~~~ Look through the GitHub issues for bugs. Anything tagged with "bug" and "help wanted" is open to whoever wants to implement it. Implement Features ~~~~~~~~~~~~~~~~~~ Look through the GitHub issues for features. Anything tagged with "enhancement" and "help wanted" is open to whoever wants to implement it. Submit Feedback ~~~~~~~~~~~~~~~ The best way to send feedback is to file an issue at https://github.com/emlove/pykulersky/issues. If you are proposing a feature: * Explain in detail how it would work. * Keep the scope as narrow as possible, to make it easier to implement. * Remember that this is a volunteer-driven project, and that contributions are welcome :) Get Started! ------------ Ready to contribute? Here's how to set up `pykulersky` for local development. 1. Fork the `pykulersky` repo on GitHub. 2. Clone your fork locally:: $ git clone git@github.com:your_name_here/pykulersky.git 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: $ mkvirtualenv pykulersky $ cd pykulersky/ $ python setup.py develop 4. Create a branch for local development:: $ git checkout -b name-of-your-bugfix-or-feature Now you can make your changes locally. 5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox:: $ flake8 pykulersky tests $ python setup.py test or pytest $ tox To get flake8 and tox, just pip install them into your virtualenv. 6. Commit your changes and push your branch to GitHub:: $ git add . $ git commit -m "Your detailed description of your changes." $ git push origin name-of-your-bugfix-or-feature 7. Submit a pull request through the GitHub website. Pull Request Guidelines ----------------------- Before you submit a pull request, check that it meets these guidelines: 1. The pull request should include tests. 2. If the pull request adds functionality, the docs should be updated. Put your new functionality into a function with a docstring, and add the feature to the list in README.rst. 3. The pull request should work for current Python versions. Check https://travis-ci.com/emlove/pykulersky/pull_requests and make sure that the tests pass for all supported Python versions. Tips ---- To run a subset of tests:: $ pytest tests.test_pykulersky pykulersky-0.6.0/LICENSE000066400000000000000000000011141503300265700147420ustar00rootroot00000000000000Apache Software License 2.0 Copyright (c) 2020, Emily Love Watson Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. pykulersky-0.6.0/MANIFEST.in000066400000000000000000000003311503300265700154730ustar00rootroot00000000000000include CONTRIBUTING.rst include LICENSE include README.rst recursive-include tests * recursive-exclude * __pycache__ recursive-exclude * *.py[co] recursive-include *.rst conf.py Makefile make.bat *.jpg *.png *.gif pykulersky-0.6.0/Makefile000066400000000000000000000034601503300265700154030ustar00rootroot00000000000000.PHONY: clean clean-test clean-pyc clean-build help .DEFAULT_GOAL := help define BROWSER_PYSCRIPT import os, webbrowser, sys from urllib.request import pathname2url webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) endef export BROWSER_PYSCRIPT define PRINT_HELP_PYSCRIPT import re, sys for line in sys.stdin: match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) if match: target, help = match.groups() print("%-20s %s" % (target, help)) endef export PRINT_HELP_PYSCRIPT BROWSER := python -c "$$BROWSER_PYSCRIPT" help: @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts clean-build: ## remove build artifacts rm -fr build/ rm -fr dist/ rm -fr .eggs/ find . -name '*.egg-info' -exec rm -fr {} + find . -name '*.egg' -exec rm -f {} + clean-pyc: ## remove Python file artifacts find . -name '*.pyc' -exec rm -f {} + find . -name '*.pyo' -exec rm -f {} + find . -name '*~' -exec rm -f {} + find . -name '__pycache__' -exec rm -fr {} + clean-test: ## remove test and coverage artifacts rm -fr .tox/ rm -f .coverage rm -fr htmlcov/ rm -fr .pytest_cache lint: ## check style with flake8 flake8 pykulersky tests test: ## run tests quickly with the default Python pytest test-all: ## run tests on every Python version with tox tox coverage: ## check code coverage quickly with the default Python coverage run --source pykulersky -m pytest coverage report -m coverage html $(BROWSER) htmlcov/index.html release: dist ## package and upload a release twine upload dist/* dist: clean ## builds source and wheel package python setup.py sdist python setup.py bdist_wheel ls -l dist install: clean ## install the package to the active Python's site-packages python setup.py install pykulersky-0.6.0/README.rst000066400000000000000000000124551503300265700154360ustar00rootroot00000000000000========== pykulersky ========== .. image:: https://img.shields.io/pypi/v/pykulersky.svg :target: https://pypi.python.org/pypi/pykulersky .. image:: https://github.com/emlove/pykulersky/workflows/tests/badge.svg :target: https://github.com/emlove/pykulersky/actions .. image:: https://coveralls.io/repos/emlove/pykulersky/badge.svg :target: https://coveralls.io/r/emlove/pykulersky Library to control Brightech Kuler Sky Bluetooth LED smart lamps * Free software: Apache Software License 2.0 Features -------- * Discover nearby bluetooth devices * Get light color * Set light color Command line usage ------------------ pykulersky ships with a command line tool that exposes the features of the library. .. code-block:: console $ pykulersky discover INFO:pykulersky.discovery:Starting scan for local devices INFO:pykulersky.discovery:Discovered AA:BB:CC:00:11:22: Living Room INFO:pykulersky.discovery:Discovered AA:BB:CC:33:44:55: Bedroom INFO:pykulersky.discovery:Scan complete AA:BB:CC:00:11:22: Living Room AA:BB:CC:33:44:55: Bedroom $ pykulersky get-color AA:BB:CC:00:11:22 INFO:pykulersky.light:Connecting to AA:BB:CC:00:11:22 INFO:pykulersky.light:Got color of AA:BB:CC:00:11:22: (0, 0, 0, 255)'> 000000ff $ pykulersky set-color AA:BB:CC:00:11:22 ff000000 INFO:pykulersky.light:Connecting to AA:BB:CC:00:11:22 INFO:pykulersky.light:Changing color of AA:BB:CC:00:11:22 to #ff000000 $ pykulersky set-color AA:BB:CC:00:11:22 000000ff INFO:pykulersky.light:Connecting to AA:BB:CC:00:11:22 INFO:pykulersky.light:Changing color of AA:BB:CC:00:11:22 to #000000ff Usage ----- Discover nearby bluetooth devices .. code-block:: python import asyncio import pykulersky async def main(): lights = await pykulersky.discover(timeout=5) for light in lights: print("Address: {} Name: {}".format(light.address, light.name)) asyncio.get_event_loop().run_until_complete(main()) Turn a light on and off .. code-block:: python import asyncio import pykulersky async def main(): address = "AA:BB:CC:00:11:22" light = pykulersky.Light(address) try: await light.connect() await light.set_color(0, 0, 0, 255) await asyncio.sleep(5) await light.set_color(0, 0, 0, 0) finally: await light.disconnect() asyncio.get_event_loop().run_until_complete(main()) Change the light color .. code-block:: python import asyncio import pykulersky async def main(): address = "AA:BB:CC:00:11:22" light = pykulersky.Light(address) try: await light.connect() while True: await light.set_color(255, 0, 0, 0) # Red await asyncio.sleep(1) await light.set_color(0, 255, 0, 0) # Green await asyncio.sleep(1) await light.set_color(0, 0, 0, 255) # White await asyncio.sleep(1) finally: await light.disconnect() asyncio.get_event_loop().run_until_complete(main()) Get the light color .. code-block:: python import asyncio import pykulersky async def main(): address = "AA:BB:CC:00:11:22" light = pykulersky.Light(address) try: await light.connect() color = await light.get_color() print(color) finally: await light.disconnect() asyncio.get_event_loop().run_until_complete(main()) Changelog --------- 0.6.0 (2025-07-07) ~~~~~~~~~~~~~~~~~~ - Update to support bleak 1.0 0.5.8 (2025-01-24) ~~~~~~~~~~~~~~~~~~ - Fix missing awaits 0.5.7 (2025-01-24) ~~~~~~~~~~~~~~~~~~ - Lower noisy log priorities 0.5.6 (2025-01-24) ~~~~~~~~~~~~~~~~~~ - Allow bleak device to be passed through 0.5.5 (2023-04-07) ~~~~~~~~~~~~~~~~~~ - Support CI for bleak 0.20 0.5.4 (2022-05-03) ~~~~~~~~~~~~~~~~~~ - Unpin test dependencies 0.5.3 (2021-11-23) ~~~~~~~~~~~~~~~~~~ - Support CI for bleak 0.13 0.5.2 (2021-03-04) ~~~~~~~~~~~~~~~~~~ - Use built-in asyncmock for Python 3.8+ 0.5.1 (2020-12-23) ~~~~~~~~~~~~~~~~~~ - Include default timeout on all API calls 0.5.0 (2020-12-19) ~~~~~~~~~~~~~~~~~~ - Refactor from pygatt to bleak for async interface 0.4.0 (2020-11-11) ~~~~~~~~~~~~~~~~~~ - Rename discover method to make behavior clear 0.3.1 (2020-11-10) ~~~~~~~~~~~~~~~~~~ - Fix connected status after broken connection 0.3.0 (2020-11-10) ~~~~~~~~~~~~~~~~~~ - Add workaround for firmware bug 0.2.0 (2020-10-14) ~~~~~~~~~~~~~~~~~~ - Remove thread-based auto_reconnect 0.1.1 (2020-10-13) ~~~~~~~~~~~~~~~~~~ - Always raise PykulerskyException 0.1.0 (2020-10-09) ~~~~~~~~~~~~~~~~~~ - Initial release 0.0.1 (2020-10-09) ~~~~~~~~~~~~~~~~~~ - Fork from pyzerproc Credits ------- - Thanks to `Uri Shaked`_ for an incredible guide to `Reverse Engineering a Bluetooth Lightbulb`_. - This package was created with Cookiecutter_ and the `audreyr/cookiecutter-pypackage`_ project template. .. _`Uri Shaked`: https://medium.com/@urish .. _`Reverse Engineering a Bluetooth Lightbulb`: https://medium.com/@urish/reverse-engineering-a-bluetooth-lightbulb-56580fcb7546 .. _Cookiecutter: https://github.com/audreyr/cookiecutter .. _`audreyr/cookiecutter-pypackage`: https://github.com/audreyr/cookiecutter-pypackage pykulersky-0.6.0/pykulersky/000077500000000000000000000000001503300265700161625ustar00rootroot00000000000000pykulersky-0.6.0/pykulersky/__init__.py000066400000000000000000000004051503300265700202720ustar00rootroot00000000000000"""Top-level package for pykulersky.""" from .discovery import discover # noqa: F401 from .light import Light # noqa: F401 from .exceptions import * # noqa: F401, F403 __author__ = """Emily Love Watson""" __email__ = 'emily@emlove.me' __version__ = '0.6.0' pykulersky-0.6.0/pykulersky/cli.py000066400000000000000000000037531503300265700173130ustar00rootroot00000000000000"""Console script for pykulersky.""" import asyncio import sys from binascii import hexlify import click import logging import pykulersky @click.group() @click.option('-v', '--verbose', count=True, help="Pass once to enable pykulersky debug logging. Pass twice " "to also enable bleak debug logging.") def main(verbose): """Console script for pykulersky.""" logging.basicConfig() logging.getLogger('pykulersky').setLevel(logging.INFO) if verbose >= 1: logging.getLogger('pykulersky').setLevel(logging.DEBUG) if verbose >= 2: logging.getLogger('bleak').setLevel(logging.DEBUG) @main.command() def discover(): """Discover nearby lights""" async def run(): lights = await pykulersky.discover() if not lights: click.echo("No nearby lights found") for light in lights: click.echo(light.address) asyncio.get_event_loop().run_until_complete(run()) return 0 @main.command() @click.argument('address') def get_color(address): """Get the current color of the light""" async def run(): light = pykulersky.Light(address) try: await light.connect() color = await light.get_color() click.echo(hexlify(bytes(color))) finally: await light.disconnect() asyncio.get_event_loop().run_until_complete(run()) return 0 @main.command() @click.argument('address') @click.argument('color') def set_color(address, color): """Set the light with the given MAC address to an RRGGBBWW hex color""" async def run(): light = pykulersky.Light(address) r, g, b, w = tuple(int(color[i:i+2], 16) for i in (0, 2, 4, 6)) try: await light.connect() await light.set_color(r, g, b, w) finally: await light.disconnect() asyncio.get_event_loop().run_until_complete(run()) return 0 if __name__ == "__main__": sys.exit(main()) # pragma: no cover pykulersky-0.6.0/pykulersky/discovery.py000066400000000000000000000013651503300265700205500ustar00rootroot00000000000000"""Device discovery code""" import asyncio import logging from .light import Light from .exceptions import PykulerskyException _LOGGER = logging.getLogger(__name__) EXPECTED_SERVICES = [ "8d96a001-0002-64c2-0001-9acc4838521c", ] async def discover(timeout=10): """Returns nearby discovered lights.""" import bleak _LOGGER.info("Starting scan for local devices") lights = [] try: devices = await asyncio.wait_for( bleak.BleakScanner.discover(service_uuids=EXPECTED_SERVICES), timeout) except Exception as ex: raise PykulerskyException() from ex for device in devices: lights.append(Light(device.address, device.name)) _LOGGER.info("Scan complete") return lights pykulersky-0.6.0/pykulersky/exceptions.py000066400000000000000000000001671503300265700207210ustar00rootroot00000000000000"""pykulersky exceptions""" class PykulerskyException(Exception): """Exception class for pykulersky.""" pass pykulersky-0.6.0/pykulersky/light.py000066400000000000000000000131131503300265700176420ustar00rootroot00000000000000"""Device class""" import asyncio import logging from .exceptions import PykulerskyException _LOGGER = logging.getLogger(__name__) CHARACTERISTIC_COMMAND_COLOR = "8d96b002-0002-64c2-0001-9acc4838521c" # Default to a 5s timeout on remote calls DEFAULT_TIMEOUT = 5 class Light(): """Represents one connected light""" def __init__(self, address_or_ble_device, name=None, *args, default_timeout=DEFAULT_TIMEOUT): import bleak self._address = getattr( address_or_ble_device, 'address', address_or_ble_device ) self._name = name self._client = bleak.BleakClient(address_or_ble_device) self._default_timeout = DEFAULT_TIMEOUT @property def address(self): """Return the mac address of this light.""" return self._address @property def name(self): """Return the discovered name of this light.""" return self._name async def is_connected(self, *args, timeout=None): """Returns true if the light is connected.""" try: return await asyncio.wait_for( self._client.is_connected(), self._default_timeout if timeout is None else timeout) except asyncio.TimeoutError: return False except Exception as ex: raise PykulerskyException() from ex async def connect(self, *args, timeout=None): """Connect to this light""" _LOGGER.debug("Connecting to %s", self._address) try: await asyncio.wait_for( self._client.connect(), self._default_timeout if timeout is None else timeout) except Exception as ex: raise PykulerskyException() from ex _LOGGER.debug("Connected to %s", self._address) async def disconnect(self, *args, timeout=None): """Close the connection to the light.""" _LOGGER.debug("Disconnecting from %s", self._address) try: await asyncio.wait_for( self._client.disconnect(), self._default_timeout if timeout is None else timeout) except Exception as ex: raise PykulerskyException() from ex _LOGGER.debug("Disconnected from %s", self._address) async def _do_set_color(self, r, g, b, w): """Set the color of the light""" for value in (r, g, b, w): if not 0 <= value <= 255: raise ValueError( "Value {} is outside the valid range of 0-255") old_color = await self._do_get_color() was_on = max(old_color) > 0 _LOGGER.debug("Changing color of %s to #%02x%02x%02x%02x", self.address, r, g, b, w) if r == 0 and g == 0 and b == 0 and w == 0: color_string = b'\x32\xFF\xFF\xFF\xFF' else: if not was_on and w > 0: # These lights have a firmware bug. When turning the light on # from off, the white channel is broken until it is first set # to zero. If the light was off, first apply the color with a # zero white channel, then write the actual color we want. color_string = b'\x02' + bytes((r, g, b, 0)) await self._write(CHARACTERISTIC_COMMAND_COLOR, color_string) color_string = b'\x02' + bytes((r, g, b, w)) await self._write(CHARACTERISTIC_COMMAND_COLOR, color_string) _LOGGER.debug("Changed color of %s", self.address) async def set_color(self, r, g, b, w, *args, timeout=None): """Set the color of the light Accepts red, green, blue, and white values from 0-255 """ try: await asyncio.wait_for( self._do_set_color(r, g, b, w), self._default_timeout if timeout is None else timeout) except asyncio.TimeoutError as ex: raise PykulerskyException() from ex async def _do_get_color(self): """Get the current color of the light""" color_string = await self._read(CHARACTERISTIC_COMMAND_COLOR) on_off_value = int(color_string[0]) r = int(color_string[1]) g = int(color_string[2]) b = int(color_string[3]) w = int(color_string[4]) if on_off_value == 0x32: color = (0, 0, 0, 0) else: color = (r, g, b, w) _LOGGER.debug("Got color of %s: %s", self.address, color) return color async def get_color(self, *args, timeout=None): """Get the current color of the light""" try: return await asyncio.wait_for( self._do_get_color(), self._default_timeout if timeout is None else timeout) except asyncio.TimeoutError as ex: raise PykulerskyException() from ex async def _read(self, uuid): """Internal method to read from the device""" _LOGGER.debug("Reading from characteristic %s", uuid) try: value = await self._client.read_gatt_char(uuid) except Exception as ex: raise PykulerskyException() from ex _LOGGER.debug("Read 0x%s from characteristic %s", value.hex(), uuid) return value async def _write(self, uuid, value): """Internal method to write to the device""" _LOGGER.debug("Writing 0x%s to characteristic %s", value.hex(), uuid) try: await self._client.write_gatt_char(uuid, bytearray(value)) except Exception as ex: raise PykulerskyException() from ex _LOGGER.debug("Wrote 0x%s to characteristic %s", value.hex(), uuid) pykulersky-0.6.0/requirements_dev.txt000066400000000000000000000003261503300265700200630ustar00rootroot00000000000000pip>=19.2.3 wheel>=0.33.6 flake8>=3.7.8 tox>=3.15.0 coverage>=5.3 coveralls>=2.2.0 twine>=1.14.0 Click>=7.0 pytest>=6.2.1 pytest-asyncio>=0.14.0 pytest-env>=0.6.2 pytest-mock>=3.4.0 pytest-cov>=2.10.1 bleak>=1.0.1 pykulersky-0.6.0/setup.cfg000066400000000000000000000001341503300265700155570ustar00rootroot00000000000000[bdist_wheel] universal = 1 [aliases] # Define setup.py command aliases here test = pytest pykulersky-0.6.0/setup.py000066400000000000000000000027751503300265700154650ustar00rootroot00000000000000#!/usr/bin/env python """The setup script.""" from setuptools import setup, find_packages from pykulersky import __author__, __email__, __version__ with open('README.rst') as readme_file: readme = readme_file.read() requirements = [ 'Click>=7.0', 'bleak>=1.0.1', ] test_requirements = ['pytest>=3', ] setup( author=__author__, author_email=__email__, python_requires='>=3.6', classifiers=[ 'Development Status :: 4 - Beta', 'Intended Audience :: Developers', 'License :: OSI Approved :: Apache Software License', 'Natural Language :: English', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13', ], description=( "Library to control Brightech Kuler Sky Bluetooth LED smart lamps"), entry_points={ 'console_scripts': [ 'pykulersky=pykulersky.cli:main', ], }, install_requires=requirements, license="Apache Software License 2.0", long_description=readme, include_package_data=True, keywords='pykulersky', name='pykulersky', packages=find_packages(include=['pykulersky', 'pykulersky.*']), test_suite='tests', tests_require=test_requirements, url='https://github.com/emlove/pykulersky', version=__version__, zip_safe=False, ) pykulersky-0.6.0/tests/000077500000000000000000000000001503300265700151025ustar00rootroot00000000000000pykulersky-0.6.0/tests/__init__.py000066400000000000000000000000501503300265700172060ustar00rootroot00000000000000"""Unit test package for pykulersky.""" pykulersky-0.6.0/tests/conftest.py000066400000000000000000000014211503300265700172770ustar00rootroot00000000000000import pytest from unittest.mock import patch, AsyncMock @pytest.fixture def client_class(): with patch('bleak.BleakClient') as client_class: yield client_class @pytest.fixture def client(client_class): client = AsyncMock() client_class.return_value = client connected = False async def is_connected(): return connected async def connect(): nonlocal connected connected = True async def disconnect(): nonlocal connected connected = False client.is_connected.side_effect = is_connected client.connect.side_effect = connect client.disconnect.side_effect = disconnect yield client @pytest.fixture def scanner(): with patch('bleak.BleakScanner') as scanner: yield scanner pykulersky-0.6.0/tests/test_discovery.py000066400000000000000000000026571503300265700205340ustar00rootroot00000000000000#!/usr/bin/env python import pytest import bleak from pykulersky import discover, PykulerskyException @pytest.mark.asyncio async def test_discover_devices(scanner, client_class): """Test the CLI.""" async def scan(*args, **kwargs): """Simulate a scanning response""" return [ bleak.backends.device.BLEDevice( 'AA:BB:CC:11:22:33', 'Living Room', {}, uuids=[ "8d96a001-0002-64c2-0001-9acc4838521c", ], ), bleak.backends.device.BLEDevice( 'AA:BB:CC:44:55:66', 'Bedroom', {}, uuids=[ "8d96a001-0002-64c2-0001-9acc4838521c", ], ), ] scanner.discover.side_effect = scan devices = await discover(15) assert len(devices) == 2 assert devices[0].address == 'AA:BB:CC:11:22:33' assert devices[0].name == 'Living Room' assert devices[1].address == 'AA:BB:CC:44:55:66' assert devices[1].name == 'Bedroom' scanner.discover.assert_called_once() @pytest.mark.asyncio async def test_exception_wrapping(scanner): """Test the CLI.""" async def raise_exception(*args, **kwargs): raise bleak.exc.BleakError("TEST") scanner.discover.side_effect = raise_exception with pytest.raises(PykulerskyException): await discover() pykulersky-0.6.0/tests/test_light.py000066400000000000000000000122721503300265700176260ustar00rootroot00000000000000#!/usr/bin/env python import asyncio import pytest import bleak from pykulersky import Light, PykulerskyException from .conftest import AsyncMock @pytest.mark.asyncio async def test_connect_disconnect(client_class, client): """Test connecting and disconnecting.""" light = Light("00:11:22") client_class.assert_called_with("00:11:22") await light.connect() client.connect.assert_called_once() await light.disconnect() client.disconnect.assert_called_once() @pytest.mark.asyncio async def test_connect_exception(client): """Test an exception while connecting.""" light = Light("00:11:22") client.connect.side_effect = bleak.exc.BleakError("TEST") with pytest.raises(PykulerskyException): await light.connect() @pytest.mark.asyncio async def test_disconnect_exception(client): """Test an exception while disconnecting.""" light = Light("00:11:22") await light.connect() client.disconnect.side_effect = bleak.exc.BleakError("TEST") with pytest.raises(PykulerskyException): await light.disconnect() @pytest.mark.asyncio async def test_is_connected(client): """Test an exception while connecting.""" light = Light("00:11:22") client.is_connected.side_effect = asyncio.TimeoutError("TEST") assert not await light.is_connected() @pytest.mark.asyncio async def test_get_color(client): """Test getting light color.""" light = Light("00:11:22") await light.connect() client.read_gatt_char.return_value = bytearray(b'\x02\x00\x00\x00\xFF') color = await light.get_color() client.read_gatt_char.assert_called_with( '8d96b002-0002-64c2-0001-9acc4838521c') assert color == (0, 0, 0, 255) client.read_gatt_char.return_value = bytearray(b'\x02\xFF\xFF\x00\x00') color = await light.get_color() client.read_gatt_char.assert_called_with( '8d96b002-0002-64c2-0001-9acc4838521c') assert color == (255, 255, 0, 0) client.read_gatt_char.return_value = bytearray(b'\x32\xFF\xFF\xFF\x00') color = await light.get_color() client.read_gatt_char.assert_called_with( '8d96b002-0002-64c2-0001-9acc4838521c') assert color == (0, 0, 0, 0) @pytest.mark.asyncio async def test_set_color(client): """Test setting light color.""" light = Light("00:11:22") await light.connect() client.read_gatt_char.return_value = bytearray(b'\x02\xFF\xFF\xFF\x00') await light.set_color(255, 255, 255, 0) client.write_gatt_char.assert_called_with( '8d96b002-0002-64c2-0001-9acc4838521c', b'\x02\xFF\xFF\xFF\x00') client.read_gatt_char.return_value = bytearray(b'\x02\xFF\xFF\xFF\x00') await light.set_color(64, 128, 192, 0) client.write_gatt_char.assert_called_with( '8d96b002-0002-64c2-0001-9acc4838521c', b'\x02\x40\x80\xC0\x00') client.read_gatt_char.return_value = bytearray(b'\x02\xFF\xFF\xFF\x00') await light.set_color(0, 0, 0, 255) client.write_gatt_char.assert_called_with( '8d96b002-0002-64c2-0001-9acc4838521c', b'\x02\x00\x00\x00\xFF') # When called with all zeros, just turn off the light client.read_gatt_char.return_value = bytearray(b'\x02\xFF\xFF\xFF\x00') await light.set_color(0, 0, 0, 0) client.write_gatt_char.assert_called_with( '8d96b002-0002-64c2-0001-9acc4838521c', b'\x32\xFF\xFF\xFF\xFF') # Turn on only the RGB channels client.read_gatt_char.return_value = bytearray(b'\x32\xFF\xFF\xFF\xFF') await light.set_color(255, 255, 255, 0) client.write_gatt_char.assert_called_with( '8d96b002-0002-64c2-0001-9acc4838521c', b'\x02\xFF\xFF\xFF\x00') client.reset_mock() # Turn on white channel when previously off (test firmware workaround) client.read_gatt_char.return_value = bytearray(b'\x32\xFF\xFF\xFF\xFF') await light.set_color(255, 255, 255, 255) client.write_gatt_char.call_args_list[0][0] == ( '8d96b002-0002-64c2-0001-9acc4838521c', b'\x02\xFF\xFF\xFF\x00') client.write_gatt_char.call_args_list[0][0] == ( '8d96b002-0002-64c2-0001-9acc4838521c', b'\x02\xFF\xFF\xFF\x00') client.reset_mock() with pytest.raises(ValueError): await light.set_color(999, 999, 999, 999) @pytest.mark.asyncio async def test_exception_wrapping(client): """Test that exceptions are wrapped.""" light = Light("00:11:22") await light.connect() client.is_connected.side_effect = bleak.exc.BleakError("TEST") with pytest.raises(PykulerskyException): await light.is_connected() client.write_gatt_char.side_effect = bleak.exc.BleakError("TEST") with pytest.raises(PykulerskyException): await light.set_color(255, 255, 255, 255) light._do_set_color = AsyncMock() light._do_set_color.side_effect = asyncio.TimeoutError("Mock timeout") with pytest.raises(PykulerskyException): await light.set_color(255, 255, 255, 255) client.read_gatt_char.side_effect = bleak.exc.BleakError("TEST") with pytest.raises(PykulerskyException): await light.get_color() light._do_get_color = AsyncMock() light._do_get_color.side_effect = asyncio.TimeoutError("Mock timeout") with pytest.raises(PykulerskyException): await light.get_color() pykulersky-0.6.0/tox.ini000066400000000000000000000004351503300265700152550ustar00rootroot00000000000000[tox] envlist = py38, py39, py310, py311, py312, py313, flake8 [testenv] setenv = PYTHONPATH = {toxinidir} deps = -r{toxinidir}/requirements_dev.txt commands = pytest --cov --cov-report term-missing [testenv:flake8] basepython = python commands = flake8 pykulersky tests