├── photos ├── img1.JPG ├── effects-builder.png ├── nanoleafapi_example.png └── nanoleafapi_new_example.png ├── tea.yaml ├── .gitignore ├── .mypy.ini ├── .pylintrc ├── docs ├── source │ ├── errors.rst │ ├── api.rst │ ├── index.rst │ ├── digitaltwin.rst │ ├── install.rst │ ├── conf.py │ └── methods.rst ├── Makefile └── make.bat ├── nanoleafapi ├── __init__.py ├── discovery.py ├── digital_twin.py ├── test_nanoleaf.py └── nanoleaf.py ├── .github ├── workflows │ ├── ci.yml │ └── pythonpublish.yml └── ISSUE_TEMPLATE │ └── bug_report.md ├── setup.py ├── LICENSE └── README.md /photos/img1.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MylesMor/nanoleafapi/HEAD/photos/img1.JPG -------------------------------------------------------------------------------- /photos/effects-builder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MylesMor/nanoleafapi/HEAD/photos/effects-builder.png -------------------------------------------------------------------------------- /photos/nanoleafapi_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MylesMor/nanoleafapi/HEAD/photos/nanoleafapi_example.png -------------------------------------------------------------------------------- /photos/nanoleafapi_new_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MylesMor/nanoleafapi/HEAD/photos/nanoleafapi_new_example.png -------------------------------------------------------------------------------- /tea.yaml: -------------------------------------------------------------------------------- 1 | # https://tea.xyz/what-is-this-file 2 | --- 3 | version: 1.0.0 4 | codeOwners: 5 | - '0x29bBFF12d05542Efc66A7D25023CFe78dF512a82' 6 | quorum: 1 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/build 2 | /nanoleafapi/__pycache__ 3 | /dist 4 | /nanoleafapi.egg-info 5 | /build/* 6 | /.vscode 7 | /.mypy_cache 8 | /nanoleafapi/test.py 9 | /nanoleafapi/effects_builder.py 10 | -------------------------------------------------------------------------------- /.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | check_untyped_defs = True 3 | disallow_any_generics = True 4 | disallow_untyped_calls = True 5 | disallow_untyped_defs = True 6 | ignore_missing_imports = True 7 | strict_optional = False -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [disable] 2 | 3 | disable = 4 | unused-argument, 5 | missing-docstring, 6 | too-many-public-methods, 7 | redundant-unittest-assert, 8 | duplicate-code 9 | 10 | good-names = 11 | nl, 12 | r, 13 | g, 14 | b, 15 | f, 16 | w, 17 | t, 18 | ip, 19 | nanoleafapi/discovery, 20 | nanoleafapi/nanoleaf, 21 | nanoleafapi/digital_twin 22 | 23 | -------------------------------------------------------------------------------- /docs/source/errors.rst: -------------------------------------------------------------------------------- 1 | Errors 2 | ================ 3 | 4 | .. code-block:: python 5 | 6 | NanoleafRegistrationError() # Raised when token generation mode not active on device 7 | NanoleafConnectionError() # Raised when there is a connection error during check_connection() method 8 | NanoleafEffectCreationError() # Raised when there is an error with an effect dictionary/method arguments -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | Detailed Documentation 2 | ======================== 3 | 4 | Nanoleaf 5 | ----------------------- 6 | 7 | .. automodule:: nanoleaf 8 | :members: 9 | 10 | Discovery 11 | ----------------------- 12 | 13 | .. automodule:: discovery 14 | :members: 15 | 16 | Digital Twin 17 | ----------------------- 18 | 19 | .. automodule:: digital_twin 20 | :members: 21 | 22 | -------------------------------------------------------------------------------- /nanoleafapi/__init__.py: -------------------------------------------------------------------------------- 1 | from nanoleafapi.nanoleaf import ( 2 | Nanoleaf, 3 | NanoleafRegistrationError, 4 | NanoleafConnectionError, 5 | NanoleafEffectCreationError 6 | ) 7 | from nanoleafapi.nanoleaf import ( 8 | RED, 9 | ORANGE, 10 | YELLOW, 11 | GREEN, 12 | LIGHT_BLUE, 13 | BLUE, 14 | PINK, 15 | PURPLE, 16 | WHITE 17 | ) 18 | from nanoleafapi.digital_twin import NanoleafDigitalTwin 19 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. nanoleafapi documentation master file, created by 2 | sphinx-quickstart on Mon Dec 30 00:30:23 2019. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to nanoleafapi's documentation! 7 | ======================================= 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | install 14 | methods 15 | digitaltwin 16 | errors 17 | api 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v2 11 | - name: Set up Python 3.9 12 | uses: actions/setup-python@v2 13 | with: 14 | python-version: 3.9 15 | - run: pip install mypy pylint requests types-requests sseclient 16 | - run: pylint nanoleafapi/nanoleaf nanoleafapi/discovery nanoleafapi/digital_twin 17 | - run: mypy nanoleafapi/nanoleaf.py nanoleafapi/discovery.py nanoleafapi/digital_twin.py -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG] " 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Development environment (please complete the following information):** 27 | - OS: [e.g. Windows] 28 | - Python version: [e.g. 3.6] 29 | - Nanoleaf device [e.g. Canvas] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v1 19 | with: 20 | python-version: '3.8' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: __token__ 28 | TWINE_PASSWORD: ${{ secrets.TWINE_TOKEN }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="nanoleafapi", 8 | version="2.1.2", 9 | author="MylesMor", 10 | author_email="hello@mylesmor.dev", 11 | description="A Python 3 wrapper for the Nanoleaf OpenAPI, " + 12 | "for use when controlling the Light Panels, Canvas and Shapes (including Hexagons)", 13 | long_description=long_description, 14 | long_description_content_type="text/markdown", 15 | url="https://github.com/MylesMor/nanoleafapi", 16 | packages=setuptools.find_packages(), 17 | install_requires=['requests', 'sseclient'], 18 | classifiers=[ 19 | "Programming Language :: Python :: 3", 20 | "License :: OSI Approved :: MIT License", 21 | "Operating System :: OS Independent", 22 | ], 23 | entry_points = { 24 | 'console_scripts': ['effects-builder=nanoleafapi.effects_builder:interactive_builder'] 25 | }, 26 | python_requires='>=3.6', 27 | ) 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 MylesMor 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/source/digitaltwin.rst: -------------------------------------------------------------------------------- 1 | NanoleafDigitalTwin Methods 2 | ================================== 3 | 4 | This class is used to make a digital twin (or copy) of the Nanoleaf device, allowing you to change the colour of individual tiles and then sync all the changes 5 | at once to the real device. 6 | 7 | To create an instance of this class, you must initialise it with a Nanoleaf object: 8 | 9 | .. code-block:: python 10 | 11 | from nanoleafapi import Nanoleaf, NanoleafDigitalTwin 12 | 13 | nl = Nanoleaf("192.168.0.2") 14 | digital_twin = NanoleafDigitalTwin(nl) 15 | 16 | 17 | Utility 18 | ---------------- 19 | 20 | .. code-block:: python 21 | 22 | get_ids() # Returns a list of panel IDs 23 | 24 | 25 | Colour 26 | ---------------- 27 | Setting the colour is all managed by using an RGB tuple, in the format: ``(R, G, B)``. 28 | 29 | .. code-block:: python 30 | 31 | set_color(panel_id, (255, 255, 255)) # Sets the panel with specified ID to white 32 | set_all_colors((255, 255, 255)) # Sets all panels to white 33 | get_color(panel_id) # Gets the colour of a specified panel 34 | get_all_colors() # Returns a dictionary of {panel_id: (R, G, B)} 35 | 36 | 37 | Sync 38 | ----------------- 39 | The sync method applies the changes to the real Nanoleaf device, based on the changes made here. 40 | 41 | .. code-block:: python 42 | 43 | sync() # Syncs with the real Nanoleaf counterpart 44 | -------------------------------------------------------------------------------- /docs/source/install.rst: -------------------------------------------------------------------------------- 1 | Getting Started 2 | ====================================== 3 | 4 | Installation 5 | ----------------- 6 | 7 | To install run: 8 | 9 | .. code-block:: 10 | 11 | pip install nanoleafapi 12 | 13 | Prerequisites 14 | ---------------- 15 | 16 | You must know the IP address of the Nanoleaf device. This can be either be done using your own methods or by using the disovery module. This module uses SSDP and should work __but__ I have found cases of this method not functioning properly. If it doesn't work, and gives an empty dictionary please identify the IP of the Nanoleaf device yourself. 17 | 18 | To use the discovery module: 19 | 20 | .. code-block:: python 21 | 22 | from nanoleafapi import discovery 23 | nanoleaf_dict = discovery.discover_devices(timeout=30) 24 | 25 | This will return a dictionary in the format: ``{name: ip}``. 26 | 27 | Usage 28 | ---------------------- 29 | 30 | There is just one class that contains all relevant functions for controlling the lights. To get started: 31 | 32 | .. code-block:: python 33 | 34 | from nanoleafapi import Nanoleaf 35 | 36 | Next, a Nanoleaf object can be created with the following section of code. IF you don't have an authentication token yet, hold the power button for 5-7 seconds on your Nanoleaf device before running the following code. This will generate a new token and save it to your user directory to use for future uses of this package. 37 | 38 | .. code-block:: python 39 | 40 | nl = Nanoleaf("ip") 41 | 42 | You can now use the commands to control the panels as displayed in the example below. 43 | 44 | .. code-block:: python 45 | 46 | nl.toggle_power() 47 | nl.set_color((255, 0, 0)) # Set colour to red 48 | 49 | -------------------------------------------------------------------------------- /nanoleafapi/discovery.py: -------------------------------------------------------------------------------- 1 | """discovery 2 | 3 | Module to aid with Nanoleaf discovery on a network. 4 | """ 5 | 6 | import socket 7 | from typing import Dict, Optional 8 | 9 | def discover_devices(timeout : int = 30, debug : bool = False) -> Dict[Optional[str], str]: 10 | """ 11 | Discovers Nanoleaf devices on the network using SSDP 12 | 13 | :param timeout: The timeout on the search in seconds (default 30) 14 | :param debug: Prints each device string for the SSDP discovery 15 | :returns: Dictionary of found devices in format {name: ip} 16 | """ 17 | ssdp = """M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\nMAN: 18 | \"ssdp:discover\"\r\nMX: 1\r\nST: nanoleaf:nl29\r\n\r\n""" 19 | 20 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 21 | sock.settimeout(timeout) 22 | sock.sendto(ssdp.encode(), ("239.255.255.250", 1900)) 23 | 24 | nanoleaves = [] 25 | 26 | while True: 27 | try: 28 | data = sock.recv(1024).decode() 29 | except socket.error: 30 | break 31 | nanoleaves.append(data) 32 | 33 | nanoleaf_dict = {} 34 | 35 | for device in nanoleaves: 36 | if debug: 37 | print(device) 38 | headers = device.split('\r\n') 39 | ip = None 40 | name = None 41 | for header in headers: 42 | if "Location" in header: 43 | try: 44 | ip_string = header.split("http://")[1] 45 | ip = ip_string.split(":")[0] 46 | except ValueError: 47 | pass 48 | if "nl-devicename" in header: 49 | try: 50 | name = header.split("nl-devicename: ")[1] 51 | except ValueError: 52 | pass 53 | if ip is not None: 54 | nanoleaf_dict[name] = ip 55 | return nanoleaf_dict 56 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | import sphinx_rtd_theme 16 | sys.path.insert(0, os.path.abspath('../../nanoleafapi')) 17 | 18 | 19 | # -- Project information ----------------------------------------------------- 20 | 21 | project = 'nanoleafapi' 22 | copyright = '2020, MylesMor' 23 | author = 'MylesMor' 24 | 25 | # The full version, including alpha/beta/rc tags 26 | release = '2.1.0' 27 | 28 | 29 | # -- General configuration --------------------------------------------------- 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | 'sphinx.ext.autodoc', 36 | "sphinx_rtd_theme", 37 | ] 38 | 39 | # Add any paths that contain templates here, relative to this directory. 40 | templates_path = ['_templates'] 41 | 42 | # List of patterns, relative to source directory, that match files and 43 | # directories to ignore when looking for source files. 44 | # This pattern also affects html_static_path and html_extra_path. 45 | exclude_patterns = [] 46 | 47 | 48 | # -- Options for HTML output ------------------------------------------------- 49 | 50 | # The theme to use for HTML and HTML Help pages. See the documentation for 51 | # a list of builtin themes. 52 | # 53 | master_doc = 'index' 54 | html_theme = "sphinx_rtd_theme" 55 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 56 | 57 | # Add any paths that contain custom static files (such as style sheets) here, 58 | # relative to this directory. They are copied after the builtin static files, 59 | # so a file named "default.css" will overwrite the builtin "default.css". 60 | html_static_path = ['_static'] 61 | -------------------------------------------------------------------------------- /nanoleafapi/digital_twin.py: -------------------------------------------------------------------------------- 1 | """NanoleafDigitalTwin 2 | 3 | This module allows for the creation of a "digital twin", allowing you to 4 | make changes to individual panels and sync them to their real counterparts.""" 5 | 6 | from typing import Tuple, List, Dict 7 | from nanoleafapi.nanoleaf import NanoleafEffectCreationError, Nanoleaf 8 | 9 | class NanoleafDigitalTwin(): 10 | """Class for creating and modifying digital twins 11 | 12 | :ivar nanoleaf: The Nanoleaf object 13 | :ivar tile_dict: The dictionary of tiles and their associated colour 14 | """ 15 | 16 | def __init__(self, nl : Nanoleaf) -> None: 17 | """Initialises a digital twin based on the Nanoleaf object provided. 18 | 19 | :param nl: The Nanoleaf object""" 20 | ids = nl.get_ids() 21 | self.nanoleaf = nl 22 | self.tile_dict = {} 23 | for panel_id in ids: 24 | self.tile_dict[panel_id] = {"R": 0, "G": 0, "B": 0, "W": 0, "T": 0} 25 | 26 | 27 | def set_color(self, panel_id : int, rgb : Tuple[int, int, int]) -> None: 28 | """Sets the colour of an individual panel. 29 | 30 | :param panel_id: The ID of the panel to change the colour of 31 | :param rgb: A tuple containing the RGB values of the colour to set""" 32 | if panel_id not in self.tile_dict: 33 | raise NanoleafEffectCreationError("Invalid panel ID") 34 | if len(rgb) != 3: 35 | raise NanoleafEffectCreationError("There must be three values in the " + 36 | "RGB tuple! E.g., (255, 0, 0)") 37 | for colour in rgb: 38 | if not isinstance(colour, int): 39 | raise NanoleafEffectCreationError("All values in the tuple must be " + 40 | "integers! E.g., (255, 0, 0)") 41 | if colour < 0 or colour > 255: 42 | raise NanoleafEffectCreationError("All values in the tuple must be " + 43 | "integers between 0 and 255! E.g., (255, 0, 0)") 44 | self.tile_dict[panel_id]['R'] = rgb[0] 45 | self.tile_dict[panel_id]['G'] = rgb[1] 46 | self.tile_dict[panel_id]['B'] = rgb[2] 47 | 48 | 49 | def set_all_colors(self, rgb : Tuple[int, int, int]) -> None: 50 | """Sets the colour of all the panels. 51 | 52 | :param rgb: A tuple containing the RGB values of the colour to set""" 53 | if len(rgb) != 3: 54 | raise NanoleafEffectCreationError("There must be three values in the " + 55 | "RGB tuple! E.g., (255, 0, 0)") 56 | for colour in rgb: 57 | if not isinstance(colour, int): 58 | raise NanoleafEffectCreationError("All values in the tuple must be " + 59 | "integers! E.g., (255, 0, 0)") 60 | if colour < 0 or colour > 255: 61 | raise NanoleafEffectCreationError("All values in the tuple must be " + 62 | "integers between 0 and 255! E.g., (255, 0, 0)") 63 | for _, value in self.tile_dict.items(): 64 | value['R'] = rgb[0] 65 | value['G'] = rgb[1] 66 | value['B'] = rgb[2] 67 | 68 | 69 | def get_ids(self) -> List[int]: 70 | """Returns a list of panel IDs. 71 | 72 | :returns: List of panel IDs. 73 | """ 74 | return list(self.tile_dict.keys()) 75 | 76 | 77 | def get_color(self, panel_id : int) -> Tuple[int, int, int]: 78 | """Returns the colour of a specified panel. 79 | 80 | :param panel_id: The panel to get the colour of. 81 | 82 | :returns: Returns the RGB tuple of the panel with ID panel_id. 83 | """ 84 | if panel_id not in self.tile_dict: 85 | raise NanoleafEffectCreationError("Invalid panel ID") 86 | return (self.tile_dict[panel_id]['R'], self.tile_dict[panel_id]['G'], 87 | self.tile_dict[panel_id]['B']) 88 | 89 | 90 | def get_all_colors(self) -> Dict[int, Tuple[int, int, int]]: 91 | """Returns a dictionary of all panel IDs and associated colours. 92 | 93 | :returns: Dictionary with panel IDs as keys and RGB tuples as values. 94 | """ 95 | color_dict = {} 96 | for key, value in self.tile_dict.items(): 97 | color_dict[key] = (value['R'], value['G'], value['B']) 98 | return color_dict 99 | 100 | def sync(self) -> bool: 101 | """Syncs the digital twin's changes to the real Nanoleaf device. 102 | 103 | :returns: True if success, otherwise False 104 | """ 105 | anim_data = str(len(self.tile_dict)) 106 | f = 1 107 | for key, value in self.tile_dict.items(): 108 | r = value['R'] 109 | g = value['G'] 110 | b = value['B'] 111 | w = value['W'] 112 | t = value['T'] 113 | anim_data += f" {str(key)} {f} {r} {g} {b} {w} {t}" 114 | base_effect = self.nanoleaf.get_custom_base_effect() 115 | base_effect['animData'] = anim_data 116 | return self.nanoleaf.write_effect(base_effect) 117 | -------------------------------------------------------------------------------- /docs/source/methods.rst: -------------------------------------------------------------------------------- 1 | Nanoleaf Methods 2 | =================== 3 | 4 | All of the following methods can be called with the Nanoleaf object you created. 5 | 6 | For more information about the Nanoleaf API: https://forum.nanoleaf.me/docs/openapi 7 | 8 | For more in-depth documentation about this package visit: https://nanoleafapi.readthedocs.io/en/latest/api.html 9 | 10 | User Management 11 | ------------------- 12 | 13 | .. code-block:: python 14 | 15 | generate_auth_token() # Generates new authentication token (hold power for 5-7 before running) 16 | delete_user(auth_token) # Deletes an authentication token from the device 17 | 18 | 19 | Power 20 | ------------------- 21 | 22 | .. code-block:: python 23 | 24 | get_power() # Returns True if lights are on, otherwise False 25 | power_off() # Powers off the lights 26 | power_on() # Powers on the lights 27 | toggle_power() # Toggles light on/off 28 | 29 | 30 | Colour 31 | ------------------- 32 | 33 | Colours are generated using HSV (or HSB) in the API, and these individual values can be adjusted using methods which are as described, hue, brightness and saturation. The method in this section uses RGB (0-255) and converts this to HSV. 34 | 35 | There are already some pre-set colours which can be imported to be used with the ``set_color()`` method: 36 | 37 | .. code-block:: python 38 | 39 | from nanoleafapi import RED, ORANGE, YELLOW, GREEN, LIGHT_BLUE, BLUE, PINK, PURPLE, WHITE 40 | 41 | 42 | The ``set_color()`` method can then be called, passing in either a pre-set colour or your own RGB colour in the form of a tuple: ``(r, g, b)``. 43 | 44 | .. code-block:: python 45 | 46 | set_color((r, g, b)) # Set all lights to RGB colour. Pass the colour as a tuple. 47 | set_color(RED) # Same result but using a pre-set colour. 48 | 49 | Brightness 50 | ------------------- 51 | 52 | .. code-block:: python 53 | 54 | set_brightness(brightness, duration) # Sets the brightness of the lights (accepts values between 0-100) 55 | increment_brightness(value) # Increments the brightness by set amount (can also be negative) 56 | get_brightness() # Returns current brightness 57 | 58 | 59 | Hue 60 | ------------------- 61 | 62 | Use these if you want to change the HSV values manually, otherwise use ``set_color()`` for colour change using RGB. 63 | 64 | .. code-block:: python 65 | 66 | set_hue(value) # Sets the hue of the lights (accepts values between 0-360) 67 | increment_hue(value) # Increments the hue by set amount (can also be negative) 68 | get_hue() # Returns current hue 69 | 70 | 71 | Saturation 72 | ------------------- 73 | 74 | Use these if you want to change the HSV values manually, otherwise use ``set_color()`` for colour change using RGB. 75 | 76 | .. code-block:: python 77 | 78 | set_saturation(value) # Sets the saturation of the lights (accepts value between 0-100) 79 | increment_saturation(value) # Increments the saturation by set amount (can also be negative) 80 | get_saturation() # Returns current saturation 81 | 82 | 83 | Identify 84 | ------------------- 85 | 86 | This is usually used to identify the current lights by flashing them on and off. 87 | 88 | .. code-block:: python 89 | 90 | identify() 91 | 92 | 93 | Colour Temperature 94 | ------------------- 95 | 96 | .. code-block:: python 97 | 98 | set_color_temp(value) # Sets the colour temperature of the lights (accepts between 1200-6500) 99 | increment_color_temp(value) # Increments the colour temperature by set amount (can also be negative) 100 | get_color_temp() # Returns current colour temperature 101 | 102 | 103 | Colour Mode 104 | ------------------- 105 | 106 | Not really sure what this is for, but included it anyway. 107 | 108 | .. code-block:: python 109 | 110 | get_color_mode() # Returns current colour mode 111 | 112 | 113 | Effects 114 | ------------------- 115 | 116 | .. code-block:: python 117 | 118 | get_current_effect() # Returns either name of current effect if available or *Solid*/*Static*/*Dynamic*. 119 | list_effects() # Returns a list of names of all available effects. 120 | effect_exists(name) # Helper method which determines whether the given string exists as an effect. 121 | set_effect(name) # Sets the current effect. 122 | 123 | Write Effect 124 | ------------------- 125 | .. code-block:: python 126 | 127 | write_effect(effect_dict) # Sets a user-created effect. 128 | 129 | Writing effects is rather complicated; you need to follow the the exact format for the effect dictionary, which can be found here: https://forum.nanoleaf.me/docs/openapi#_u2t4jzmkp8nt 130 | 131 | In future updates, I hope to add a way to make this process easier, but for now an example of a valid effect dictionary is provided below: 132 | 133 | .. code-block:: python 134 | 135 | effect_data = { 136 | "command": "display", 137 | "animName": "New animation", 138 | "animType": "random", 139 | "colorType": "HSB", 140 | "animData": None, 141 | "palette": [ 142 | { 143 | "hue": 0, 144 | "saturation": 100, 145 | "brightness": 100 146 | }, 147 | { 148 | "hue": 120, 149 | "saturation": 100, 150 | "brightness": 100 151 | }, 152 | { 153 | "hue": 180, 154 | "saturation": 100, 155 | "brightness": 100 156 | } 157 | ], 158 | "brightnessRange": { 159 | "minValue": 50, 160 | "maxValue": 100 161 | }, 162 | "transTime": { 163 | "minValue": 50, 164 | "maxValue": 100 165 | }, 166 | "delayTime": { 167 | "minValue": 50, 168 | "maxValue": 100 169 | }, 170 | "loop": True 171 | } 172 | 173 | 174 | Inputting an invalid dictionary will result in the function returning False, and it printing to the console `Invalid effect dictionary!`. 175 | 176 | 177 | Events 178 | ------------------- 179 | Creates an event listener for the different types of events. 180 | 181 | .. code-block:: python 182 | 183 | register_event(function, event_types) 184 | 185 | You should pass your own function with one argument (event as a dictionary). This function will run every time a new event is received. 186 | 187 | **IMPORTANT**: You cannot currently call ``register_event()`` more than **once** due to API limitations. Instead, distinguish between the events in your function using the dictionary data. 188 | 189 | A list of event types you would like to listen for should also be passed. You can register up to 4 events (all of them), and these are listed below: 190 | 191 | Event IDs: 192 | 193 | | State (changes in power/brightness): **1** 194 | | Layout: **2** 195 | | Effects: **3** 196 | | Touch (canvas only): **4** 197 | 198 | 199 | Example Usage 200 | ~~~~~~~~~~~~~~~ 201 | 202 | .. code-block:: python 203 | 204 | def event_function(event): 205 | print(event) 206 | 207 | # Register for all events 208 | nl.register_event(event_function, [1, 2, 3, 4]) 209 | 210 | 211 | Example Output 212 | ~~~~~~~~~~~~~~~~~~ 213 | 214 | When an event occurs, the ``event_function()`` will run and therefore in this case, print the event dictionary. 215 | 216 | .. code-block:: python 217 | 218 | {"events":[{"attr":2,"value":65}]} # Example of state event (1) 219 | {"events":[{"attr":1,"value":"Falling Whites"}]} # Example of effects event (3) 220 | {"events":[{"panelId":7397,"gesture":0}]} # Example of touch event (4) 221 | -------------------------------------------------------------------------------- /nanoleafapi/test_nanoleaf.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from nanoleafapi.nanoleaf import Nanoleaf, NanoleafEffectCreationError 3 | from nanoleafapi.digital_twin import NanoleafDigitalTwin 4 | import socket 5 | 6 | class TestNanoleafMethods(unittest.TestCase): 7 | 8 | @classmethod 9 | def setUpClass(cls): 10 | # INSERT YOUR OWN VALUES HERE 11 | cls.ip = '192.168.1.70' 12 | cls.nl = Nanoleaf(cls.ip, None, True) 13 | cls.digital_twin = NanoleafDigitalTwin(cls.nl) 14 | 15 | def test_power_on(self): 16 | self.assertTrue(self.nl.power_on()) 17 | 18 | def test_power_off(self): 19 | self.assertTrue(self.nl.power_off()) 20 | 21 | def test_toggle_power(self): 22 | self.assertTrue(self.nl.toggle_power()) 23 | 24 | def test_set_color(self): 25 | self.assertTrue(self.nl.set_color((255, 255, 255))) 26 | self.assertFalse(self.nl.set_color((255, 255, 276))) 27 | self.assertFalse(self.nl.set_color((255, 255, -234))) 28 | 29 | def test_set_brightness(self): 30 | self.assertTrue(self.nl.set_brightness(100)) 31 | with self.assertRaises(ValueError): 32 | self.nl.set_brightness(-10) 33 | 34 | def test_increment_brightness(self): 35 | self.assertTrue(self.nl.increment_brightness(10)) 36 | self.assertTrue(self.nl.increment_brightness(-20)) 37 | self.assertTrue(self.nl.increment_brightness(200)) 38 | self.assertTrue(self.nl.increment_brightness(-300)) 39 | 40 | def test_identify(self): 41 | self.assertTrue(self.nl.identify()) 42 | 43 | def test_set_hue(self): 44 | self.assertTrue(self.nl.set_hue(100)) 45 | with self.assertRaises(ValueError): 46 | self.nl.set_hue(-10) 47 | 48 | def test_increment_hue(self): 49 | self.assertTrue(self.nl.increment_hue(10)) 50 | self.assertTrue(self.nl.increment_hue(-20)) 51 | self.assertTrue(self.nl.increment_hue(200)) 52 | self.assertTrue(self.nl.increment_hue(-300)) 53 | 54 | def test_set_saturation(self): 55 | self.assertTrue(self.nl.set_saturation(100)) 56 | with self.assertRaises(ValueError): 57 | self.nl.set_saturation(-10) 58 | 59 | def test_increment_saturation(self): 60 | self.assertTrue(self.nl.increment_saturation(10)) 61 | self.assertTrue(self.nl.increment_saturation(-20)) 62 | self.assertTrue(self.nl.increment_saturation(200)) 63 | self.assertTrue(self.nl.increment_saturation(-300)) 64 | 65 | def test_set_color_temp(self): 66 | self.assertTrue(self.nl.set_color_temp(6500)) 67 | with self.assertRaises(ValueError): 68 | self.nl.set_color_temp(1100) 69 | 70 | def increment_color_temp(self): 71 | self.assertTrue(self.nl.increment_color_temp(10)) 72 | self.assertTrue(self.nl.increment_color_temp(-20)) 73 | self.assertTrue(self.nl.increment_color_temp(200)) 74 | self.assertTrue(self.nl.increment_color_temp(-300)) 75 | 76 | def test_set_effect(self): 77 | self.assertFalse(self.nl.set_effect('non-existent-effect')) 78 | 79 | def test_get_info(self): 80 | self.assertTrue(self.nl.get_info()) 81 | 82 | def test_get_power(self): 83 | self.assertTrue(self.nl.get_power()) 84 | 85 | def test_get_brightness(self): 86 | self.nl.set_brightness(100) 87 | self.assertEqual(self.nl.get_brightness(), 100) 88 | 89 | def test_get_hue(self): 90 | self.nl.set_hue(100) 91 | self.assertEqual(self.nl.get_hue(), 100) 92 | 93 | def test_get_saturation(self): 94 | self.nl.set_saturation(100) 95 | self.assertEqual(self.nl.get_saturation(), 100) 96 | 97 | def test_get_color_temp(self): 98 | self.assertTrue(self.nl.get_color_temp()) 99 | 100 | def test_get_color_mode(self): 101 | self.assertTrue(self.nl.get_color_mode()) 102 | 103 | def test_get_current_effect(self): 104 | self.assertTrue(self.nl.get_current_effect()) 105 | 106 | def test_list_effects(self): 107 | self.assertTrue(self.nl.list_effects()) 108 | 109 | def test_pulsate(self): 110 | self.assertTrue(self.nl.pulsate((255, 0, 0), 1)) 111 | with self.assertRaises(NanoleafEffectCreationError): 112 | self.nl.pulsate([(256, 0, 0)], 1) 113 | with self.assertRaises(NanoleafEffectCreationError): 114 | self.nl.pulsate([(255, 0, 0), (0, 255, 0)], 1) 115 | with self.assertRaises(NanoleafEffectCreationError): 116 | self.nl.pulsate([(255, 0)], 1) 117 | 118 | 119 | def test_flow(self): 120 | self.assertTrue(self.nl.flow([(255, 0, 0), (0, 255, 0)], 1)) 121 | with self.assertRaises(NanoleafEffectCreationError): 122 | self.nl.flow([(256, 0, 0), (0, 255, 0)], 1) 123 | with self.assertRaises(NanoleafEffectCreationError): 124 | self.nl.flow([(255, 0)], 1) 125 | with self.assertRaises(NanoleafEffectCreationError): 126 | self.nl.flow([(256, 0, 0)], 1) 127 | 128 | 129 | def test_spectrum(self): 130 | self.assertTrue(self.nl.spectrum(1)) 131 | 132 | def test_write_effect(self): 133 | effect_data = { 134 | "command": "display", 135 | "animName": "New animation", 136 | "animType": "random", 137 | "colorType": "HSB", 138 | "animData": None, 139 | "palette": [ 140 | { 141 | "hue": 0, 142 | "saturation": 100, 143 | "brightness": 100 144 | }, 145 | { 146 | "hue": 120, 147 | "saturation": 100, 148 | "brightness": 100 149 | }, 150 | { 151 | "hue": 240, 152 | "saturation": 100, 153 | "brightness": 100 154 | } 155 | ], 156 | "brightnessRange": { 157 | "minValue": 25, 158 | "maxValue": 100 159 | }, 160 | "transTime": { 161 | "minValue": 25, 162 | "maxValue": 100 163 | }, 164 | "delayTime": { 165 | "minValue": 25, 166 | "maxValue": 100 167 | }, 168 | "loop": True 169 | } 170 | self.assertTrue(self.nl.write_effect(effect_data)) 171 | with self.assertRaises(NanoleafEffectCreationError): 172 | self.assertFalse(self.nl.write_effect({"invalid-string": "invalid"})) 173 | 174 | def test_effect_exists(self): 175 | self.assertFalse(self.nl.effect_exists('non-existent-effect')) 176 | 177 | def test_get_layout(self): 178 | self.assertTrue(self.nl.get_layout()) 179 | 180 | def __helper_function(self, dictionary): 181 | self.assertTrue(True) 182 | 183 | def test_register_event(self): 184 | with self.assertRaises(Exception): 185 | self.nl.register_event(self.__helper_function, [5]) 186 | with self.assertRaises(Exception): 187 | self.nl.register_event(self.__helper_function, [1, 2, 3, 3, 4]) 188 | self.nl.register_event(self.__helper_function, [1]) 189 | self.nl.toggle_power() 190 | 191 | def test_digital_twin_get_ids(self): 192 | self.assertTrue(self.digital_twin.get_ids() == self.nl.get_ids()) 193 | 194 | def test_digital_twin_set_color(self): 195 | self.digital_twin.set_color(self.digital_twin.get_ids()[0], (255, 255, 255)) 196 | self.assertTrue( 197 | self.digital_twin.get_color(self.digital_twin.get_ids()[0]) == (255, 255, 255) 198 | ) 199 | 200 | def test_digital_twin_set_all_colors(self): 201 | self.digital_twin.set_all_colors((255, 255, 255)) 202 | for panel_id in self.digital_twin.get_ids(): 203 | self.assertTrue(self.digital_twin.get_color(panel_id) == (255, 255, 255)) 204 | all_colours = self.digital_twin.get_all_colors() 205 | for value in all_colours.values(): 206 | self.assertTrue(value == (255, 255, 255)) 207 | 208 | def test_digital_twin_sync(self): 209 | self.digital_twin.set_all_colors((255, 255, 255)) 210 | self.assertTrue(self.digital_twin.sync()) 211 | 212 | def test_ext_control(self): 213 | nanoleaf_udp_port = 60222 214 | nanoleaf_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0) 215 | 216 | self.nl.enable_extcontrol() 217 | 218 | panel_ids = self.nl.get_ids() 219 | n_panels = len(panel_ids) - 1 220 | n_panels_b = n_panels.to_bytes(2, "big") 221 | 222 | send_data = b"" 223 | send_data += n_panels_b 224 | transition = 1 225 | for panel_id in panel_ids: 226 | if panel_id != 0: 227 | panel_id_b = panel_id.to_bytes(2, "big") 228 | send_data += panel_id_b 229 | white = 255 230 | w = 0 231 | send_data += white.to_bytes(1, "big") 232 | send_data += white.to_bytes(1, "big") 233 | send_data += white.to_bytes(1, "big") 234 | send_data += w.to_bytes(1, "big") 235 | send_data += transition.to_bytes(2, "big") 236 | 237 | nanoleaf_socket.sendto(send_data, (self.ip, nanoleaf_udp_port)) 238 | nanoleaf_socket.close() 239 | info = self.nl.get_info() 240 | self.assertTrue(info['state']['brightness']['value'] == 100) 241 | self.assertTrue(info['state']['colorMode'] == 'effect') 242 | self.assertTrue(info['state']['ct']['value'] == 6500) 243 | self.assertTrue(info['state']['hue']['value'] == 0) 244 | self.assertTrue(info['state']['sat']['value'] == 0) 245 | 246 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nanoleafapi 2 | 3 | [![PyPI version](https://badge.fury.io/py/nanoleafapi.svg)](https://badge.fury.io/py/nanoleafapi) [![Documentation Status](https://readthedocs.org/projects/nanoleafapi/badge/?version=latest)](https://nanoleafapi.readthedocs.io/en/latest/?badge=latest) [![Downloads](https://pepy.tech/badge/nanoleafapi)](https://pepy.tech/project/nanoleafapi) 4 | 5 | 6 | __nanoleafapi__ is a Python 3 wrapper for the Nanoleaf OpenAPI. It provides an easy way to use many of the functions available in the API. It supports the Light Panels (previously Aurora), Canvas and Shapes (Hexagons, Triangles and Elements). It does **not** support the Nanoleaf Essentials range. 7 | 8 | __Nanoleaf API__: https://forum.nanoleaf.me/docs/openapi 9 | 10 | __Detailed package documentation__: https://nanoleafapi.readthedocs.io 11 | 12 | __IMPORTANT__: As of version 2.0.0, there have been some API changes relating to how the authentication token is generated and stored, please re-read the [Usage](#Usage) section. 13 | 14 | # Table of Contents 15 | 1. [Installation](#Installation) 16 | 2. [Prerequisites](#Prerequisites) 17 | 3. [Usage](#Usage) 18 | * [Methods](#Methods) 19 | * [Effects](#Effects) 20 | * [Events](#Events) 21 | 4. [Digital Twins](#NanoleafDigitalTwin) 22 | 5. [Errors](#Errors) 23 | 24 | ## Installation 25 | To install the latest stable release: 26 | 27 | ```batch 28 | python -m pip install nanoleafapi 29 | ``` 30 | 31 | ## Prerequisites 32 | 33 | You must know the IP address of the Nanoleaf device. This can be either be done using your own methods or by using the disovery module. This module uses SSDP and should work __but__ I have found cases of this method not functioning properly. If it doesn't work, and gives an empty dictionary please identify the IP of the Nanoleaf device yourself. 34 | 35 | To use the discovery module: 36 | 37 | ```py 38 | from nanoleafapi import discovery 39 | 40 | nanoleaf_dict = discovery.discover_devices() 41 | ``` 42 | 43 | This will return a dictionary in the format: `{name: ip}`. 44 | 45 | 46 | ## Usage 47 | 48 | There is just one class that contains all relevant functions for controlling the lights. To get started: 49 | 50 | ```py 51 | from nanoleafapi import Nanoleaf 52 | ``` 53 | 54 | Next, a Nanoleaf object can be created with the following line of code. __IF you don't have an authentication token yet, hold the power button for 5-7 seconds on your Nanoleaf device before running the following code. This will generate a new token and save it to your user directory to use for future uses of this package.__ 55 | 56 | ```py 57 | nl = Nanoleaf("ip") 58 | ``` 59 | 60 | You can now use the commands to control the panels as displayed in the example below. 61 | 62 | ```py 63 | nl.toggle_power() # Toggle power 64 | nl.set_color((255, 0, 0)) # Set colour to red 65 | ``` 66 | 67 | ![Example setup](https://github.com/MylesMor/nanoleafapi/blob/master/photos/nanoleafapi_new_example.png?raw=true) 68 | 69 | ## Methods 70 | 71 | All of the following methods can be called with the Nanoleaf object you created. 72 | 73 | For more information about the Nanoleaf API: https://forum.nanoleaf.me/docs/openapi 74 | 75 | For more in-depth documentation about this package visit: https://nanoleafapi.readthedocs.io 76 | 77 | #### User Management 78 | ```py 79 | create_auth_token() # Creates an authentication token and stores it in the user's home directory. 80 | delete_auth_token() # Deletes an authentication token from the device and the token storage file. 81 | ``` 82 | 83 | #### General 84 | ```py 85 | get_info() # Returns device information dictionary 86 | get_name() # Returns the current device name 87 | check_connection() # Raises NanoleafConnectionError if connection fails 88 | ``` 89 | 90 | #### Power 91 | ```py 92 | get_power() # Returns True if lights are on, otherwise False 93 | power_off() # Powers off the lights 94 | power_on() # Powers on the lights 95 | toggle_power() # Toggles light on/off 96 | ``` 97 | 98 | #### Colour 99 | Colours are generated using HSV (or HSB) in the API, and these individual values can be adjusted using methods which are as described, [hue](#Hue), [saturation](#Saturation), [brightness/value](#Brightness). The method in this section uses RGB (0-255) and converts this to HSV. 100 | 101 | There are already some pre-set colours which can be imported to be used with the ``set_color()`` method: 102 | 103 | ```py 104 | from nanoleafapi import RED, ORANGE, YELLOW, GREEN, LIGHT_BLUE, BLUE, PINK, PURPLE, WHITE 105 | ``` 106 | 107 | The `set_color()` method can then be called, passing in either a pre-set colour or your own RGB colour in the form of a tuple: `(r, g, b)`. 108 | 109 | ```py 110 | set_color((r, g, b)) # Set all lights to RGB colour. Pass the colour as a tuple. 111 | set_color(RED) # Same result but using a pre-set colour. 112 | ``` 113 | 114 | #### Brightness 115 | ```py 116 | set_brightness(brightness, duration) # Sets the brightness of the lights (accepts values between 0-100) 117 | increment_brightness(value) # Increments the brightness by set amount (can also be negative) 118 | get_brightness() # Returns current brightness 119 | ``` 120 | 121 | #### Hue 122 | Use these if you want to change the HSV values manually, otherwise use `set_color()` for colour change using RGB. 123 | ```py 124 | set_hue(value) # Sets the hue of the lights (accepts values between 0-360) 125 | increment_hue(value) # Increments the hue by set amount (can also be negative) 126 | get_hue() # Returns current hue 127 | ``` 128 | 129 | #### Saturation 130 | Use these if you want to change the HSV values manually, otherwise use `set_color()` for colour change using RGB. 131 | 132 | ```py 133 | set_saturation(value) # Sets the saturation of the lights (accepts value between 0-100) 134 | increment_saturation(value) # Increments the saturation by set amount (can also be negative) 135 | get_saturation() # Returns current saturation 136 | ``` 137 | 138 | #### Identify 139 | This is usually used to identify the current lights by flashing them on and off. 140 | ```py 141 | identify() 142 | ``` 143 | 144 | #### Colour Temperature 145 | ```py 146 | set_color_temp(value) # Sets the colour temperature of the lights (accepts between 1200-6500) 147 | increment_color_temp(value) # Increments the colour temperature by set amount (can also be negative) 148 | get_color_temp() # Returns current colour temperature 149 | ``` 150 | 151 | #### Colour Mode 152 | 153 | ```py 154 | get_color_mode() # Returns current colour mode 155 | ``` 156 | 157 | ### Effects 158 | ```py 159 | get_current_effect() # Returns either name of current effect if available or *Solid*/*Static*/*Dynamic*. 160 | list_effects() # Returns a list of names of all available effects. 161 | effect_exists(name) # Helper method which determines whether the given string exists as an effect. 162 | set_effect(name) # Sets the current effect. 163 | ``` 164 | 165 | #### Custom Effects 166 | ```py 167 | pulsate((r, g, b), speed) # Displays a pulsate effect with the specified colour and speed. 168 | flow([(r, g, b), (r, g, b), ...], speed) # Displays a sequence of specified colours and speed. 169 | spectrum(speed) # Displays a spectrum cycling effect with the specified speed. 170 | ``` 171 | 172 | #### Write Effect 173 | ```py 174 | write_effect(effect_dict) # Sets a user-created effect. 175 | ``` 176 | Writing effects is rather complicated; you need to follow the the exact format for the effect dictionary, which can be found here: https://forum.nanoleaf.me/docs/openapi#_u2t4jzmkp8nt 177 | 178 | In future updates, I hope to add a way to make this process easier, but for now an example of a valid effect dictionary is provided below: 179 | 180 | ```py 181 | effect_data = { 182 | "command": "display", 183 | "animName": "New animation", 184 | "animType": "random", 185 | "colorType": "HSB", 186 | "animData": None, 187 | "palette": [ 188 | { 189 | "hue": 0, 190 | "saturation": 100, 191 | "brightness": 100 192 | }, 193 | { 194 | "hue": 120, 195 | "saturation": 100, 196 | "brightness": 100 197 | }, 198 | { 199 | "hue": 180, 200 | "saturation": 100, 201 | "brightness": 100 202 | } 203 | ], 204 | "brightnessRange": { 205 | "minValue": 50, 206 | "maxValue": 100 207 | }, 208 | "transTime": { 209 | "minValue": 50, 210 | "maxValue": 100 211 | }, 212 | "delayTime": { 213 | "minValue": 50, 214 | "maxValue": 100 215 | }, 216 | "loop": True 217 | } 218 | ``` 219 | 220 | Inputting an invalid dictionary will raise a NanoleafEffectCreationError. 221 | 222 | #### Enable UDP Streaming 223 | ```py 224 | nl.enable_extcontrol() # Enables UDP extControl API 225 | ``` 226 | This enables the UDP extControl API, which is detailed further in the [documentation](https://forum.nanoleaf.me/docs/openapi#_9gd8j3cnjaju). There is also an example provided by [@erhan-](https://www.github.com/erhan-) in their PR for adding this feature, which gives a great first example. This can be found [here](https://github.com/MylesMor/nanoleafapi/pull/15#issuecomment-1137766460). 227 | 228 | ### Events 229 | Creates an event listener for the different types of events. 230 | 231 | ```py 232 | register_event(function, event_types) 233 | ``` 234 | You should pass your own function with one argument (event as a dictionary). This function will run every time a new event is received. 235 | 236 | __IMPORTANT__: You cannot currently call ```register_event()``` more than __once__ due to API limitations. Instead, distinguish between the events in your function using the dictionary data. 237 | 238 | A list of event types you would like to listen for should also be passed. You can register up to 4 events (all of them), and these are listed below: 239 | 240 | Event IDs: 241 | ``` 242 | State (changes in power/brightness): 1 243 | Layout: 2 244 | Effects: 3 245 | Touch (Canvas/Shapes only): 4 246 | ``` 247 | 248 | #### Example Usage 249 | 250 | ```py 251 | def event_function(event): 252 | print(event) 253 | 254 | # Register for all events 255 | nl.register_event(event_function, [1, 2, 3, 4]) 256 | ``` 257 | 258 | #### Example Output 259 | 260 | When an event occurs, the `event_function()` will run and therefore in this case, print the event dictionary. 261 | 262 | ```py 263 | {"events":[{"attr":2,"value":65}]} # Example of state event (1) 264 | {"events":[{"attr":1,"value":"Falling Whites"}]} # Example of effects event (3) 265 | {"events":[{"panelId":7397,"gesture":0}]} # Example of touch event (4) 266 | ``` 267 | 268 | ## NanoleafDigitalTwin 269 | 270 | This class is used to make a digital twin (or copy) of the Nanoleaf device, allowing you to change the colour of individual tiles and then sync all the changes 271 | at once to the real device. 272 | 273 | To create an instance of this class, you must initialise it with a Nanoleaf object: 274 | 275 | ```py 276 | from nanoleafapi import Nanoleaf, NanoleafDigitalTwin 277 | 278 | nl = Nanoleaf("192.168.0.2") 279 | digital_twin = NanoleafDigitalTwin(nl) 280 | ``` 281 | 282 | ### Utility 283 | 284 | ```py 285 | nl.get_ids() # Returns a list of panel IDs 286 | ``` 287 | 288 | ### Colour 289 | 290 | Setting the colour is all managed by using an RGB tuple, in the format: `(R, G, B)`. 291 | 292 | ```py 293 | digital_twin.set_color(panel_id, (255, 255, 255)) # Sets the panel with specified ID to white 294 | digital_twin.set_all_colors((255, 255, 255)) # Sets all panels to white 295 | digital_twin.get_color(panel_id) # Gets the colour of a specified panel 296 | digital_twin.get_all_colors() # Returns a dictionary of {panel_id: (R, G, B)} 297 | ``` 298 | 299 | ### Sync 300 | The sync method applies the changes to the real Nanoleaf device, based on the changes made here. 301 | 302 | ```py 303 | digital_twin.sync() # Syncs with the real Nanoleaf counterpart 304 | ``` 305 | 306 | ### Full NanoleafDigitalTwin example 307 | 308 | ```py 309 | from nanoleafapi import Nanoleaf, NanoleafDigitalTwin 310 | import random 311 | 312 | nl = Nanoleaf("ip") 313 | digital_twin = NanoleafDigitalTwin(nl) 314 | 315 | # get a list of all panels 316 | panels = nl.get_ids() 317 | 318 | for panel_id in panels: 319 | # set each panel to a random RGB value 320 | digital_twin.set_color(panel_id, ( 321 | random.randint(0,255), 322 | random.randint(0,255), 323 | random.randint(0,255), 324 | )) 325 | 326 | # call 'sync' to copy the "twin" state to the actual panels 327 | digital_twin.sync() 328 | ``` 329 | 330 | ## Errors 331 | ```py 332 | NanoleafRegistrationError() # Raised when token generation mode not active on device 333 | NanoleafConnectionError() # Raised when there is a connection error during check_connection() method 334 | NanoleafEffectCreationError() # Raised when there is an error with an effect dictionary/method arguments 335 | ``` 336 | -------------------------------------------------------------------------------- /nanoleafapi/nanoleaf.py: -------------------------------------------------------------------------------- 1 | """nanoleafapi 2 | 3 | This module is a Python 3 wrapper for the Nanoleaf OpenAPI. 4 | It provides an easy way to use many of the functions available in the API. 5 | It supports the Light Panels (previously Aurora), Canvas and Shapes (including Hexgaons).""" 6 | 7 | import json 8 | from threading import Thread 9 | import colorsys 10 | import os 11 | from typing import Any, List, Dict, Tuple, Union, Callable, Optional 12 | from sseclient import SSEClient 13 | import requests 14 | 15 | # Preset colours 16 | RED = (255, 0, 0) 17 | ORANGE = (255, 165, 0) 18 | YELLOW = (255, 255, 0) 19 | GREEN = (0, 255, 0) 20 | LIGHT_BLUE = (173, 216, 230) 21 | BLUE = (0, 0, 255) 22 | PINK = (255, 192, 203) 23 | PURPLE = (128, 0, 128) 24 | WHITE = (255, 255, 255) 25 | 26 | class Nanoleaf(): 27 | """The Nanoleaf class for controlling the Light Panels and Canvas 28 | 29 | :ivar ip: IP of the Nanoleaf device 30 | :ivar url: The base URL for requests 31 | :ivar auth_token: The authentication token for the API 32 | :ivar print_errors: True for errors to be shown, otherwise False 33 | """ 34 | 35 | def __init__(self, ip : str, auth_token : str =None, print_errors : bool =False): 36 | """Initalises Nanoleaf class with desired arguments. 37 | 38 | :param ip: The IP address of the Nanoleaf device 39 | :param auth_token: Optional, include Nanoleaf authentication 40 | token here if required. 41 | :param print_errors: Optional, True to show errors in the console 42 | 43 | :type ip: str 44 | :type auth_token: str 45 | :type print_errors: bool 46 | """ 47 | self.ip = ip 48 | self.print_errors = print_errors 49 | self.url = "http://" + ip + ":16021/api/v1/" + str(auth_token) 50 | self.check_connection() 51 | if auth_token is None: 52 | self.auth_token = self.create_auth_token() 53 | if self.auth_token is None: 54 | raise NanoleafRegistrationError() 55 | else: 56 | self.auth_token = auth_token 57 | self.url = "http://" + ip + ":16021/api/v1/" + str(self.auth_token) 58 | self.already_registered = False 59 | 60 | 61 | def __error_check(self, code : int) -> bool: 62 | """Checks and displays error messages 63 | 64 | Determines the request status code and prints the error, if print_errors 65 | is true. 66 | 67 | :param code: The error code 68 | 69 | :returns: Returns True if request was successful, otherwise False 70 | """ 71 | if self.print_errors: 72 | if code in (200, 204): 73 | print(str(code) + ": Action performed successfully.") 74 | return True 75 | if code == 400: 76 | print("Error 400: Bad request.") 77 | elif code == 401: 78 | print("Error 401: Unauthorized, invalid auth token. " + 79 | "Please generate a new one.") 80 | elif code == 403: 81 | print("Error 403: Unauthorized, please hold the power " + 82 | "button on the controller for 5-7 seconds, then try again.") 83 | elif code == 404: 84 | print("Error 404: Resource not found.") 85 | elif code == 500: 86 | print("Error 500: Internal server error.") 87 | return False 88 | return bool(code in (200, 204)) 89 | 90 | def create_auth_token(self) -> Union[str, None]: 91 | """Creates or retrives the device authentication token 92 | 93 | The power button on the device should be held for 5-7 seconds, then 94 | this method should be run. This will set both the auth_token and url 95 | instance variables, and save the token in a file for future instances 96 | of the Nanoleaf object. 97 | 98 | :returns: Token if successful, None if not. 99 | """ 100 | file_path = os.path.expanduser('~') + os.path.sep + '.nanoleaf_token' 101 | if os.path.exists(file_path) is False: 102 | with open(file_path, 'w', encoding='utf-8'): 103 | pass 104 | with open(file_path, 'r', encoding='utf-8') as token_file: 105 | tokens = token_file.readlines() 106 | for token in tokens: 107 | if token != "": 108 | token = token.rstrip() 109 | response = requests.get("http://" + self.ip + ":16021/api/v1/" + str(token)) 110 | if self.__error_check(response.status_code): 111 | return token 112 | 113 | response = requests.post('http://' + self.ip + ':16021/api/v1/new') 114 | 115 | # process response 116 | if response and response.status_code == 200: 117 | data = json.loads(response.text) 118 | 119 | if 'auth_token' in data: 120 | with open(file_path, 'a', encoding='utf-8') as file: 121 | file.write("\n" + data['auth_token']) 122 | return data['auth_token'] 123 | return None 124 | 125 | 126 | def delete_auth_token(self, auth_token : str) -> bool: 127 | """Deletes an authentication token 128 | 129 | Deletes an authentication token and the .nanoleaf_token file if it 130 | contains the auth token to delete. This token can no longer be used 131 | as part of an API call to control the device. If required, generate 132 | a new one using create_auth_token(). 133 | 134 | :param auth_token: The authentication token to delete. 135 | 136 | :returns: True if successful, otherwise False 137 | """ 138 | url = "http://" + self.ip + ":16021/api/v1/" + str(auth_token) 139 | response = requests.delete(url) 140 | return self.__error_check(response.status_code) 141 | 142 | def check_connection(self) -> None: 143 | """Ensures there is a valid connection""" 144 | try: 145 | requests.get(self.url, timeout=5) 146 | except Exception as connection_error: 147 | raise NanoleafConnectionError() from connection_error 148 | 149 | def get_info(self) -> Dict[str, Any]: 150 | """Returns a dictionary of device information""" 151 | response = requests.get(self.url) 152 | return json.loads(response.text) 153 | 154 | def get_name(self) -> str: 155 | """Returns the name of the current device""" 156 | return self.get_info()['name'] 157 | 158 | def get_auth_token(self) -> Optional[str]: 159 | """Returns the current auth token or None""" 160 | return self.auth_token 161 | 162 | def get_ids(self) -> List[int]: 163 | """Returns a list of all device ids""" 164 | position_data = [] 165 | device_ids = [] 166 | info_data = self.get_info() 167 | 168 | if ('panelLayout' in info_data and 'layout' in info_data['panelLayout'] and 169 | 'positionData' in info_data['panelLayout']['layout']): 170 | position_data = info_data['panelLayout']['layout']['positionData'] 171 | 172 | # process position data 173 | for data in position_data: 174 | device_ids.append(data['panelId']) 175 | 176 | return device_ids 177 | 178 | @staticmethod 179 | def get_custom_base_effect(anim_type : str ='custom', loop : bool =True) -> Dict[str, Any]: 180 | """Returns base custom effect dictionary""" 181 | base_effect = { 182 | 'command': 'display', 183 | 'animType': anim_type, 184 | 'loop': loop, 185 | 'palette': [] 186 | } 187 | return base_effect 188 | 189 | 190 | ####################################################### 191 | #### POWER #### 192 | ####################################################### 193 | 194 | def power_off(self) -> bool: 195 | """Powers off the lights 196 | 197 | :returns: True if successful, otherwise False 198 | """ 199 | data = {"on" : {"value": False}} 200 | response = requests.put(self.url + "/state", data=json.dumps(data)) 201 | return self.__error_check(response.status_code) 202 | 203 | def power_on(self) -> bool: 204 | """Powers on the lights 205 | 206 | :returns: True if successful, otherwise False 207 | """ 208 | data = {"on" : {"value": True}} 209 | response = requests.put(self.url + "/state", data=json.dumps(data)) 210 | return self.__error_check(response.status_code) 211 | 212 | def get_power(self) -> bool: 213 | """Returns the power status of the lights 214 | 215 | :returns: True if on, False if off 216 | """ 217 | response = requests.get(self.url + "/state/on") 218 | ans = json.loads(response.text) 219 | return ans['value'] 220 | 221 | def toggle_power(self) -> bool: 222 | """Toggles the lights on/off""" 223 | if self.get_power(): 224 | return self.power_off() 225 | return self.power_on() 226 | 227 | ####################################################### 228 | #### COLOUR #### 229 | ####################################################### 230 | 231 | def set_color(self, rgb : Tuple[int, int, int]) -> bool: 232 | """Sets the colour of the lights 233 | 234 | :param rgb: Tuple in the format (r, g, b) 235 | 236 | :returns: True if successful, otherwise False 237 | """ 238 | hsv_colour = colorsys.rgb_to_hsv(rgb[0]/255, rgb[1]/255, rgb[2]/255) 239 | hsv_colour_list = list(hsv_colour) 240 | hsv_colour_list[0] *= 360 241 | hsv_colour_list[1] *= 100 242 | hsv_colour_list[2] *= 100 243 | final_colour = [ int(x) for x in hsv_colour_list ] 244 | data = { 245 | "hue" : {"value": final_colour[0]}, 246 | "sat": {"value": final_colour[1]}, 247 | "brightness": {"value": final_colour[2], "duration": 0} 248 | } 249 | response = requests.put(self.url + "/state", data=json.dumps(data)) 250 | return self.__error_check(response.status_code) 251 | 252 | 253 | ####################################################### 254 | #### ADJUST BRIGHTNESS #### 255 | ####################################################### 256 | 257 | def set_brightness(self, brightness : int, duration : int =0) -> bool: 258 | """Sets the brightness of the lights 259 | 260 | :param brightness: The required brightness (between 0 and 100) 261 | :param duration: The duration over which to change the brightness 262 | 263 | :returns: True if successful, otherwise False 264 | """ 265 | if brightness > 100 or brightness < 0: 266 | raise ValueError('Brightness should be between 0 and 100') 267 | data = {"brightness" : {"value": brightness, "duration": duration}} 268 | response = requests.put(self.url + "/state", data=json.dumps(data)) 269 | return self.__error_check(response.status_code) 270 | 271 | def increment_brightness(self, brightness : int) -> bool: 272 | """Increments the brightness of the lights 273 | 274 | :param brightness: How much to increment the brightness, can 275 | also be negative 276 | 277 | :returns: True if successful, otherwise False 278 | """ 279 | data = {"brightness" : {"increment": brightness}} 280 | response = requests.put(self.url + "/state", data = json.dumps(data)) 281 | return self.__error_check(response.status_code) 282 | 283 | def get_brightness(self) -> int: 284 | """Returns the current brightness value of the lights""" 285 | response = requests.get(self.url + "/state/brightness") 286 | ans = json.loads(response.text) 287 | return ans['value'] 288 | 289 | ####################################################### 290 | #### IDENTIFY #### 291 | ####################################################### 292 | 293 | def identify(self) -> bool: 294 | """Runs the identify sequence on the lights 295 | 296 | :returns: True if successful, otherwise False 297 | """ 298 | response = requests.put(self.url + "/identify") 299 | return self.__error_check(response.status_code) 300 | 301 | ####################################################### 302 | #### HUE #### 303 | ####################################################### 304 | 305 | def set_hue(self, value : int) -> bool: 306 | """Sets the hue of the lights 307 | 308 | :param value: The required hue (between 0 and 360) 309 | 310 | :returns: True if successful, otherwise False 311 | """ 312 | if value > 360 or value < 0: 313 | raise ValueError('Hue should be between 0 and 360') 314 | data = {"hue" : {"value" : value}} 315 | response = requests.put(self.url + "/state", data=json.dumps(data)) 316 | return self.__error_check(response.status_code) 317 | 318 | def increment_hue(self, value : int) -> bool: 319 | """Increments the hue of the lights 320 | 321 | :param value: How much to increment the hue, can also be negative 322 | 323 | :returns: True if successful, otherwise False 324 | """ 325 | data = {"hue" : {"increment" : value}} 326 | response = requests.put(self.url + "/state", data=json.dumps(data)) 327 | return self.__error_check(response.status_code) 328 | 329 | def get_hue(self) -> int: 330 | """Returns the current hue value of the lights""" 331 | response = requests.get(self.url + "/state/hue") 332 | ans = json.loads(response.text) 333 | return ans['value'] 334 | 335 | ####################################################### 336 | #### SATURATION #### 337 | ####################################################### 338 | 339 | def set_saturation(self, value : int) -> bool: 340 | """Sets the saturation of the lights 341 | 342 | :param value: The required saturation (between 0 and 100) 343 | 344 | :returns: True if successful, otherwise False 345 | """ 346 | if value > 100 or value < 0: 347 | raise ValueError('Saturation should be between 0 and 100') 348 | data = {"sat" : {"value" : value}} 349 | response = requests.put(self.url + "/state", data=json.dumps(data)) 350 | return self.__error_check(response.status_code) 351 | 352 | def increment_saturation(self, value : int) -> bool: 353 | """Increments the saturation of the lights 354 | 355 | :param brightness: How much to increment the saturation, can also be 356 | negative. 357 | 358 | :returns: True if successful, otherwise False 359 | """ 360 | data = {"sat" : {"increment" : value}} 361 | response = requests.put(self.url + "/state", data=json.dumps(data)) 362 | return self.__error_check(response.status_code) 363 | 364 | def get_saturation(self) -> int: 365 | """Returns the current saturation value of the lights""" 366 | response = requests.get(self.url + "/state/sat") 367 | ans = json.loads(response.text) 368 | return ans['value'] 369 | 370 | ####################################################### 371 | #### COLOUR TEMPERATURE #### 372 | ####################################################### 373 | 374 | def set_color_temp(self, value : int) -> bool: 375 | """Sets the white colour temperature of the lights 376 | 377 | :param value: The required colour temperature (between 0 and 100) 378 | 379 | :returns: True if successful, otherwise False 380 | """ 381 | if value > 6500 or value < 1200: 382 | raise ValueError('Colour temp should be between 1200 and 6500') 383 | data = {"ct" : {"value" : value}} 384 | response = requests.put(self.url + "/state", json.dumps(data)) 385 | return self.__error_check(response.status_code) 386 | 387 | def increment_color_temp(self, value : int) -> bool: 388 | """Sets the white colour temperature of the lights 389 | 390 | :param value: How much to increment the colour temperature by, can also 391 | be negative. 392 | 393 | :returns: True if successful, otherwise False 394 | """ 395 | data = {"ct" : {"increment" : value}} 396 | response = requests.put(self.url + "/state", json.dumps(data)) 397 | return self.__error_check(response.status_code) 398 | 399 | def get_color_temp(self) -> int: 400 | """Returns the current colour temperature of the lights""" 401 | response = requests.get(self.url + "/state/ct") 402 | ans = json.loads(response.text) 403 | return ans['value'] 404 | 405 | ####################################################### 406 | #### COLOUR MODE #### 407 | ####################################################### 408 | 409 | def get_color_mode(self) -> str: 410 | """Returns the colour mode of the lights""" 411 | response = requests.get(self.url + "/state/colorMode") 412 | return json.loads(response.text) 413 | 414 | ####################################################### 415 | #### EFFECTS #### 416 | ####################################################### 417 | 418 | def get_current_effect(self) -> str: 419 | """Returns the currently selected effect 420 | 421 | If the name of the effect isn't available, this will return 422 | *Solid*, *Dynamic* or *Static* instead. 423 | 424 | :returns: Name of the effect or type if unavailable. 425 | """ 426 | response = requests.get(self.url + "/effects/select") 427 | return json.loads(response.text) 428 | 429 | def set_effect(self, effect_name : str) -> bool: 430 | """Sets the effect of the lights 431 | 432 | :param effect_name: The name of the effect 433 | 434 | :returns: True if successful, otherwise False 435 | """ 436 | data = {"select": effect_name} 437 | response = requests.put(self.url + "/effects", data=json.dumps(data)) 438 | return self.__error_check(response.status_code) 439 | 440 | def list_effects(self) -> List[str]: 441 | """Returns a list of available effects""" 442 | response = requests.get(self.url + "/effects/effectsList") 443 | return json.loads(response.text) 444 | 445 | def write_effect(self, effect_dict : Dict['str', Any]) -> bool: 446 | """Writes a user-defined effect to the panels 447 | 448 | :param effect_dict: The effect dictionary in the format 449 | described here: https://forum.nanoleaf.me/docs/openapi#_u2t4jzmkp8nt 450 | 451 | :raises NanoleafEffectCreationError: When invalid effect dictionary is provided. 452 | 453 | :returns: True if successful, otherwise False 454 | """ 455 | response = requests.put(self.url + "/effects", data=json.dumps({"write": effect_dict})) 456 | if response.status_code == 400: 457 | raise NanoleafEffectCreationError("Invalid effect dictionary") 458 | return self.__error_check(response.status_code) 459 | 460 | def effect_exists(self, effect_name : str) -> bool: 461 | """Verifies whether an effect exists 462 | 463 | :param effect_name: Name of the effect to verify 464 | 465 | :returns: True if effect exists, otherwise False 466 | """ 467 | response = requests.get(self.url + "/effects/effectsList") 468 | if effect_name in json.loads(response.text): 469 | return True 470 | return False 471 | 472 | def pulsate(self, rgb : Tuple[int, int, int], speed : float = 1) -> bool: 473 | """Displays a pulsating effect on the device with two colours 474 | 475 | :param rgb: A tuple containing the RGB colour to pulsate in the format (r, g, b). 476 | :param speed: The speed of the transition between colours in seconds, 477 | with a maximum of 1 decimal place. 478 | 479 | :raises NanoleafEffectCreationError: When an invalid rgb value is provided. 480 | 481 | :returns: True if the effect was created and displayed successfully, otherwise False 482 | """ 483 | if len(rgb) != 3: 484 | raise NanoleafEffectCreationError("There must be three values in the " + 485 | "RGB tuple! E.g., (255, 0, 0)") 486 | for colour in rgb: 487 | if not isinstance(colour, int): 488 | raise NanoleafEffectCreationError("All values in the tuple must be " + 489 | "integers! E.g., (255, 0, 0)") 490 | if colour < 0 or colour > 255: 491 | raise NanoleafEffectCreationError("All values in the tuple must be " + 492 | "integers between 0 and 255! E.g., (255, 0, 0)") 493 | base_effect = self.get_custom_base_effect() 494 | ids = self.get_ids() 495 | anim_data = str(len(ids)) 496 | frame_string = "" 497 | for device_id in ids: 498 | frame_string += f" {device_id} 2" 499 | r, g, b = rgb[0], rgb[1], rgb[2] 500 | speed = int(speed*10) 501 | speed_2 = int(speed*10) 502 | frame_string += f" {r} {g} {b} 0 {speed} 0 0 0 0 {speed_2}" 503 | base_effect['animData'] = anim_data + frame_string 504 | return self.write_effect(base_effect) 505 | 506 | def flow(self, rgb_list : List[Tuple[int, int, int]], speed : float = 1) -> bool: 507 | """Displays a sequence of specified colours on the device. 508 | 509 | :param rgb: A list of tuples containing RGB colours to flow between in the format (r, g, b). 510 | :param speed: The speed of the transition between colours in seconds, with a maximum of 511 | 1 decimal place. 512 | 513 | :raises NanoleafEffectCreationError: When an invalid rgb_list is provided. 514 | 515 | :returns: True if the effect was created and displayed successfully, otherwise False 516 | """ 517 | if len(rgb_list) <= 1: 518 | raise NanoleafEffectCreationError("There has to be more than one tuple in " + 519 | "the RGB list for this effect! E.g., [(255, 0, 0), (0, 0, 0)]") 520 | for tup in rgb_list: 521 | if len(tup) != 3: 522 | raise NanoleafEffectCreationError("There must be three values in the " + 523 | "RGB tuple! E.g., (255, 0, 0)") 524 | for colour in tup: 525 | if not isinstance(colour, int): 526 | raise NanoleafEffectCreationError("All values in the tuple must " + 527 | "be integers! E.g., (255, 0, 0)") 528 | if colour < 0 or colour > 255: 529 | raise NanoleafEffectCreationError("All values in the tuple must " + 530 | "be integers between 0 and 255! E.g., (255, 0, 0)") 531 | base_effect = self.get_custom_base_effect() 532 | ids = self.get_ids() 533 | anim_data = str(len(ids)) 534 | frame_string = "" 535 | for device_id in ids: 536 | number_frames = len(rgb_list) 537 | frame_string += f" {device_id} {number_frames}" 538 | for rgb in rgb_list: 539 | r, g, b = rgb[0], rgb[1], rgb[2] 540 | speed = int(speed*10) 541 | frame_string += f" {r} {g} {b} 0 {speed}" 542 | base_effect['animData'] = anim_data + frame_string 543 | return self.write_effect(base_effect) 544 | 545 | def spectrum(self, speed : float = 1) -> bool: 546 | """Displays a spectrum cycling effect on the device 547 | 548 | :param speed: The speed of the transition between colours in seconds, 549 | with a maximum of 1 decimal place. 550 | 551 | :returns: True if the effect was created and displayed successfully, 552 | otherwise False 553 | """ 554 | base_effect = self.get_custom_base_effect() 555 | ids = self.get_ids() 556 | spectrum_palette = [] 557 | for hue in range(0, 360, 10): 558 | (r, g, b) = colorsys.hsv_to_rgb(hue/360, 1.0, 1.0) 559 | spectrum_palette.append((int(255*r), int(255*g), int(255*b))) 560 | anim_data = str(len(ids)) 561 | frame_string = "" 562 | for device_id in ids: 563 | number_frames = len(spectrum_palette) 564 | frame_string += f" {device_id} {number_frames}" 565 | for rgb in spectrum_palette: 566 | r, g, b = rgb[0], rgb[1], rgb[2] 567 | speed = int(speed*10) 568 | frame_string += f" {r} {g} {b} 0 {speed}" 569 | base_effect['animData'] = anim_data + frame_string 570 | return self.write_effect(base_effect) 571 | 572 | def enable_extcontrol(self) -> bool: 573 | """Enables the extControl UDP streaming mode 574 | 575 | :returns: True if successful, otherwise False 576 | """ 577 | data = {"write": {"command": "display", 578 | "animType": "extControl", 579 | "extControlVersion": "v2"}} 580 | response = requests.put(self.url + "/effects", data=json.dumps(data)) 581 | return self.__error_check(response.status_code) 582 | 583 | ####################################################### 584 | #### LAYOUT #### 585 | ####################################################### 586 | 587 | def get_layout(self) -> Dict[str, Any]: 588 | """Returns the device layout information""" 589 | response = requests.get(self.url + "/panelLayout/layout") 590 | return json.loads(response.text) 591 | 592 | ####################################################### 593 | #### EVENTS #### 594 | ####################################################### 595 | 596 | def register_event(self, func : Callable[[Dict[str, Any]], Any], 597 | event_types : List[int]) -> None: 598 | """Starts a thread to register and listen for events 599 | 600 | Creates an event listener. This method can only be called once per 601 | program run due to API limitations. 602 | 603 | :param func: The function to run when an event is recieved (this 604 | should be defined by the user with one argument). This function 605 | will recieve the event as a dictionary. 606 | :param event_types: A list containing up to 4 numbers from 607 | 1-4 corresponding to the relevant events to be registered for. 608 | 1 = state (power/brightness), 609 | 2 = layout, 610 | 3 = effects, 611 | 4 = touch (Canvas only) 612 | """ 613 | 614 | if self.already_registered: 615 | print("Cannot register events more than once.") 616 | return 617 | if len(event_types) > 4 or len(event_types) < 1: 618 | raise Exception("The number of events to register for must be" + 619 | "between 1-4") 620 | for event in event_types: 621 | if event < 1 or event > 4: 622 | raise Exception("Valid event types must be between 1-4") 623 | self.already_registered = True 624 | thread = Thread(target=self.__event_listener, args=(func, set(event_types))) 625 | thread.daemon = True 626 | thread.start() 627 | 628 | def __event_listener(self, func : Callable[[Dict[str, Any]], Any], 629 | event_types : List[int]) -> None: 630 | """Listens for events and passes event data to the user-defined 631 | function.""" 632 | url = self.url + "/events?id=" 633 | for event in event_types: 634 | url += str(event) + "," 635 | client = SSEClient(url[:-1]) 636 | for event in client: 637 | func(json.loads(str(event))) 638 | 639 | 640 | ####################################################### 641 | #### ERRORS #### 642 | ####################################################### 643 | 644 | class NanoleafRegistrationError(Exception): 645 | """Raised when an issue during device registration.""" 646 | 647 | def __init__(self) -> None: 648 | message = """Authentication token generation failed. Hold the power 649 | button on your Nanoleaf device for 5-7 seconds and try again.""" 650 | super().__init__(message) 651 | 652 | 653 | class NanoleafConnectionError(Exception): 654 | """Raised when the connection to the Nanoleaf device fails.""" 655 | 656 | def __init__(self) -> None: 657 | message = "Connection to Nanoleaf device failed. Is this the correct IP?" 658 | super().__init__(message) 659 | 660 | 661 | class NanoleafEffectCreationError(Exception): 662 | """Raised when one of the custom effects creation has incorrect arguments.""" 663 | --------------------------------------------------------------------------------