pax_global_header00006660000000000000000000000064144773456510014532gustar00rootroot0000000000000052 comment=bfd1bbef9d3a6e2f168a422cb9b3a350475defec Danielhiversen-flux_led-bfd1bbe/000077500000000000000000000000001447734565100171215ustar00rootroot00000000000000Danielhiversen-flux_led-bfd1bbe/.coveragerc000066400000000000000000000007701447734565100212460ustar00rootroot00000000000000[run] source = flux_led omit = flux_led/fluxled.py [report] # Regexes for lines to exclude from consideration exclude_lines = # Have to re-enable the standard pragma pragma: no cover # Don't complain about missing debug-only code: def __repr__ # Don't complain if tests don't hit defensive assertion code: raise AssertionError raise NotImplementedError # TYPE_CHECKING and @overload blocks are never executed during pytest run if TYPE_CHECKING: @overload Danielhiversen-flux_led-bfd1bbe/.github/000077500000000000000000000000001447734565100204615ustar00rootroot00000000000000Danielhiversen-flux_led-bfd1bbe/.github/stale.yml000066400000000000000000000012541447734565100223160ustar00rootroot00000000000000# Number of days of inactivity before an issue becomes stale daysUntilStale: 60 # Number of days of inactivity before a stale issue is closed daysUntilClose: 7 # Issues with these labels will never be considered stale exemptLabels: - pinned - security # Label to use when marking an issue as stale staleLabel: wontfix # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. # Comment to post when closing a stale issue. Set to `false` to disable closeComment: false Danielhiversen-flux_led-bfd1bbe/.github/workflows/000077500000000000000000000000001447734565100225165ustar00rootroot00000000000000Danielhiversen-flux_led-bfd1bbe/.github/workflows/python-package.yml000066400000000000000000000031401447734565100261510ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: Python package on: push: branches: [master] pull_request: branches: [master] jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: [3.8, 3.9, "3.10", "3.11"] 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 python -m pip install flake8 pytest .[test] -r requirements.txt -r requirements_test.txt - name: mypy run: | mypy flux_led - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics flake8 . --count --max-complexity=10 --max-line-length=127 --statistics - name: Test with pytest run: | pytest --cov=flux_led --cov-report term-missing --cov-report xml -- tests.py tests_aio.py - name: Upload codecov uses: codecov/codecov-action@v2 Danielhiversen-flux_led-bfd1bbe/.github/workflows/python-publish.yml000066400000000000000000000015161447734565100262310ustar00rootroot00000000000000 # This workflows will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: Upload Python Package on: release: types: [created] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python uses: actions/setup-python@v2 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install setuptools wheel twine - name: Build and publish env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | python setup.py sdist bdist_wheel twine upload dist/* Danielhiversen-flux_led-bfd1bbe/.gitignore000066400000000000000000000013551447734565100211150ustar00rootroot00000000000000# ---> Python # 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/ *.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 # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/_build/ # PyBuilder target/ .vscode/settings.json Danielhiversen-flux_led-bfd1bbe/.pylintrc000066400000000000000000000000341447734565100207630ustar00rootroot00000000000000[FORMAT] max-line-length=240Danielhiversen-flux_led-bfd1bbe/.vscode/000077500000000000000000000000001447734565100204625ustar00rootroot00000000000000Danielhiversen-flux_led-bfd1bbe/.vscode/tasks.json000066400000000000000000000052341447734565100225060ustar00rootroot00000000000000{ "version": "2.0.0", "tasks": [ { "label": "Pytest", "type": "shell", "command": "pytest tests.py tests_aio.py", "dependsOn": ["Install all Test Requirements"], "group": { "kind": "test", "isDefault": true }, "presentation": { "reveal": "always", "panel": "new" }, "problemMatcher": [] }, { "label": "Flake8", "type": "shell", "command": "flake8 --statistics --show-source --count --select=E9,F63,F7,F82 --max-complexity=10 --max-line-length=127", "dependsOn": ["Install all Test Requirements"], "group": { "kind": "test", "isDefault": true }, "presentation": { "reveal": "always", "panel": "new" }, "problemMatcher": [] }, { "label": "Pylint", "type": "shell", "command": "python3.10 -m pylint flux_led", "group": { "kind": "test", "isDefault": true }, "presentation": { "reveal": "always", "panel": "new" }, "problemMatcher": [] }, { "label": "Code Coverage", "detail": "Generate code coverage report", "type": "shell", "command": "pytest tests.py tests_aio.py --cov=flux_led --cov-report term-missing", "group": { "kind": "test", "isDefault": true }, "presentation": { "reveal": "always", "panel": "new" }, "problemMatcher": [] }, { "label": "Install all Test Requirements", "type": "shell", "command": "pip3 install -r requirements_test.txt", "group": { "kind": "build", "isDefault": true }, "presentation": { "reveal": "always", "panel": "new" }, "problemMatcher": [] }, { "label": "Install all Requirements", "type": "shell", "command": "pip3 install -r requirements.txt", "group": { "kind": "build", "isDefault": true }, "presentation": { "reveal": "always", "panel": "new" }, "problemMatcher": [] } ] } Danielhiversen-flux_led-bfd1bbe/LICENSE000066400000000000000000000167431447734565100201410ustar00rootroot00000000000000 GNU LESSER GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below. 0. Additional Definitions. As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to version 3 of the GNU General Public License. "The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined below. An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library. A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the "Linked Version". The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version. The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work. 1. Exception to Section 3 of the GNU GPL. You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL. 2. Conveying Modified Versions. If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version: a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy. 3. Object Code Incorporating Material from Library Header Files. The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following: a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the object code with a copy of the GNU GPL and this license document. 4. Combined Works. You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following: a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the Combined Work with a copy of the GNU GPL and this license document. c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document. d) Do one of the following: 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source. 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version. e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.) 5. Combined Libraries. You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License. b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 6. Revised Versions of the GNU Lesser General Public License. The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation. If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library. Danielhiversen-flux_led-bfd1bbe/README.md000066400000000000000000000320011447734565100203740ustar00rootroot00000000000000[![Python package][python-package-shield]][python-package] [![codecov][code-cover-shield]][code-coverage] \ [![Python Versions][python-ver-shield]][python-ver] [![PyPi Project][pypi-shield]][pypi]\ [![GitHub Activity][commits-shield]][commits] [![License][license-shield]](LICENSE)\ [![GitHub Top Language][language-shield]][language] ### Magic Home / Surp Life / flux_led This is a utility for controlling stand-alone [Magic Home](http://www.zengge.com/appkzd) and [Surp Life](http://www.zengge.com/newbrand) devices manufactured by [Zengge](http://www.zengge.com/sy). The protocol was reverse-engineered by studying packet captures between a bulb and the controlling "Magic Home" mobile app. The code here dealing with the network protocol is littered with magic numbers, and ain't so pretty. But it does seem to work! So far most of the functionality of the apps is available here via the CLI and/or programmatically. The classes in this project could very easily be used as an API, and incorporated into a GUI app written in PyQt, Kivy, or some other framework. #### Minimum python version 3.7 ##### Available: * Discovering bulbs on LAN * Turning on/off bulb * Get state information * Setting "warm white" mode * Setting single color mode * Setting preset pattern mode * Setting custom pattern mode * Reading timers * Setting timers * Sync clock * Music Mode for devices with a built-in microphone (asyncio version only) * Remote access administration (asyncio version only) * Device configuration including wiring order, ic type, pixels, etc (asyncio version only) ##### Some missing pieces: * Initial administration to set up WiFi SSID and passphrase/key. ##### Cool feature: * Specify colors with names or web hex values. Requires that python "webcolors" package is installed. (Easily done via pip, easy_install, or apt-get, etc.) Use --listcolors to show valid color names. ##### Installation: * Flux_led package available at https://pypi.python.org/pypi/flux-led/ ``` pip install flux_led ``` ##### Examples: ``` Scan network: flux_led -s Scan network and show info about all: flux_led -sSti Turn on: flux_led 192.168.1.100 --on flux_led 192.168.1.100 -192.168.1.101 -1 Turn on all bulbs on LAN: flux_led -sS --on Turn off: flux_led 192.168.1.100 --off flux_led 192.168.1.100 --0 flux_led -sS --off Set warm white, 75% flux_led 192.168.1.100 -w 75 -1 Set fixed color red : flux_led 192.168.1.100 -c Red flux_led 192.168.1.100 -c 255,0,0 flux_led 192.168.1.100 -c "#FF0000" Set preset pattern #35 with 40% speed: flux_led 192.168.1.100 -p 35 40 Set custom pattern 25% speed, red/green/blue, gradual change: flux_led 192.168.1.100 -C gradual 25 "red green (0,0,255)" Sync all bulb's clocks with this computer's: flux_led -sS --setclock Set timer #1 to turn on red at 5:30pm on weekdays: flux_led 192.168.1.100 -T 1 color "time:1730;repeat:12345;color:red" Deactivate timer #4: flux_led 192.168.1.100 -T 4 inactive "" Use --timerhelp for more details on setting timers ``` ##### Show help: ``` $ flux_led -h Usage: usage: __main__.py [-sS10cwpCiltThe] [addr1 [addr2 [addr3] ...]. A utility to control Flux WiFi LED Bulbs. Options: -h, --help show this help message and exit -s, --scan Search for bulbs on local network -S, --scanresults Operate on scan results instead of arg list -i, --info Info about bulb(s) state --getclock Get clock --setclock Set clock to same as current time on this computer -t, --timers Show timers -T NUM MODE SETTINGS, --settimer=NUM MODE SETTINGS Set timer. NUM: number of the timer (1-6). MODE: inactive, poweroff, default, color, preset, or warmwhite. SETTINGS: a string of settings including time, repeatdays or date, and other mode specific settings. Use --timerhelp for more details. Program help and information option: -e, --examples Show usage examples --timerhelp Show detailed help for setting timers -l, --listpresets List preset codes --listcolors List color names Power options (mutually exclusive): -1, --on Turn on specified bulb(s) -0, --off Turn off specified bulb(s) Mode options (mutually exclusive): -c COLOR, --color=COLOR Set single color mode. Can be either color name, web hex, or comma-separated RGB triple -w LEVEL, --warmwhite=LEVEL Set warm white mode (LEVEL is percent) -p CODE SPEED, --preset=CODE SPEED Set preset pattern mode (SPEED is percent) -C TYPE SPEED COLORLIST, --custom=TYPE SPEED COLORLIST Set custom pattern mode. TYPE should be jump, gradual, or strobe. SPEED is percent. COLORLIST is a space- separated list of color names, web hex values, or comma-separated RGB triples ``` ### Supported Models The following models have been tested with library. | Model | Description | Microphone | Notes | | ----- | --------------------------- | ---------- | ------------------------------- | | 0x01 | Legacy RGB Controller | no | Original protocol | | 0x03 | Legacy CCT Controller | no | Original protocol | | 0x04 | UFO Controller RGBW | no | | | 0x06 | Controller RGBW | no | | | 0x07 | Controller RGBCW | no | | | 0x08 | Controller RGB with MIC | yes | | | 0x09 | Ceiling Light CCT | no | | | 0x0E | Floor Lamp RGBCW | no | | | 0x10 | Christmas Light | no | | | 0x16 | Magnetic Light CCT | no | | | 0x17 | Magnetic Light Dimmable | no | | | 0x1A | Christmas Light | no | | | 0x1C | Table Light CCT | no | | | 0x1E | Ceiling Light RGBCW | no | | | 0x21 | Bulb Dimmable | no | | | 0x25 | Controller RGB/WW/CW | no | Supports RGB, RGBW, RGBWW, CW, DIM | | 0x33 | Controller RGB | no | | | 0x35 | Bulb RGBCW | no | | | 0x41 | Controller Dimmable | no | | | 0x44 | Bulb RGBW | no | | | 0x52 | Bulb CCT | no | | | 0x54 | Downlight RGBW | no | | | 0x62 | Controller CCT | no | | | 0x93 | Switch 1 Channel | no | | | 0x97 | Socket | no | | | 0xA1 | Addressable v1 | no | Supports UCS1903, SM16703, WS2811, WS2812B, SK6812, INK1003, WS2801, LB1914 | | 0xA2 | Addressable v2 | yes | Supports UCS1903, SM16703, WS2811, WS2811B, SK6812, INK1003, WS2801, WS2815, APA102, TM1914, UCS2904B | | 0xA3 | Addressable v3 | yes | Supports WS2812B, SM16703, SM16704, WS2811, UCS1903, SK6812, SK6812RGBW (WS2814), INK1003, UCS2904B | | 0xA4 | Addressable v4 | no | Supports WS2812B, SM16703, SM16704, WS2811, UCS1903, SK6812, SK6812RGBW (WS2814), INK1003, UCS2904B | | 0xA6 | Addressable v6 | yes | Supports WS2812B, SM16703, SM16704, WS2811, UCS1903, SK6812, SK6812RGBW (WS2814), INK1003, UCS2904B | | 0xA7 | Addressable v7 | yes | Supports WS2812B, SM16703, SM16704, WS2811, UCS1903, SK6812, SK6812RGBW (WS2814), INK1003, UCS2904B | | 0xE1 | Ceiling Light CCT | no | | | 0xE2 | Ceiling Light Assist | no | Auxiliary Switch not supported | ### Untested Models The following models have not been tested with the library but may work. | Model | Description | Microphone | Notes | | ----- | --------------------------- | ---------- | ------------------------------- | | 0x02 | Legacy Dimmable Controller | no | Original protocol, discontinued | ### Unsupported Models The following models are confirmed to be unsupported. | Model | Description | Microphone | Notes | | ----- | --------------------------- | ---------- | ------------------------------- | | 0x18 | Plant Grow Light | no | | | 0x19 | Socket with 2 USB | no | | | 0x1B | Aroma Fragrance Lamp | no | | | 0x1D | Fill Light | no | | | 0x94 | Switch 1c Watt | no | | | 0x95 | Switch 2 Channel | no | | | 0x96 | Switch 4 Channel | no | | | 0xD1 | Digital Time Lamp | no | | ### Known Vendors - Aislan - [Allkeys](http://allkeystech.com/) - Apobob - [Arilux](https://www.ariluxworldwide.com/) - Aubric - BERENNIS - BHGY - [Brizled](https://www.brizled.com/) - Bunpeon - [Chichin](https://chichinlighting.com/) - Comoyda - dalattin - [DALS RGBW / Armacost Lighting / MyLED](https://www.armacostlighting.com/) - DARKPROOF - [Daybetter](https://www.daybetter.com/) - deerdance - DIAMOND - [Diode Dynamics](https://www.diodedynamics.com/) - [Flux LED](https://www.fluxsmartlighting.com/) - [FVTLED](https://fvtled.com/) - [GEV LIG](https://www.gev.de/) - GEYUEYA Home - GIDEALED - [GIDERWEL](https://giderwel.com/) - GMK - Goldwin - Hakkatronics - [HaoDeng](http://www.zengge.com/appkzd) - [Heissner](https://www.heissner.de/) - HDDFL - [illume RGBW](https://dals.com/illume/) - [Illumination FX](https://www.illumination-fx.com/) - INDARUN - iNextStation - [Koopower](https://www.koopower.com/) - [Lallumer](https://www.lapuretes.cn/) - LEDENET - [LiteWRX](https://litewrx.com/) - Lytworx - Magic Ambient - [Magic Home](http://www.zengge.com/appkzd) - [Magic Hue](http://www.magichue.com/) - [Magic Light](https://www.magiclightbulbs.com/) - Miheal - Mowelai - Nexlux - OBSESS - [Offdarks](http://offdarks.net) - PH LED - PHOPOLLO - [Pin Stadium Pinball Lights](https://pinstadium.com/) - POV Lamp - [PROTEAM Europe Pool Lights](https://proteam-me.com/) - [Rimikon](https://www.rimikon.com/) - SMFX - [Sumaote](https://fvtled.com/) - [Superhome](https://superhome.com.cy/) - [SurpLife](http://www.zengge.com/newbrand) - [SuperlightingLED](https://www.superlightingled.com/) - Svipear - Tommox - Vanance - Yetaida - YHW - [Zengge](http://www.zengge.com/sy) - Zombber ### File Structure device.py -> contains code to manipulate device as well as get any information from device that's needed.\ fluxled.py -> command line code for flux_led.\ pattern.py -> contains code to identify pattern as well as set patterns.\ protocol.py -> contains communication protocol to communicate with differnt devices.\ scanner.py -> contins scanner to scan network and identify devices on network.\ sock.py -> contains code to communicate on network.\ timer.py -> contains code to support setting timers on devices and getting timer information from devices.\ utils.py -> contains helpers to calculate differnt parameters such as color, cct, brightness etc. [code-coverage]: https://codecov.io/gh/Danielhiversen/flux_led [code-cover-shield]: https://codecov.io/gh/Danielhiversen/flux_led/branch/master/graph/badge.svg [commits-shield]: https://img.shields.io/github/commit-activity/y/Danielhiversen/flux_led.svg [commits]: https://github.com/Danielhiversen/flux_led/commits/main [language]: https://github.com/Danielhiversen/flux_led/search?l=python [language-shield]: https://img.shields.io/github/languages/top/Danielhiversen/flux_led [license-shield]: https://img.shields.io/github/license/Danielhiversen/flux_led.svg [pypi]: https://pypi.org/project/flux_led/ [pypi-shield]: https://img.shields.io/pypi/v/flux_led [python-package]: https://github.com/Danielhiversen/flux_led/actions/workflows/python-package.yml [python-package-shield]: https://github.com/Danielhiversen/flux_led/actions/workflows/python-package.yml/badge.svg?branch=master [python-ver]: https://pypi.python.org/pypi/flux_led/ [python-ver-shield]: https://img.shields.io/pypi/pyversions/flux_led.svg Danielhiversen-flux_led-bfd1bbe/examples/000077500000000000000000000000001447734565100207375ustar00rootroot00000000000000Danielhiversen-flux_led-bfd1bbe/examples/aio.py000066400000000000000000000013101447734565100220540ustar00rootroot00000000000000import asyncio import logging import pprint from flux_led.aio import AIOWifiLedBulb logging.basicConfig(level=logging.DEBUG) async def go(): bulb = AIOWifiLedBulb("192.168.107.91") def _async_updated(): pprint.pprint(["State Changed!", bulb.raw_state]) await bulb.async_setup(_async_updated) while True: await bulb.async_turn_on() await asyncio.sleep(2) await bulb.async_update() await asyncio.sleep(2) await bulb.async_set_levels(255, 0, 0) await asyncio.sleep(2) await bulb.async_set_white_temp(2700, 255) await asyncio.sleep(2) await bulb.async_turn_off() await asyncio.sleep(2) asyncio.run(go()) Danielhiversen-flux_led-bfd1bbe/examples/aio_power_restore_state.py000066400000000000000000000013251447734565100262410ustar00rootroot00000000000000import asyncio import logging import pprint from flux_led.aio import AIOWifiLedBulb from flux_led.protocol import PowerRestoreState logging.basicConfig(level=logging.DEBUG) async def go(): socket = AIOWifiLedBulb("192.168.213.66") def _async_updated(): pprint.pprint(["State Changed!", socket.raw_state]) await socket.async_setup(_async_updated) await asyncio.sleep(1) pprint.pprint(["Current restore states", socket.power_restore_states]) pprint.pprint("Setting power restore state to restore on power lost") await socket.async_set_power_restore(channel1=PowerRestoreState.LAST_STATE) pprint.pprint(["Current restore states", socket.power_restore_states]) asyncio.run(go()) Danielhiversen-flux_led-bfd1bbe/examples/aioscanner.py000066400000000000000000000003711447734565100234340ustar00rootroot00000000000000import asyncio import logging import pprint from flux_led.aioscanner import AIOBulbScanner logging.basicConfig(level=logging.DEBUG) async def go(): scanner = AIOBulbScanner() pprint.pprint(await scanner.async_scan()) asyncio.run(go()) Danielhiversen-flux_led-bfd1bbe/examples/aiozones.py000066400000000000000000000054061447734565100231450ustar00rootroot00000000000000import asyncio import logging import pprint from flux_led.aio import AIOWifiLedBulb from flux_led.const import MultiColorEffects logging.basicConfig(level=logging.DEBUG) async def go(): bulb = AIOWifiLedBulb("192.168.106.118") def _async_updated(): pprint.pprint(["State Changed!", bulb.raw_state]) await bulb.async_setup(_async_updated) while True: pprint.pprint("Setting to red/orange/yellow/green/blue/indigo/violet - static") await bulb.async_set_zones( [ (0xFF, 0x00, 0x00), # red (0xFF, 0xA5, 0x00), # orange (0xFF, 0xFF, 0x00), # yellow (0x00, 0xFF, 0x00), # green (0x00, 0x00, 0xFF), # blue (0x4B, 0x00, 0x82), # indigo (0xEE, 0x82, 0xEE), # violet ], 100, MultiColorEffects.STATIC, ) await asyncio.sleep(5) pprint.pprint("Setting to white/green - static") await bulb.async_set_zones( [(255, 255, 255), (0, 255, 0)], 100, MultiColorEffects.STATIC ) await asyncio.sleep(5) pprint.pprint("Setting to red/blue - static") await bulb.async_set_zones( [(255, 0, 0), (0, 0, 255)], 100, MultiColorEffects.STATIC ) await asyncio.sleep(5) pprint.pprint("Setting to white/blue - running water") await bulb.async_set_zones( [(255, 255, 255), (0, 0, 255)], 100, MultiColorEffects.RUNNING_WATER ) await asyncio.sleep(5) pprint.pprint("Setting to white/blue - breathing") await bulb.async_set_zones( [(255, 255, 255), (0, 0, 255)], 100, MultiColorEffects.BREATHING ) await asyncio.sleep(5) pprint.pprint("Setting to white/green - jump") await bulb.async_set_zones( [(255, 255, 255), (0, 255, 0)], 100, MultiColorEffects.JUMP ) await asyncio.sleep(5) pprint.pprint("Setting to red/blue - strobe") await bulb.async_set_zones( [(255, 0, 0), (0, 0, 255)], 100, MultiColorEffects.STROBE ) await asyncio.sleep(5) pprint.pprint( "Setting to red/orange/yellow/green/blue/indigo/violet - running water" ) await bulb.async_set_zones( [ (0xFF, 0x00, 0x00), # red (0xFF, 0xA5, 0x00), # orange (0xFF, 0xFF, 0x00), # yellow (0x00, 0xFF, 0x00), # green (0x00, 0x00, 0xFF), # blue (0x4B, 0x00, 0x82), # indigo (0xEE, 0x82, 0xEE), # violet ], 100, MultiColorEffects.RUNNING_WATER, ) await asyncio.sleep(5) asyncio.run(go()) Danielhiversen-flux_led-bfd1bbe/examples/crossfade_example.py000077500000000000000000000046651447734565100250130ustar00rootroot00000000000000#!/usr/bin/env python """Example to cycle a bulb between colors in a list, with a smooth fade between. Assumes the bulb is already on. The python file with the Flux LED wrapper classes should live in the same folder as this script """ from itertools import cycle import os import sys import time from flux_led import BulbScanner, WifiLedBulb this_folder = os.path.dirname(os.path.realpath(__file__)) sys.path.append(this_folder) def crossFade(bulb, color1, color2): r1, g1, b1 = color1 r2, g2, b2 = color2 steps = 100 for i in range(1, steps + 1): r = r1 - int(i * float(r1 - r2) / steps) g = g1 - int(i * float(g1 - g2) / steps) b = b1 - int(i * float(b1 - b2) / steps) # (use non-persistent mode to help preserve flash) bulb.setRgb(r, g, b, persist=False) def main(): # Find the bulb on the LAN scanner = BulbScanner() scanner.scan(timeout=4) # Specific ID/MAC of the bulb to set bulb_info = scanner.getBulbInfoByID("ACCF235FFFFF") if bulb_info: bulb = WifiLedBulb(bulb_info["ipaddr"]) color_time = 5 # seconds on each color red = (255, 0, 0) orange = (255, 125, 0) yellow = (255, 255, 0) springgreen = (125, 255, 0) green = (0, 255, 0) turquoise = (0, 255, 125) cyan = (0, 255, 255) ocean = (0, 125, 255) blue = (0, 0, 255) violet = (125, 0, 255) magenta = (255, 0, 255) raspberry = (255, 0, 125) colorwheel = [ red, orange, yellow, springgreen, green, turquoise, cyan, ocean, blue, violet, magenta, raspberry, ] # use cycle() to treat the list in a circular fashion colorpool = cycle(colorwheel) # get the first color before the loop color = next(colorpool) while True: bulb.refreshState() # set to color and wait # (use non-persistent mode to help preserve flash) bulb.setRgb(*color, persist=False) time.sleep(color_time) # fade from color to next color next_color = next(colorpool) crossFade(bulb, color, next_color) # ready for next loop color = next_color else: print("Can't find bulb") if __name__ == "__main__": main() Danielhiversen-flux_led-bfd1bbe/examples/disable_remote_access.py000066400000000000000000000004331447734565100256100ustar00rootroot00000000000000import asyncio import logging import pprint from flux_led.aioscanner import AIOBulbScanner logging.basicConfig(level=logging.DEBUG) async def go(): scanner = AIOBulbScanner() pprint.pprint(await scanner.async_disable_remote_access("192.168.106.198")) asyncio.run(go()) Danielhiversen-flux_led-bfd1bbe/examples/enable_remote_access.py000066400000000000000000000005371447734565100254400ustar00rootroot00000000000000import asyncio import logging import pprint from flux_led.aioscanner import AIOBulbScanner logging.basicConfig(level=logging.DEBUG) async def go(): scanner = AIOBulbScanner() pprint.pprint( await scanner.async_enable_remote_access( "192.168.106.198", "ra8815us02.magichue.net", 8815 ) ) asyncio.run(go()) Danielhiversen-flux_led-bfd1bbe/examples/mockdevice.py000066400000000000000000000137401447734565100234270ustar00rootroot00000000000000import asyncio import logging import socket from typing import Tuple, Optional from flux_led.aioscanner import AIOBulbScanner from flux_led.protocol import OUTER_MESSAGE_WRAPPER logging.basicConfig(level=logging.DEBUG) _LOGGER = logging.getLogger(__name__) DEVICE_ID = 0xA1 VERSION = 9 MINOR_VERSION = 28 ON_AT_START = False # MODEL = "AK001-ZJ2101" # Supports auto on # MODEL = "AK001-ZJ2145" # Supports dimmable effects MODEL = "AK001-ZJ2147" def get_local_ip(): """Find the local ip address.""" s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.setblocking(False) try: s.connect(("10.255.255.255", 1)) return s.getsockname()[0] except Exception: return None finally: s.close() class MagicHomeDiscoveryProtocol(asyncio.Protocol): """A asyncio.Protocol implementing the MagicHome discovery protocol.""" def __init__(self) -> None: self.loop = asyncio.get_running_loop() self.local_ip = get_local_ip() self.transport: Optional[asyncio.BaseTransport] = None def connection_made(self, transport): self.transport = transport def send(self, data: bytes, addr: Tuple[str, int]) -> None: """Trigger on data.""" _LOGGER.debug( "UDP %s => %s (%d)", addr, data, len(data), ) self.transport.sendto(data, addr) def datagram_received(self, data: bytes, addr: Tuple[str, int]) -> None: """Trigger on data.""" _LOGGER.debug( "UDP %s <= %s (%d)", addr, data, len(data), ) assert self.transport is not None model_str = hex(DEVICE_ID)[2:].zfill(2).upper() version_str = hex(VERSION)[2:].zfill(2).upper() minor_version_str = str(MINOR_VERSION).zfill(2).upper() if data.startswith(AIOBulbScanner.DISCOVER_MESSAGE): self.send( f"{self.local_ip},B4E842{model_str}{version_str}{minor_version_str},{MODEL}".encode(), addr, ) if data.startswith(AIOBulbScanner.VERSION_MESSAGE): model_str = hex(DEVICE_ID)[2:].zfill(2).upper() self.send( f"+ok={model_str}_{minor_version_str}_20210428_ZG-BL\r".encode(), addr ) def error_received(self, ex: Optional[Exception]) -> None: """Handle error.""" _LOGGER.debug("LEDENETDiscovery error: %s", ex) def connection_lost(self, ex: Optional[Exception]) -> None: """The connection is lost.""" class MagichomeServerProtocol(asyncio.Protocol): """A asyncio.Protocol implementing the MagicHome protocol.""" def __init__(self) -> None: self.loop = asyncio.get_running_loop() self.handler = None self.peername = None self.transport: Optional[asyncio.BaseTransport] = None def connection_lost(self, exc: Exception) -> None: """Handle connection lost.""" _LOGGER.debug("%s: Connection lost: %s", self.peername, exc) def connection_made(self, transport: asyncio.Transport) -> None: """Handle incoming connection.""" _LOGGER.debug("%s: Connection made", transport) self.peername = transport.get_extra_info("peername") self.transport = transport def send(self, data: bytes, random_byte: Optional[None]) -> None: """Trigger on data.""" if random_byte is not None: msg = self.construct_wrapped_message(data, random_byte) else: msg = data _LOGGER.debug( "TCP %s => %s (%d)", self.peername, " ".join(f"0x{x:02X}" for x in msg), len(msg), ) self.transport.write(msg) def data_received(self, data: bytes) -> None: """Process new data from the socket.""" _LOGGER.debug( "TCP %s <= %s (%d)", self.peername, " ".join(f"0x{x:02X}" for x in data), len(data), ) assert self.transport is not None if data.startswith(bytearray([*OUTER_MESSAGE_WRAPPER])): msg = data[10:-1] random = data[7] else: random = None msg = data if msg.startswith(bytearray([0x81])): self.send( self.construct_message( bytearray( [ 0x81, DEVICE_ID, 0x23 if ON_AT_START else 0x24, 0x61, 0xC5, 0x17, 0x18, 0x00, 0x00, 0x00, VERSION, 0xF0, 0xF2, ] ) ), random, ) def construct_wrapped_message( self, inner_msg: bytearray, random_byte: int ) -> bytearray: """Construct a wrapped message.""" inner_msg_len = len(inner_msg) return self.construct_message( bytearray( [ *OUTER_MESSAGE_WRAPPER, random_byte, inner_msg_len >> 8, inner_msg_len & 0xFF, *inner_msg, ] ) ) def construct_message(self, raw_bytes: bytearray) -> bytearray: """Calculate checksum of byte array and add to end.""" csum = sum(raw_bytes) & 0xFF raw_bytes.append(csum) return raw_bytes async def go(): loop = asyncio.get_running_loop() await loop.create_server( lambda: MagichomeServerProtocol(), host="0.0.0.0", port=5577, ) await loop.create_datagram_endpoint( lambda: MagicHomeDiscoveryProtocol(), local_addr=("0.0.0.0", AIOBulbScanner.DISCOVERY_PORT), ) await asyncio.sleep(86400) asyncio.run(go()) Danielhiversen-flux_led-bfd1bbe/examples/reboot.py000066400000000000000000000004141447734565100226020ustar00rootroot00000000000000import asyncio import logging import pprint from flux_led.aioscanner import AIOBulbScanner logging.basicConfig(level=logging.DEBUG) async def go(): scanner = AIOBulbScanner() pprint.pprint(await scanner.async_reboot("192.168.209.182")) asyncio.run(go()) Danielhiversen-flux_led-bfd1bbe/examples/scanner.py000066400000000000000000000002401447734565100227360ustar00rootroot00000000000000import logging import pprint from flux_led.scanner import BulbScanner logging.basicConfig(level=logging.DEBUG) pprint.pprint(BulbScanner().scan(timeout=10)) Danielhiversen-flux_led-bfd1bbe/examples/sun_timers_example.py000077500000000000000000000107441447734565100252250ustar00rootroot00000000000000#!/usr/bin/env python """This is an example script that can be used to set on and off timers based on the sunrise/sunset times. Specifically, it will set times on an outside porch light to turn on at dusk and off at dawn. It will set the timers for inside light to turn on at sunset, and off at a fixed time. A script like this is best used with an /etc/crontab entry that might run every day or every few days. For example: ----------------- # Sync up the bulb clocks a few times a day, in case of manual power toggles 00 3,12,17,22 * * * username /path/to/scripts/flux_led.py -Ss --setclock # Set the sun timers everyday at 3am 00 3 * * * username /path/to/scripts/sun_timers.py ----------------- The python file with the Flux LED wrapper classes should live in the same folder as this script """ import datetime import os import sys import syslog from flux_led import BulbScanner, LedTimer, WifiLedBulb try: from astral import Astral except ImportError: print("Error: Need to install python package: astral") sys.exit(-1) this_folder = os.path.dirname(os.path.realpath(__file__)) sys.path.append(this_folder) debug = False def main(): syslog.openlog(sys.argv[0]) # Change location to nearest city. location = "San Diego" # Get the local sunset/sunrise times a = Astral() a.solar_depression = "civil" city = a[location] timezone = city.timezone sun = city.sun(date=datetime.datetime.now(), local=True) if debug: print(f"Information for {location}/{city.region}\n") print(f"Timezone: {timezone}") print( "Latitude: {:.02f}; Longitude: {:.02f}\n".format( city.latitude, city.longitude ) ) print("Dawn: {}".format(sun["dawn"])) print("Sunrise: {}".format(sun["sunrise"])) print("Noon: {}".format(sun["noon"])) print("Sunset: {}".format(sun["sunset"])) print("Dusk: {}".format(sun["dusk"])) # Find the bulbs on the LAN scanner = BulbScanner() scanner.scan(timeout=4) # Specific ID/MAC of the bulbs to set porch_info = scanner.getBulbInfoByID("ACCF235FFFEE") livingroom_info = scanner.getBulbInfoByID("ACCF235FFFAA") if porch_info: bulb = WifiLedBulb(porch_info["ipaddr"]) bulb.refreshState() timers = bulb.getTimers() # Set the porch bulb to turn on at dusk using timer idx 0 syslog.syslog( syslog.LOG_ALERT, "Setting porch light to turn on at {}:{:02d}".format( sun["dusk"].hour, sun["dusk"].minute ), ) dusk_timer = LedTimer() dusk_timer.setActive(True) dusk_timer.setRepeatMask(LedTimer.Everyday) dusk_timer.setModeWarmWhite(35) dusk_timer.setTime(sun["dusk"].hour, sun["dusk"].minute) timers[0] = dusk_timer # Set the porch bulb to turn off at dawn using timer idx 1 syslog.syslog( syslog.LOG_ALERT, "Setting porch light to turn off at {}:{:02d}".format( sun["dawn"].hour, sun["dawn"].minute ), ) dawn_timer = LedTimer() dawn_timer.setActive(True) dawn_timer.setRepeatMask(LedTimer.Everyday) dawn_timer.setModeTurnOff() dawn_timer.setTime(sun["dawn"].hour, sun["dawn"].minute) timers[1] = dawn_timer bulb.sendTimers(timers) else: print("Can't find porch bulb") if livingroom_info: bulb = WifiLedBulb(livingroom_info["ipaddr"]) bulb.refreshState() timers = bulb.getTimers() # Set the living room bulb to turn on at sunset using timer idx 0 syslog.syslog( syslog.LOG_ALERT, "Setting LR light to turn on at {}:{:02d}".format( sun["sunset"].hour, sun["sunset"].minute ), ) sunset_timer = LedTimer() sunset_timer.setActive(True) sunset_timer.setRepeatMask(LedTimer.Everyday) sunset_timer.setModeWarmWhite(50) sunset_timer.setTime(sun["sunset"].hour, sun["sunset"].minute) timers[0] = sunset_timer # Set the living room bulb to turn off at a fixed time off_timer = LedTimer() off_timer.setActive(True) off_timer.setRepeatMask(LedTimer.Everyday) off_timer.setModeTurnOff() off_timer.setTime(23, 30) timers[1] = off_timer bulb.sendTimers(timers) else: print("Can't find living room bulb") if __name__ == "__main__": main() Danielhiversen-flux_led-bfd1bbe/flux_led/000077500000000000000000000000001447734565100207235ustar00rootroot00000000000000Danielhiversen-flux_led-bfd1bbe/flux_led/__init__.py000066400000000000000000000006211447734565100230330ustar00rootroot00000000000000"""Init file for Flux LED""" from .base_device import DeviceType, DeviceUnavailableException from .device import WifiLedBulb from .pattern import PresetPattern from .scanner import BulbScanner from .timer import LedTimer from .utils import utils __all__ = [ "DeviceType", "PresetPattern", "LedTimer", "WifiLedBulb", "BulbScanner", "utils", "DeviceUnavailableException", ] Danielhiversen-flux_led-bfd1bbe/flux_led/aio.py000066400000000000000000000001041447734565100220400ustar00rootroot00000000000000from .aiodevice import AIOWifiLedBulb __all__ = ["AIOWifiLedBulb"] Danielhiversen-flux_led-bfd1bbe/flux_led/aiodevice.py000066400000000000000000001054241447734565100232330ustar00rootroot00000000000000import asyncio import logging import time from asyncio import ALL_COMPLETED, FIRST_COMPLETED from datetime import datetime from typing import Any, Callable, Dict, List, Optional, Tuple, Union from .aioprotocol import AIOLEDENETProtocol from .aioscanner import AIOBulbScanner from .aioutils import asyncio_timeout from .base_device import ( ALL_ADDRESSABLE_PROTOCOLS, ALL_IC_PROTOCOLS, DeviceType, DeviceUnavailableException, LEDENETDevice, ) from .const import ( COLOR_MODE_CCT, COLOR_MODE_DIM, COLOR_MODE_RGB, COLOR_MODE_RGBW, COLOR_MODE_RGBWW, EFFECT_MUSIC, EFFECT_RANDOM, NEVER_TIME, PRESET_MUSIC_MODE, PUSH_UPDATE_INTERVAL, STATE_BLUE, STATE_COOL_WHITE, STATE_GREEN, STATE_RED, STATE_WARM_WHITE, MultiColorEffects, ) from .protocol import ( POWER_RESTORE_BYTES_TO_POWER_RESTORE, REMOTE_CONFIG_BYTES_TO_REMOTE_CONFIG, LEDENETOriginalRawState, LEDENETRawState, PowerRestoreState, PowerRestoreStates, ProtocolLEDENET8Byte, ProtocolLEDENETAddressableA3, ProtocolLEDENETAddressableChristmas, ProtocolLEDENETOriginal, RemoteConfig, ) from .scanner import FluxLEDDiscovery from .timer import LedTimer from .utils import color_temp_to_white_levels, rgbw_brightness, rgbww_brightness _LOGGER = logging.getLogger(__name__) COMMAND_SPACING_DELAY = 1 MAX_UPDATES_WITHOUT_RESPONSE = 4 DEVICE_CONFIG_WAIT_SECONDS = ( 3.5 # time it takes for the device to respond after a config change ) POWER_STATE_TIMEOUT = 1.2 POWER_CHANGE_ATTEMPTS = 6 class AIOWifiLedBulb(LEDENETDevice): """A LEDENET Wifi bulb device.""" def __init__( self, ipaddr: str, port: int = 0, timeout: float = 7.5, discovery: Optional[FluxLEDDiscovery] = None, ) -> None: """Init and setup the bulb.""" super().__init__(ipaddr, port, timeout, discovery) loop = asyncio.get_running_loop() self._connect_lock = asyncio.Lock() self._aio_protocol: Optional[AIOLEDENETProtocol] = None self._get_time_lock: asyncio.Lock = asyncio.Lock() self._get_time_future: Optional[asyncio.Future[bool]] = None self._get_timers_lock: asyncio.Lock = asyncio.Lock() self._get_timers_future: Optional[asyncio.Future[bool]] = None self._timers: Optional[List[LedTimer]] = None self._power_restore_future: "asyncio.Future[bool]" = loop.create_future() self._device_config_lock: asyncio.Lock = asyncio.Lock() self._device_config_future: asyncio.Future[bool] = loop.create_future() self._remote_config_future: asyncio.Future[bool] = loop.create_future() self._device_config_setup = False self._power_state_lock = asyncio.Lock() self._power_state_futures: List["asyncio.Future[bool]"] = [] self._state_futures: List[ "asyncio.Future[Union[LEDENETRawState, LEDENETOriginalRawState]]" ] = [] self._determine_protocol_future: Optional["asyncio.Future[bool]"] = None self._updated_callback: Optional[Callable[[], None]] = None self._updates_without_response = 0 self._last_update_time: float = NEVER_TIME self._power_restore_state: Optional[PowerRestoreStates] = None self._buffer = b"" self.loop = loop @property def power_restore_states(self) -> Optional[PowerRestoreStates]: """Returns the power restore states for all channels.""" return self._power_restore_state async def async_setup(self, updated_callback: Callable[[], None]) -> None: """Setup the connection and fetch initial state.""" self._updated_callback = updated_callback try: await self._async_setup() except Exception: # pylint: disable=broad-except self._async_stop() raise return async def _async_setup(self) -> None: await self._async_determine_protocol() assert self._protocol is not None if isinstance(self._protocol, ALL_IC_PROTOCOLS): await self._async_device_config_setup() hardware = self.hardware if hardware is not None and hardware.remote_24g_controls: await self._async_remote_config_setup() if self.device_type == DeviceType.Switch: await self._async_switch_setup() _LOGGER.debug( "%s: device_config: wiring=%s operating_mode=%s", self.ipaddr, self.wiring, self.operating_mode, ) async def _async_remote_config_setup(self) -> None: """Setup remote config.""" assert self._protocol is not None await self._async_send_msg(self._protocol.construct_query_remote_config()) try: async with asyncio_timeout(self.timeout): await self._remote_config_future except asyncio.TimeoutError: _LOGGER.warning("%s: Could not determine 2.4ghz remote config", self.ipaddr) async def _async_switch_setup(self) -> None: """Setup a switch.""" assert self._protocol is not None await self._async_send_msg(self._protocol.construct_power_restore_state_query()) try: async with asyncio_timeout(self.timeout): await self._power_restore_future except asyncio.TimeoutError: self.set_unavailable("Could not determine power restore state") raise DeviceUnavailableException( f"{self.ipaddr}: Could not determine power restore state" ) async def _async_device_config_setup(self) -> None: """Setup an addressable light.""" assert self._protocol is not None if isinstance(self._protocol, ProtocolLEDENETAddressableChristmas): self._device_config = self._protocol.parse_strip_setting(b"") return if self._device_config_setup: self._device_config_future = self.loop.create_future() self._device_config_setup = True assert isinstance(self._protocol, ALL_ADDRESSABLE_PROTOCOLS) await self._async_send_msg(self._protocol.construct_request_strip_setting()) try: async with asyncio_timeout(self.timeout): await self._device_config_future except asyncio.TimeoutError: self.set_unavailable("Could not determine number pixels") raise DeviceUnavailableException( f"{self.ipaddr}: Could not determine number pixels" ) async def async_stop(self) -> None: """Shutdown the connection.""" self._async_stop() def _async_stop(self) -> None: """Shutdown the connection and mark unavailable.""" self.set_unavailable("Connection closed") self._async_close() self._last_update_time = NEVER_TIME def _async_close(self) -> None: """Close the connection.""" if self._aio_protocol: self._aio_protocol.close() self._aio_protocol = None async def _async_send_state_query(self) -> None: assert self._protocol is not None await self._async_send_msg(self._protocol.construct_state_query()) async def _async_wait_state_change( self, futures: List["asyncio.Future[Any]"], state: bool, timeout: float ) -> bool: # If the device requires the two step turn on, we need to wait for the # power state and the state update. If its the single step turn on, we # only need to wait for any future. return_when = ALL_COMPLETED if self.requires_turn_on else FIRST_COMPLETED done, _ = await asyncio.wait(futures, timeout=timeout, return_when=return_when) return bool(done and self.is_on == state) async def _async_set_power_state( self, state: bool, accept_any_power_state_response: bool ) -> bool: assert self._protocol is not None power_state_future: "asyncio.Future[bool]" = self.loop.create_future() state_future: "asyncio.Future[Union[LEDENETRawState, LEDENETOriginalRawState]]" = ( self.loop.create_future() ) self._power_state_futures.append(power_state_future) self._state_futures.append(state_future) await self._async_send_msg(self._protocol.construct_state_change(state)) _LOGGER.debug("%s: Waiting for power state response", self.ipaddr) if await self._async_wait_state_change( [state_future, power_state_future], state, POWER_STATE_TIMEOUT * (3 / 8) ): return True if power_state_future.done() and accept_any_power_state_response: # The magic home app will accept any response as success # so after a few tries, we do as well. return True elif power_state_future.done() or state_future.done(): _LOGGER.debug( "%s: Bulb power state change taking longer than expected to %s, sending state query", self.ipaddr, state, ) else: _LOGGER.debug( "%s: Bulb failed to respond, sending state query", self.ipaddr ) if state_future.done(): state_future = self.loop.create_future() self._state_futures.append(state_future) pending: "List[asyncio.Future[Any]]" = [state_future] if not power_state_future.done(): # If the power state still hasn't responded # we want to stop waiting as soon as it does pending.append(power_state_future) await self._async_send_state_query() if await self._async_wait_state_change( pending, state, POWER_STATE_TIMEOUT * (5 / 8) ): return True _LOGGER.debug( "%s: State query did not return expected power state of %s", self.ipaddr, state, ) return False async def async_turn_on(self) -> bool: """Turn on the device.""" return await self._async_set_power_locked(True) async def async_turn_off(self) -> bool: """Turn off the device.""" return await self._async_set_power_locked(False) async def _async_set_power_locked(self, state: bool) -> bool: async with self._power_state_lock: self._power_state_transition_complete_time = NEVER_TIME return await self._async_set_power_state_with_retry(state) async def _async_set_power_state_with_retry(self, state: bool) -> bool: for idx in range(POWER_CHANGE_ATTEMPTS): accept_any_power_state_response = idx > 2 if await self._async_set_power_state( state, accept_any_power_state_response ): _LOGGER.debug( "%s: Completed power state change to %s (%s/%s)", self.ipaddr, state, 1 + idx, POWER_CHANGE_ATTEMPTS, ) if accept_any_power_state_response and self.is_on != state: # Sometimes these devices respond with "I turned off" and # they actually turn on when we are requesting to turn on. assert self._protocol is not None byte = self._protocol.on_byte if state else self._protocol.off_byte self._set_power_state(byte) self._set_power_transition_complete_time() return True _LOGGER.debug( "%s: Failed to set power state to %s (%s/%s)", self.ipaddr, state, 1 + idx, POWER_CHANGE_ATTEMPTS, ) _LOGGER.error( "%s: Failed to change power state to %s after %s attempts; Try rebooting the device", self.ipaddr, state, POWER_CHANGE_ATTEMPTS, ) return False async def async_set_white_temp( self, temperature: int, brightness: int, persist: bool = True ) -> None: """Set the white tempature.""" warm, cold = color_temp_to_white_levels( temperature, brightness, self.min_temp, self.max_temp ) if self.rgbw_color_temp_support(self.color_modes): await self.async_set_levels(cold, cold, cold, warm, 0, persist=persist) else: await self.async_set_levels(None, None, None, warm, cold, persist=persist) async def async_update(self, force: bool = False) -> None: """Request an update. The callback will be triggered when the state is recieved. """ now = time.monotonic() assert self._protocol is not None if ( # If the device is not available from a previous disconnect # fall through and try to reconnect send we do the # _async_send_state_query self.available and not force and (self._last_update_time + PUSH_UPDATE_INTERVAL) > now ): if self.is_on and self._protocol.state_push_updates: # If the device pushes state updates when on # then no need to poll except for the interval # to make sure the device is still responding return elif self._protocol.power_push_updates: # If the device pushes power updates # then no need to poll except for the interval # to make sure the device is still responding return self._last_update_time = now if ( self._aio_protocol and self._updates_without_response >= MAX_UPDATES_WITHOUT_RESPONSE ): self._async_close() self.set_unavailable( f"device stopped responding after {MAX_UPDATES_WITHOUT_RESPONSE} requests to send state" ) raise DeviceUnavailableException( f"{self.ipaddr}: device stopped responding after {MAX_UPDATES_WITHOUT_RESPONSE} requests to send state" ) try: await self._async_send_state_query() except asyncio.TimeoutError as ex: raise DeviceUnavailableException( f"{self.ipaddr}: timed out trying to connect after {self.timeout} seconds" ) from ex self._updates_without_response += 1 async def async_set_levels( self, r: Optional[int] = None, g: Optional[int] = None, b: Optional[int] = None, w: Optional[int] = None, w2: Optional[int] = None, persist: bool = True, brightness: Optional[int] = None, ) -> None: """Set any of the levels.""" await self._async_process_levels_change( *self._generate_levels_change( { STATE_RED: r, STATE_GREEN: g, STATE_BLUE: b, STATE_WARM_WHITE: w, STATE_COOL_WHITE: w2, }, persist, brightness, ) ) async def _async_process_levels_change( self, msgs: List[bytearray], updates: Dict[str, int] ) -> None: """Process and send a levels change.""" self._set_transition_complete_time() if updates: self._replace_raw_state(updates) for idx, msg in enumerate(msgs): await self._async_send_msg(msg) if idx > 0: self._process_callbacks() await asyncio.sleep(COMMAND_SPACING_DELAY) self._set_transition_complete_time() async def async_set_preset_pattern( self, effect: int, speed: int, brightness: int = 100 ) -> None: """Set a preset pattern on the device.""" self._set_transition_complete_time() await self._async_send_msg( self._generate_preset_pattern(effect, speed, brightness) ) async def async_set_custom_pattern( self, rgb_list: List[Tuple[int, int, int]], speed: int, transition_type: str ) -> None: """Set a custom pattern on the device.""" await self._async_send_msg( self._generate_custom_patterm(rgb_list, speed, transition_type) ) async def async_set_effect( self, effect: str, speed: int, brightness: int = 100 ) -> None: """Set an effect.""" if effect == EFFECT_RANDOM: await self.async_set_random() return if effect == EFFECT_MUSIC: await self.async_set_music_mode(brightness=brightness) return await self.async_set_preset_pattern( self._effect_to_pattern(effect), speed, brightness ) async def async_set_zones( self, rgb_list: List[Tuple[int, int, int]], speed: int = 100, effect: MultiColorEffects = MultiColorEffects.STATIC, ) -> None: """Set zones.""" assert self._protocol is not None if not self._protocol.zones: raise ValueError("{self.model} does not support zones") assert self._device_config is not None assert isinstance( self._protocol, (ProtocolLEDENETAddressableA3, ProtocolLEDENETAddressableChristmas), ) await self._async_send_msg( self._protocol.construct_zone_change( self._device_config.pixels_per_segment, rgb_list, speed, effect ) ) async def async_set_music_mode( self, sensitivity: Optional[int] = 100, brightness: Optional[int] = 100, mode: Optional[int] = None, effect: Optional[int] = None, foreground_color: Optional[Tuple[int, int, int]] = None, background_color: Optional[Tuple[int, int, int]] = None, ) -> None: """Set music mode.""" assert self._protocol is not None if not self.microphone: raise ValueError("{self.model} does not have a built-in microphone") self._set_preset_pattern_transition_complete_time() self._replace_raw_state({"preset_pattern": PRESET_MUSIC_MODE}) for idx, bytes_send in enumerate( self._protocol.construct_music_mode( sensitivity or 100, brightness or 100, mode, effect, foreground_color or self.rgb, background_color, ) ): if idx > 0: await asyncio.sleep(COMMAND_SPACING_DELAY) await self._async_send_msg(bytes_send) async def async_set_random(self) -> None: """Set levels randomly.""" await self._async_process_levels_change(*self._generate_random_levels_change()) async def async_set_brightness(self, brightness: int) -> None: """Adjust brightness.""" effect = self.effect if effect: effect_brightness = round(brightness / 255 * 100) await self.async_set_effect(effect, self.speed, effect_brightness) return if self.color_mode == COLOR_MODE_CCT: await self.async_set_white_temp(self.color_temp, brightness) return if self.color_mode == COLOR_MODE_RGB: await self.async_set_levels(*self.rgb_unscaled, brightness=brightness) return if self.color_mode == COLOR_MODE_RGBW: await self.async_set_levels(*rgbw_brightness(self.rgbw, brightness)) return if self.color_mode == COLOR_MODE_RGBWW: await self.async_set_levels(*rgbww_brightness(self.rgbww, brightness)) return if self.color_mode == COLOR_MODE_DIM: await self.async_set_levels(w=brightness) return async def async_enable_remote_access( self, remote_access_host: str, remote_access_port: int ) -> None: """Enable remote access.""" await AIOBulbScanner().async_enable_remote_access( self.ipaddr, remote_access_host, remote_access_port ) self._async_stop() async def async_disable_remote_access(self) -> None: """Disable remote access.""" await AIOBulbScanner().async_disable_remote_access(self.ipaddr) self._async_stop() async def async_reboot(self) -> None: """Reboot a device.""" await AIOBulbScanner().async_reboot(self.ipaddr) self._async_stop() async def async_set_power_restore( self, channel1: Optional[PowerRestoreState] = None, channel2: Optional[PowerRestoreState] = None, channel3: Optional[PowerRestoreState] = None, channel4: Optional[PowerRestoreState] = None, ) -> None: new_power_restore_state = self._power_restore_state assert new_power_restore_state is not None if channel1 is not None: new_power_restore_state.channel1 = channel1 if channel2 is not None: new_power_restore_state.channel2 = channel2 if channel3 is not None: new_power_restore_state.channel3 = channel3 if channel4 is not None: new_power_restore_state.channel4 = channel4 assert self._protocol is not None await self._async_send_msg( self._protocol.construct_power_restore_state_change(new_power_restore_state) ) async def async_set_device_config( self, operating_mode: Optional[str] = None, wiring: Optional[str] = None, ic_type: Optional[str] = None, # ic type pixels_per_segment: Optional[int] = None, # pixels per segment segments: Optional[int] = None, # number of segments music_pixels_per_segment: Optional[int] = None, # music pixels per segment music_segments: Optional[int] = None, # number of music segments ) -> None: """Set device configuration.""" # Since Home Assistant will modify one value at a time, # we need to lock, and then update so the previous value # modification does not get trampled in the event they # change two values before the first one has been updated async with self._device_config_lock: device_config = self.model_data.device_config ic_type_to_num = device_config.ic_type_to_num operating_mode_to_num = device_config.operating_mode_to_num if self._device_config is not None: wiring_to_num = self._device_config.wiring_to_num else: wiring_to_num = device_config.wiring_to_num operating_mode_num = ( self.operating_mode_num if operating_mode is None else operating_mode_to_num[operating_mode] ) wiring_num = self.wiring_num if wiring is None else wiring_to_num[wiring] ic_type_num = ( self.ic_type_num if ic_type is None else ic_type_to_num[ic_type] ) assert self._protocol is not None assert not isinstance(self._protocol, ProtocolLEDENETOriginal) await self._async_send_msg( self._protocol.construct_device_config( operating_mode_num, wiring_num, ic_type_num, pixels_per_segment or self.pixels_per_segment, segments or self.segments, music_pixels_per_segment or self.music_pixels_per_segment, music_segments or self.music_segments, ) ) if isinstance(self._protocol, ALL_IC_PROTOCOLS): await self._async_device_config_resync() async def async_unpair_remotes(self) -> None: """Unpair 2.4ghz remotes.""" assert self._protocol is not None if self.paired_remotes is None: raise ValueError("{self.model} does support unpairing remotes") await self._async_send_msg(self._protocol.construct_unpair_remotes()) await self._async_send_msg(self._protocol.construct_query_remote_config()) async def async_config_remotes(self, remote_config: RemoteConfig) -> None: """Change remote config.""" assert self._protocol is not None if self.paired_remotes is None: raise ValueError("{self.model} does support unpairing remotes") await self._async_send_msg( self._protocol.construct_remote_config(remote_config) ) await self._async_send_msg(self._protocol.construct_query_remote_config()) async def async_get_time(self) -> Optional[datetime]: """Get the current time.""" assert self._protocol is not None await self._async_send_msg(self._protocol.construct_get_time()) async with self._get_time_lock: self._get_time_future = self.loop.create_future() try: async with asyncio_timeout(self.timeout): await self._get_time_future except asyncio.TimeoutError: _LOGGER.warning("%s: Could not get time from the device", self.ipaddr) return None return self._last_time async def async_get_timers(self) -> Optional[List[LedTimer]]: """Get the timers.""" assert self._protocol is not None if isinstance(self._protocol, ProtocolLEDENETOriginal): led_timers: List[LedTimer] = [] return led_timers await self._async_send_msg(self._protocol.construct_get_timers()) async with self._get_timers_lock: self._get_timers_future = self.loop.create_future() try: async with asyncio_timeout(self.timeout): await self._get_timers_future except asyncio.TimeoutError: _LOGGER.warning("%s: Could not get timers from the device", self.ipaddr) return None return self._timers async def async_set_timers(self, timer_list: List[LedTimer]) -> None: """Set the timers.""" assert self._protocol is not None await self._async_send_msg(self._protocol.construct_set_timers(timer_list)) async def async_set_time(self, time: Optional[datetime] = None) -> None: """Set the current time.""" assert self._protocol is not None await self._async_send_msg(self._protocol.construct_set_time(time)) async def _async_device_config_resync(self) -> None: await asyncio.sleep(DEVICE_CONFIG_WAIT_SECONDS) await self._async_device_config_setup() async def _async_connect(self) -> None: """Create connection.""" async with asyncio_timeout(self.timeout): _, self._aio_protocol = await self.loop.create_connection( lambda: AIOLEDENETProtocol( self._async_data_recieved, self._async_connection_lost ), self.ipaddr, self.port, ) def _async_connection_lost(self, exc: Optional[Exception]) -> None: """Called when the connection is lost.""" self._aio_protocol = None self.set_unavailable("Connection lost") def _async_data_recieved(self, data: bytes) -> None: """New data on the socket.""" assert self._protocol is not None assert self._aio_protocol is not None start_empty_buffer = not self._buffer self._buffer += data self._updates_without_response = 0 msg_length = len(self._buffer) while msg_length: expected_length = self._protocol.expected_response_length(self._buffer) if msg_length < expected_length: # need more bytes return msg = self._buffer[:expected_length] self._buffer = self._buffer[expected_length:] msg_length = len(self._buffer) if not start_empty_buffer: _LOGGER.debug( "%s <= Reassembled (%s) (%d)", self._aio_protocol.peername, " ".join(f"0x{x:02X}" for x in msg), len(msg), ) self._async_process_message(msg) def _async_process_state_response(self, msg: bytes) -> bool: if ( self._determine_protocol_future and not self._determine_protocol_future.done() ): assert self._protocol is not None self._set_protocol_from_msg(msg, self._protocol.name) self._determine_protocol_future.set_result(True) return self.process_state_response(msg) def _async_process_message(self, msg: bytes) -> None: """Process a full message (maybe reassembled).""" assert self._protocol is not None self.set_available(f"Received message {msg.hex()}") prev_state = self.raw_state changed_state = False if self._protocol.is_valid_outer_message(msg): msg = self._protocol.extract_inner_message(msg) if self._protocol.is_valid_state_response(msg): self._last_message["state"] = msg self._async_process_state_response(msg) self._process_state_futures() elif self._protocol.is_valid_power_state_response(msg): self._last_message["power_state"] = msg self.process_power_state_response(msg) self._process_power_futures() elif self._protocol.is_valid_get_time_response(msg): self._last_message["get_time"] = msg self.process_time_response(msg) elif self._protocol.is_valid_timers_response(msg): self._last_message["timers"] = msg self.process_timers_response(msg) changed_state = True elif self._protocol.is_valid_device_config_response(msg): self._last_message["device_config"] = msg self.process_device_config_response(msg) changed_state = True elif self._protocol.is_valid_power_restore_state_response(msg): self._last_message["power_restore_state"] = msg self.process_power_restore_state_response(msg) elif self._protocol.is_valid_remote_config_response(msg): self._last_message["remote_config"] = msg self.process_remote_config_response(msg) changed_state = True else: self._last_message["unknown"] = msg _LOGGER.debug( "%s: Ignoring unknown message: %s", self.ipaddr, " ".join(f"0x{x:02X}" for x in msg), ) return if not changed_state and self.raw_state == prev_state: return self._process_callbacks() def _process_state_futures(self) -> None: """Process power future responses.""" assert self.raw_state is not None for future in self._state_futures: if not future.done(): future.set_result(self.raw_state) self._state_futures.clear() def _process_power_futures(self) -> None: """Process power future responses.""" for future in self._power_state_futures: if not future.done(): future.set_result(self.is_on) self._power_state_futures.clear() def _process_callbacks(self) -> None: """Called when state changes.""" assert self._updated_callback is not None try: self._updated_callback() except Exception as ex: # pylint: disable=broad-except _LOGGER.error("Error while calling callback: %s", ex) def process_power_restore_state_response(self, msg: bytes) -> None: """Process a power restore state response. Power on state always off f0 32 ff f0 f0 f0 f1 Power on state always on f0 32 0f f0 f0 f0 01 Power on state keep last state f0 32 f0 f0 f0 f0 e2 """ self._power_restore_state = PowerRestoreStates( channel1=POWER_RESTORE_BYTES_TO_POWER_RESTORE.get(msg[2]), channel2=POWER_RESTORE_BYTES_TO_POWER_RESTORE.get(msg[3]), channel3=POWER_RESTORE_BYTES_TO_POWER_RESTORE.get(msg[4]), channel4=POWER_RESTORE_BYTES_TO_POWER_RESTORE.get(msg[5]), ) if not self._power_restore_future.done(): self._power_restore_future.set_result(True) def process_device_config_response(self, msg: bytes) -> None: """Process an IC (strip config) response.""" super().process_device_config_response(msg) if not self._device_config_future.done(): self._device_config_future.set_result(True) def process_time_response(self, msg: bytes) -> None: """Process an time response.""" assert self._protocol is not None self._last_time = self._protocol.parse_get_time(msg) if self._get_time_future and not self._get_time_future.done(): self._get_time_future.set_result(True) def process_timers_response(self, msg: bytes) -> None: """Process an timers response.""" assert self._protocol is not None self._timers = self._protocol.parse_get_timers(msg) if self._get_timers_future and not self._get_timers_future.done(): self._get_timers_future.set_result(True) def process_remote_config_response(self, msg: bytes) -> None: """Process a 2.4ghz remote config response.""" # 2b 03 00 02 00 00 00 00 00 00 00 00 00 30 # 0 1 2 3 self._paired_remotes = msg[3] self._remote_config = REMOTE_CONFIG_BYTES_TO_REMOTE_CONFIG.get(msg[1]) _LOGGER.debug( "%s: remote_config: config=%s paired_remotes=%s", self.ipaddr, self._remote_config, self._paired_remotes, ) if not self._remote_config_future.done(): self._remote_config_future.set_result(True) async def _async_send_msg(self, msg: bytearray) -> None: """Write a message on the socket.""" if not self._aio_protocol: async with self._connect_lock: # Check again under the lock if not self._aio_protocol: await self._async_connect() assert self._aio_protocol is not None self._aio_protocol.write(msg) async def _async_determine_protocol(self) -> None: # determine the type of protocol based of first 2 bytes. for protocol_cls in self._protocol_probes(): protocol = protocol_cls() assert isinstance(protocol, (ProtocolLEDENET8Byte, ProtocolLEDENETOriginal)) self._protocol = protocol async with self._connect_lock: await self._async_connect() assert self._aio_protocol is not None self._determine_protocol_future = self.loop.create_future() self._aio_protocol.write(protocol.construct_state_query()) try: async with asyncio_timeout(self.timeout): await self._determine_protocol_future except asyncio.TimeoutError: self._async_close() continue else: return self.set_unavailable("Cannot determine protocol") raise DeviceUnavailableException(f"{self.ipaddr}: Cannot determine protocol") Danielhiversen-flux_led-bfd1bbe/flux_led/aioprotocol.py000066400000000000000000000040111447734565100236230ustar00rootroot00000000000000import asyncio import logging from asyncio.transports import BaseTransport, WriteTransport from typing import Any, Callable, Optional, cast _LOGGER = logging.getLogger(__name__) class AIOLEDENETProtocol(asyncio.Protocol): """A asyncio.Protocol implementing a wrapper around the LEDENET protocol.""" def __init__( self, data_received: Callable[[bytes], Any], connection_lost: Callable[[Optional[Exception]], Any], ) -> None: self._data_receive_callback = data_received self._connection_lost_callback = connection_lost self.transport: Optional[WriteTransport] = None def connection_lost(self, exc: Optional[Exception]) -> None: """Handle connection lost.""" _LOGGER.debug("%s: Connection lost: %s", self.peername, exc) self.close() self._connection_lost_callback(exc) def connection_made(self, transport: BaseTransport) -> None: """Handle connection made.""" self.transport = cast(WriteTransport, transport) self.peername = transport.get_extra_info("peername") def write(self, data: bytes) -> None: """Write data to the client.""" assert self.transport is not None if _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( "%s => %s (%d)", self.peername, " ".join(f"0x{x:02X}" for x in data), len(data), ) self.transport.write(data) def close(self) -> None: """Remove the connection and close the transport.""" assert self.transport is not None self.transport.write_eof() self.transport.close() def data_received(self, data: bytes) -> None: """Process new data from the socket.""" if _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( "%s <= %s (%d)", self.peername, " ".join(f"0x{x:02X}" for x in data), len(data), ) self._data_receive_callback(data) Danielhiversen-flux_led-bfd1bbe/flux_led/aioscanner.py000066400000000000000000000152361447734565100234260ustar00rootroot00000000000000import asyncio import contextlib import logging import time from typing import Callable, Dict, List, Optional, Tuple from .aioutils import asyncio_timeout from .scanner import MESSAGE_SEND_INTERLEAVE_DELAY, BulbScanner, FluxLEDDiscovery _LOGGER = logging.getLogger(__name__) class LEDENETDiscovery(asyncio.DatagramProtocol): def __init__( self, destination: Tuple[str, int], on_response: Callable[[bytes, Tuple[str, int]], None], ) -> None: """Init the discovery protocol.""" self.transport = None self.destination = destination self.on_response = on_response def datagram_received(self, data: bytes, addr: Tuple[str, int]) -> None: """Trigger on_response.""" self.on_response(data, addr) def error_received(self, ex: Optional[Exception]) -> None: """Handle error.""" _LOGGER.debug("LEDENETDiscovery error: %s", ex) def connection_lost(self, ex: Optional[Exception]) -> None: """The connection is lost.""" class AIOBulbScanner(BulbScanner): """A LEDENET discovery scanner.""" def __init__(self) -> None: self.loop = asyncio.get_running_loop() super().__init__() async def _async_send_messages( self, messages: List[bytes], sender: asyncio.DatagramTransport, destination: Tuple[str, int], ) -> None: """Send messages with a short delay between them.""" last_idx = len(messages) - 1 for idx, message in enumerate(messages): self._send_message(sender, destination, message) if idx != last_idx: await asyncio.sleep(MESSAGE_SEND_INTERLEAVE_DELAY) async def _async_send_and_wait( self, events: List[asyncio.Event], commands: List[bytes], transport: asyncio.DatagramTransport, destination: Tuple[str, int], timeout: int, ) -> None: """Send a message and wait for a response.""" event_map: Dict[int, asyncio.Event] = {} for idx, _ in enumerate(commands): event = asyncio.Event() event_map[idx] = event events.append(event) for idx, command in enumerate(commands): self._send_message(transport, destination, command) async with asyncio_timeout(timeout): await event_map[idx].wait() async def _async_send_commands_and_reboot( self, messages: Optional[List[bytes]], address: str, timeout: int = 5, ) -> None: """Send a command and reboot.""" sock = self._create_socket() destination = self._destination_from_address(address) events: List[asyncio.Event] = [] def _on_response(data: bytes, addr: Tuple[str, int]) -> None: _LOGGER.debug("udp: %s <= %s", addr, data) if data.startswith(b"+ok"): events.pop(0).set() transport_proto = await self.loop.create_datagram_endpoint( lambda: LEDENETDiscovery( destination=destination, on_response=_on_response, ), sock=sock, ) transport = transport_proto[0] commands: List[bytes] = [] if messages: commands.extend(messages) commands.extend(self._get_reboot_messages()) try: await self._async_send_messages( self._get_start_messages(), transport, destination ) await self._async_send_and_wait( events, commands, transport, destination, timeout ) finally: transport.close() async def _async_run_scan( self, transport: asyncio.DatagramTransport, destination: Tuple[str, int], timeout: int, found_all_future: "asyncio.Future[bool]", ) -> None: """Send the scans.""" discovery_messages = self.get_discovery_messages() await self._async_send_messages(discovery_messages, transport, destination) quit_time = time.monotonic() + timeout time_out = timeout / self.BROADCAST_FREQUENCY while True: try: async with asyncio_timeout(time_out): await asyncio.shield(found_all_future) except asyncio.TimeoutError: pass else: return # found_all time_out = min( quit_time - time.monotonic(), timeout / self.BROADCAST_FREQUENCY ) if time_out <= 0: return # No response, send broadcast again in cast it got lost await self._async_send_messages(discovery_messages, transport, destination) async def async_scan( self, timeout: int = 10, address: Optional[str] = None ) -> List[FluxLEDDiscovery]: """Discover LEDENET.""" sock = self._create_socket() destination = self._destination_from_address(address) found_all_future: "asyncio.Future[bool]" = self.loop.create_future() def _on_response(data: bytes, addr: Tuple[str, int]) -> None: _LOGGER.debug("discover: %s <= %s", addr, data) if self._process_response(data, addr, address, self._discoveries): with contextlib.suppress(asyncio.InvalidStateError): found_all_future.set_result(True) transport_proto = await self.loop.create_datagram_endpoint( lambda: LEDENETDiscovery( destination=destination, on_response=_on_response, ), sock=sock, ) transport = transport_proto[0] try: await self._async_run_scan( transport, destination, timeout, found_all_future ) finally: transport.close() return self.found_bulbs async def async_disable_remote_access(self, address: str, timeout: int = 5) -> None: """Disable remote access.""" await self._async_send_commands_and_reboot( self._get_disable_remote_access_messages(), address, timeout ) async def async_enable_remote_access( self, address: str, remote_access_host: str, remote_access_port: int, timeout: int = 5, ) -> None: """Enable remote access.""" await self._async_send_commands_and_reboot( self._get_enable_remote_access_messages( remote_access_host, remote_access_port ), address, timeout, ) async def async_reboot(self, address: str, timeout: int = 5) -> None: """Reboot the device.""" await self._async_send_commands_and_reboot(None, address, timeout) Danielhiversen-flux_led-bfd1bbe/flux_led/aioutils.py000066400000000000000000000003001447734565100231170ustar00rootroot00000000000000import sys __all__ = ["asyncio_timeout"] if sys.version_info[:2] < (3, 11): from async_timeout import timeout as asyncio_timeout else: from asyncio import timeout as asyncio_timeout Danielhiversen-flux_led-bfd1bbe/flux_led/base_device.py000066400000000000000000001367451447734565100235460ustar00rootroot00000000000000import colorsys import logging import random import time from dataclasses import asdict, is_dataclass from enum import Enum from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Type, Union from .const import ( # imported for back compat, remove once Home Assistant no longer uses ADDRESSABLE_STATE_CHANGE_LATENCY, ATTR_MODEL, ATTR_MODEL_DESCRIPTION, ATTR_MODEL_INFO, CHANNEL_STATES, COLOR_MODE_CCT, COLOR_MODE_DIM, COLOR_MODE_RGB, COLOR_MODE_RGBW, COLOR_MODE_RGBWW, COLOR_MODES_RGB, COLOR_MODES_RGB_CCT, COLOR_MODES_RGB_W, DEFAULT_MODE, DEFAULT_WHITE_CHANNEL_TYPE, EFFECT_MUSIC, EFFECT_RANDOM, MAX_TEMP, MODE_COLOR, MODE_CUSTOM, MODE_MUSIC, MODE_PRESET, MODE_SWITCH, MODE_WW, MODEL_NUMS_SWITCHS, NEVER_TIME, POWER_STATE_CHANGE_LATENCY, PRESET_MUSIC_MODE, PRESET_MUSIC_MODE_LEGACY, PRESET_MUSIC_MODES, PRESET_PATTERN_CHANGE_LATENCY, STATE_BLUE, STATE_CHANGE_LATENCY, STATE_COOL_WHITE, STATE_GREEN, STATE_POWER_STATE, STATE_RED, STATE_WARM_WHITE, STATIC_MODES, WRITE_ALL_COLORS, WRITE_ALL_WHITES, LevelWriteMode, WhiteChannelType, ) from .models_db import ( BASE_MODE_MAP, HARDWARE_MAP, LEDENETHardware, LEDENETModel, get_model, is_known_model, ) from .pattern import ( ADDRESSABLE_EFFECT_ID_NAME, ADDRESSABLE_EFFECT_NAME_ID, ASSESSABLE_MULTI_COLOR_ID_NAME, CHRISTMAS_ADDRESSABLE_EFFECT_ID_NAME, CHRISTMAS_ADDRESSABLE_EFFECT_NAME_ID, EFFECT_CUSTOM, EFFECT_CUSTOM_CODE, EFFECT_ID_NAME, EFFECT_ID_NAME_LEGACY_CCT, EFFECT_LIST, EFFECT_LIST_DIMMABLE, EFFECT_LIST_LEGACY_CCT, ORIGINAL_ADDRESSABLE_EFFECT_ID_NAME, ORIGINAL_ADDRESSABLE_EFFECT_NAME_ID, PresetPattern, ) from .protocol import ( PROTOCOL_LEDENET_8BYTE, PROTOCOL_LEDENET_8BYTE_AUTO_ON, PROTOCOL_LEDENET_8BYTE_DIMMABLE_EFFECTS, PROTOCOL_LEDENET_9BYTE, PROTOCOL_LEDENET_9BYTE_AUTO_ON, PROTOCOL_LEDENET_9BYTE_DIMMABLE_EFFECTS, PROTOCOL_LEDENET_ADDRESSABLE_A1, PROTOCOL_LEDENET_ADDRESSABLE_A2, PROTOCOL_LEDENET_ADDRESSABLE_A3, PROTOCOL_LEDENET_ADDRESSABLE_CHRISTMAS, PROTOCOL_LEDENET_CCT, PROTOCOL_LEDENET_CCT_WRAPPED, PROTOCOL_LEDENET_ORIGINAL, PROTOCOL_LEDENET_ORIGINAL_CCT, PROTOCOL_LEDENET_ORIGINAL_RGBW, PROTOCOL_LEDENET_SOCKET, LEDENETAddressableDeviceConfiguration, LEDENETOriginalRawState, LEDENETRawState, ProtocolLEDENET8Byte, ProtocolLEDENET8ByteAutoOn, ProtocolLEDENET8ByteDimmableEffects, ProtocolLEDENET9Byte, ProtocolLEDENET9ByteAutoOn, ProtocolLEDENET9ByteDimmableEffects, ProtocolLEDENETAddressableA1, ProtocolLEDENETAddressableA2, ProtocolLEDENETAddressableA3, ProtocolLEDENETAddressableChristmas, ProtocolLEDENETCCT, ProtocolLEDENETCCTWrapped, ProtocolLEDENETOriginal, ProtocolLEDENETOriginalCCT, ProtocolLEDENETOriginalRGBW, ProtocolLEDENETSocket, RemoteConfig, ) from .scanner import FluxLEDDiscovery, is_legacy_device from .timer import BuiltInTimer from .utils import scaled_color_temp_to_white_levels, utils, white_levels_to_color_temp _LOGGER = logging.getLogger(__name__) class DeviceUnavailableException(RuntimeError): """Exception to indicate a device is not available.""" PROTOCOL_PROBES: Tuple[Type[ProtocolLEDENET8Byte], Type[ProtocolLEDENETOriginal]] = ( ProtocolLEDENET8Byte, ProtocolLEDENETOriginal, ) PROTOCOL_PROBES_LEGACY: Tuple[ Type[ProtocolLEDENETOriginal], Type[ProtocolLEDENET8Byte] ] = (ProtocolLEDENETOriginal, ProtocolLEDENET8Byte) PROTOCOL_TYPES = Union[ ProtocolLEDENET8Byte, ProtocolLEDENET8ByteAutoOn, ProtocolLEDENET8ByteDimmableEffects, ProtocolLEDENET9Byte, ProtocolLEDENET9ByteAutoOn, ProtocolLEDENET9ByteDimmableEffects, ProtocolLEDENETAddressableA1, ProtocolLEDENETAddressableA2, ProtocolLEDENETAddressableA3, ProtocolLEDENETOriginal, ProtocolLEDENETOriginalCCT, ProtocolLEDENETOriginalRGBW, ProtocolLEDENETCCT, ProtocolLEDENETCCTWrapped, ProtocolLEDENETSocket, ProtocolLEDENETAddressableChristmas, ] ADDRESSABLE_PROTOCOLS = { PROTOCOL_LEDENET_ADDRESSABLE_A1, PROTOCOL_LEDENET_ADDRESSABLE_A2, PROTOCOL_LEDENET_ADDRESSABLE_A3, } ALL_ADDRESSABLE_PROTOCOLS = ( ProtocolLEDENETAddressableA1, ProtocolLEDENETAddressableA2, ProtocolLEDENETAddressableA3, ) ALL_IC_PROTOCOLS = (ProtocolLEDENETAddressableChristmas, *ALL_ADDRESSABLE_PROTOCOLS) CHRISTMAS_EFFECTS_PROTOCOLS = {PROTOCOL_LEDENET_ADDRESSABLE_CHRISTMAS} OLD_EFFECTS_PROTOCOLS = {PROTOCOL_LEDENET_ADDRESSABLE_A1} NEW_EFFECTS_PROTOCOLS = { PROTOCOL_LEDENET_ADDRESSABLE_A2, PROTOCOL_LEDENET_ADDRESSABLE_A3, } SPEED_ADJUST_WILL_TURN_ON = { PROTOCOL_LEDENET_ADDRESSABLE_A1, PROTOCOL_LEDENET_ADDRESSABLE_A2, } PROTOCOL_NAME_TO_CLS = { PROTOCOL_LEDENET_ORIGINAL: ProtocolLEDENETOriginal, PROTOCOL_LEDENET_ORIGINAL_CCT: ProtocolLEDENETOriginalCCT, PROTOCOL_LEDENET_ORIGINAL_RGBW: ProtocolLEDENETOriginalRGBW, PROTOCOL_LEDENET_8BYTE: ProtocolLEDENET8Byte, PROTOCOL_LEDENET_8BYTE_AUTO_ON: ProtocolLEDENET8ByteAutoOn, PROTOCOL_LEDENET_8BYTE_DIMMABLE_EFFECTS: ProtocolLEDENET8ByteDimmableEffects, PROTOCOL_LEDENET_9BYTE: ProtocolLEDENET9Byte, PROTOCOL_LEDENET_9BYTE_AUTO_ON: ProtocolLEDENET9ByteAutoOn, PROTOCOL_LEDENET_9BYTE_DIMMABLE_EFFECTS: ProtocolLEDENET9ByteDimmableEffects, PROTOCOL_LEDENET_ADDRESSABLE_A3: ProtocolLEDENETAddressableA3, PROTOCOL_LEDENET_ADDRESSABLE_A2: ProtocolLEDENETAddressableA2, PROTOCOL_LEDENET_ADDRESSABLE_A1: ProtocolLEDENETAddressableA1, PROTOCOL_LEDENET_CCT: ProtocolLEDENETCCT, PROTOCOL_LEDENET_CCT_WRAPPED: ProtocolLEDENETCCTWrapped, PROTOCOL_LEDENET_SOCKET: ProtocolLEDENETSocket, PROTOCOL_LEDENET_ADDRESSABLE_CHRISTMAS: ProtocolLEDENETAddressableChristmas, } PATTERN_CODE_TO_EFFECT = { PRESET_MUSIC_MODE: MODE_MUSIC, PRESET_MUSIC_MODE_LEGACY: MODE_MUSIC, EFFECT_CUSTOM_CODE: EFFECT_CUSTOM, } SERIALIZABLE_TYPES = (str, bool, dict, int, float, list, tuple, set) DEFAULT_PORT = 5577 ARMACOST_PORT = 34001 class DeviceType(Enum): Bulb = 0 Switch = 1 class LEDENETDevice: """An LEDENET Device.""" def __init__( self, ipaddr: str, port: int = 0, timeout: float = 5, discovery: Optional[FluxLEDDiscovery] = None, ) -> None: """Init the LEDENEt Device.""" self.ipaddr: str = ipaddr self._port: int = port self.timeout: float = timeout self.raw_state: Optional[Union[LEDENETOriginalRawState, LEDENETRawState]] = None self.available: Optional[bool] = None self._model_num: Optional[int] = None self._model_data: Optional[LEDENETModel] = None self._paired_remotes: Optional[int] = None self._remote_config: Optional[RemoteConfig] = None self._white_channel_channel_type: WhiteChannelType = DEFAULT_WHITE_CHANNEL_TYPE self._discovery = discovery self._protocol: Optional[PROTOCOL_TYPES] = None self._mode: Optional[str] = None self._transition_complete_time: float = 0 self._preset_pattern_transition_complete_time: float = 0 self._power_state_transition_complete_time: float = 0 self._last_effect_brightness: int = 100 self._device_config: Optional[LEDENETAddressableDeviceConfiguration] = None self._last_message: Dict[str, bytes] = {} self._unavailable_reason: Optional[str] = None def _protocol_probes( self, ) -> Union[ Tuple[Type[ProtocolLEDENETOriginal], Type[ProtocolLEDENET8Byte]], Tuple[Type[ProtocolLEDENET8Byte], Type[ProtocolLEDENETOriginal]], ]: """Determine the probe order based on device type.""" discovery = self.discovery return ( PROTOCOL_PROBES_LEGACY if is_legacy_device(discovery) else PROTOCOL_PROBES ) @property def model_num(self) -> int: """Return the model number.""" assert self._model_num is not None return self._model_num @property def model_data(self) -> LEDENETModel: """Return the model data.""" assert self._model_data is not None return self._model_data @property def discovery(self) -> Optional[FluxLEDDiscovery]: """Return the discovery data.""" return self._discovery @discovery.setter def discovery(self, value: FluxLEDDiscovery) -> None: """Set the discovery data.""" self._discovery = value @property def white_channel_channel_type(self) -> WhiteChannelType: """Return the type of the white channel.""" return self._white_channel_channel_type @white_channel_channel_type.setter def white_channel_channel_type(self, value: WhiteChannelType) -> None: """Set the type of the white channel.""" self._white_channel_channel_type = value @property def hardware(self) -> Optional[LEDENETHardware]: """Retrurn the hardware mapping for the device.""" if not self._discovery or ATTR_MODEL not in self._discovery: return None model = self._discovery.get(ATTR_MODEL) if model is None: return None assert isinstance(model, str) return HARDWARE_MAP.get(model) @property def paired_remotes(self) -> Optional[int]: """Return the number of paired remotes or None if not supported.""" return self._paired_remotes @property def remote_config(self) -> Optional[RemoteConfig]: """Return the number of remote config or None if not supported.""" return self._remote_config @property def speed_adjust_off(self) -> int: """Return true if the speed of an effect can be adjusted while off.""" return self.protocol not in SPEED_ADJUST_WILL_TURN_ON @property def _whites_are_temp_brightness(self) -> bool: """Return true if warm_white and cool_white are scaled temp values and not raw 0-255.""" return self.protocol in (PROTOCOL_LEDENET_CCT, PROTOCOL_LEDENET_CCT_WRAPPED) @property def model(self) -> str: """Return the human readable model description.""" if self._discovery and self._discovery.get(ATTR_MODEL_DESCRIPTION): return f"{self._discovery[ATTR_MODEL_DESCRIPTION]} (0x{self.model_num:02X})" return f"{self.model_data.description} (0x{self.model_num:02X})" @property def version_num(self) -> int: """Return the version number.""" assert self.raw_state is not None raw_state = self.raw_state if hasattr(raw_state, "version_number"): assert isinstance(raw_state, LEDENETRawState) return raw_state.version_number return 0 # old devices report as 0 @property def preset_pattern_num(self) -> int: """Return the preset pattern number.""" assert self.raw_state is not None return self.raw_state.preset_pattern @property def rgbwprotocol(self) -> bool: """Devices that don't require a separate rgb/w bit.""" return self.rgbwcapable or self.model_data.always_writes_white_and_colors @property def microphone(self) -> bool: """Devices that have a microphone built in.""" return self.model_data.microphone @property def rgbwcapable(self) -> bool: """Devices that actually support rgbw.""" color_modes = self.color_modes return COLOR_MODE_RGBW in color_modes or COLOR_MODE_RGBWW in color_modes @property def device_type(self) -> DeviceType: """Return the device type.""" is_switch = self.model_num in MODEL_NUMS_SWITCHS return DeviceType.Switch if is_switch else DeviceType.Bulb @property def color_temp(self) -> int: """Return the current color temp in kelvin.""" return (self.getWhiteTemperature())[0] @property def min_temp(self) -> int: """Returns the minimum color temp in kelvin.""" return int(self._white_channel_channel_type.value) @property def max_temp(self) -> int: """Returns the maximum color temp in kelvin.""" return MAX_TEMP @property def _rgbwwprotocol(self) -> bool: """Device that uses the 9-byte protocol.""" return self.protocol in ( PROTOCOL_LEDENET_9BYTE, PROTOCOL_LEDENET_9BYTE_DIMMABLE_EFFECTS, ) @property def white_active(self) -> bool: """Any white channel is active.""" assert self.raw_state is not None raw_state = self.raw_state return bool(raw_state.warm_white or raw_state.cool_white) @property def color_active(self) -> bool: """Any color channel is active.""" assert self.raw_state is not None raw_state = self.raw_state return bool(raw_state.red or raw_state.green or raw_state.blue) def rgbw_color_temp_support(self, color_modes: Set[str]) -> bool: """RGBW color temp support.""" return COLOR_MODE_RGBW in color_modes and self.max_temp != self.min_temp @property def color_is_white_only(self) -> bool: """Return if the curent color is active and white.""" assert self.raw_state is not None raw_state = self.raw_state return bool( # At least one channel is on (raw_state.red or raw_state.green or raw_state.blue or raw_state.warm_white) # The color channels are white and raw_state.red == raw_state.green == raw_state.blue ) @property def multi_color_mode(self) -> bool: """The device supports multiple color modes.""" return len(self.color_modes) > 1 @property def color_modes(self) -> Set[str]: """The available color modes.""" color_modes = self._internal_color_modes # We support CCT mode if the device supports RGBWW # but we do not add it to internal color modes as # we need to distingush between devices that are RGB/CCT # and ones that are RGB&CCT if ( COLOR_MODE_CCT not in color_modes and COLOR_MODE_RGBWW in color_modes or self.rgbw_color_temp_support(color_modes) ): return {COLOR_MODE_CCT, *color_modes} return color_modes @property def _internal_color_modes(self) -> Set[str]: """The internal available color modes.""" assert self.raw_state is not None if ( self._device_config is not None # Currently this is only the SK6812RGBW strips on 0xA3 and self._device_config.operating_mode == COLOR_MODE_RGBW ): return {COLOR_MODE_RGBW} if not is_known_model(self.model_num): # Default mode is RGB return BASE_MODE_MAP.get(self.raw_state.mode & 0x0F, {DEFAULT_MODE}) model_data = self.model_data return model_data.mode_to_color_mode.get( self.raw_state.mode, model_data.color_modes ) @property def pixels_per_segment(self) -> Optional[int]: """Return the pixels per segment.""" if self._device_config is None: return None return self._device_config.pixels_per_segment @property def segments(self) -> Optional[int]: """Return the number of segments.""" if self._device_config is None: return None return self._device_config.segments @property def music_pixels_per_segment(self) -> Optional[int]: """Return the music pixels per segment.""" if self._device_config is None: return None return self._device_config.music_pixels_per_segment @property def music_segments(self) -> Optional[int]: """Return the number of music segments.""" if self._device_config is None: return None return self._device_config.music_segments @property def wiring(self) -> Optional[str]: """Return the sort order as a string.""" device_config = self.model_data.device_config if not device_config.wiring: return None if self._device_config: return self._device_config.wiring assert self.raw_state is not None return device_config.num_to_wiring.get(int((self.raw_state.mode & 0xF0) / 16)) @property def wiring_num(self) -> Optional[int]: """Return the wiring number.""" if not self.model_data.device_config.wiring: return None if self._device_config: return self._device_config.wiring_num assert self.raw_state is not None return int((self.raw_state.mode & 0xF0) / 16) @property def wirings(self) -> Optional[List[str]]: """Return available wirings for the device.""" device_config = self.model_data.device_config if not device_config.wiring: return None if self._device_config: return list(self._device_config.wirings) return list(device_config.wiring_to_num) @property def operating_mode(self) -> Optional[str]: """Return the strip mode as a string.""" device_config = self.model_data.device_config if not device_config.operating_modes: return None if self._device_config: return self._device_config.operating_mode assert self.raw_state is not None return device_config.num_to_operating_mode.get(self.raw_state.mode & 0x0F) @property def operating_mode_num(self) -> Optional[int]: """Return the strip mode as a string.""" if not self.model_data.device_config.operating_modes: return None assert self.raw_state is not None return self.raw_state.mode & 0x0F @property def operating_modes(self) -> Optional[List[str]]: """Return available operating modes for the device.""" if not self.model_data.device_config.operating_modes: return None return list(self.model_data.device_config.operating_mode_to_num) @property def ic_type(self) -> Optional[str]: """Return the strip ictype as a string.""" if not self.model_data.device_config.ic_type: return None assert self._device_config is not None return self._device_config.ic_type @property def ic_type_num(self) -> Optional[int]: """Return the strip ictype as an int.""" if not self.model_data.device_config.ic_type: return None assert self._device_config is not None return self._device_config.ic_type_num @property def ic_types(self) -> Optional[List[str]]: """Return the ic types.""" if not self.model_data.device_config.ic_type: return None return list(self.model_data.device_config.ic_type_to_num) @property def color_mode(self) -> Optional[str]: """The current color mode.""" color_modes = self._internal_color_modes if COLOR_MODE_RGBWW in color_modes: # We support CCT mode if the device supports RGBWW return COLOR_MODE_RGBWW if self.color_active else COLOR_MODE_CCT if self.rgbw_color_temp_support(color_modes): # We support CCT mode if the device supports RGB&W return COLOR_MODE_CCT if self.color_is_white_only else COLOR_MODE_RGBW if ( color_modes == COLOR_MODES_RGB_CCT ): # RGB/CCT split, only one active at a time return COLOR_MODE_CCT if self.white_active else COLOR_MODE_RGB if color_modes == COLOR_MODES_RGB_W: # RGB/W split, only one active at a time return COLOR_MODE_DIM if self.white_active else COLOR_MODE_RGB if color_modes: return list(color_modes)[0] return None # Usually a switch or non-light device @property def protocol(self) -> Optional[str]: """Returns the name of the protocol in use.""" if self._protocol is None: return None return self._protocol.name @property def dimmable_effects(self) -> bool: """Return true of the device supports dimmable effects.""" assert self._protocol is not None return self._protocol.dimmable_effects @property def requires_turn_on(self) -> bool: """Return true of the device requires a power on command before setting levels/effects.""" assert self._protocol is not None return self._protocol.requires_turn_on @property def is_on(self) -> bool: assert self.raw_state is not None assert self._protocol is not None return self.raw_state.power_state == self._protocol.on_byte @property def mode(self) -> Optional[str]: return self._mode @property def warm_white(self) -> int: assert self.raw_state is not None return self.raw_state.warm_white if self._rgbwwprotocol else 0 @property def effect_list(self) -> List[str]: """Return the list of available effects.""" effects: Iterable[str] = [] protocol = self.protocol if protocol in OLD_EFFECTS_PROTOCOLS: effects = ORIGINAL_ADDRESSABLE_EFFECT_ID_NAME.values() elif protocol in NEW_EFFECTS_PROTOCOLS: effects = ADDRESSABLE_EFFECT_ID_NAME.values() elif protocol in CHRISTMAS_EFFECTS_PROTOCOLS: effects = CHRISTMAS_ADDRESSABLE_EFFECT_ID_NAME.values() elif COLOR_MODES_RGB.intersection(self.color_modes): effects = EFFECT_LIST_DIMMABLE if self.dimmable_effects else EFFECT_LIST elif protocol == PROTOCOL_LEDENET_ORIGINAL_CCT: effects = EFFECT_LIST_LEGACY_CCT if self.microphone: return [*effects, EFFECT_RANDOM, EFFECT_MUSIC] return [*effects, EFFECT_RANDOM] @property def effect(self) -> Optional[str]: """Return the current effect.""" if self.protocol in CHRISTMAS_EFFECTS_PROTOCOLS: return self._named_effect return PATTERN_CODE_TO_EFFECT.get(self.preset_pattern_num, self._named_effect) @property def _named_effect(self) -> Optional[str]: """Returns the named effect.""" assert self.raw_state is not None mode = self.raw_state.mode pattern_code = self.preset_pattern_num protocol = self.protocol if protocol in OLD_EFFECTS_PROTOCOLS: effect_id = (pattern_code << 8) + mode - 99 return ORIGINAL_ADDRESSABLE_EFFECT_ID_NAME.get(effect_id) if protocol in NEW_EFFECTS_PROTOCOLS: if pattern_code == 0x25: return ADDRESSABLE_EFFECT_ID_NAME.get(mode) if pattern_code == 0x24: return ASSESSABLE_MULTI_COLOR_ID_NAME.get(mode) return None if protocol in CHRISTMAS_EFFECTS_PROTOCOLS: if pattern_code == 0x25: return CHRISTMAS_ADDRESSABLE_EFFECT_ID_NAME.get(mode) return None if protocol == PROTOCOL_LEDENET_ORIGINAL_CCT: return EFFECT_ID_NAME_LEGACY_CCT.get(pattern_code) return EFFECT_ID_NAME.get(pattern_code) @property def cool_white(self) -> int: assert self.raw_state is not None if self._rgbwwprotocol: return self.raw_state.cool_white return 0 # Old name is deprecated @property def cold_white(self) -> int: return self.cool_white @property def brightness(self) -> int: """Return current brightness 0-255. For warm white return current led level. For RGB calculate the HSV and return the 'value'. for CCT calculate the brightness. for ww send led level """ color_mode = self.color_mode raw_state = self.raw_state assert raw_state is not None if self._named_effect: if self.dimmable_effects: if ( self.protocol in NEW_EFFECTS_PROTOCOLS and time.monotonic() > self._transition_complete_time ): # the red byte holds the brightness during an effect return min(255, round(raw_state.red * 255 / 100)) return round(self._last_effect_brightness * 255 / 100) return 255 if raw_state.preset_pattern in PRESET_MUSIC_MODES and not self.dimmable_effects: return 255 if color_mode == COLOR_MODE_DIM: return int(raw_state.warm_white) elif color_mode == COLOR_MODE_CCT: _, b = self.getWhiteTemperature() return b r, g, b = self.getRgb() _, _, v = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255) v_255 = v * 255 if color_mode == COLOR_MODE_RGBW: return round((v_255 + raw_state.warm_white) / 2) if color_mode == COLOR_MODE_RGBWW: return round((v_255 + raw_state.warm_white + raw_state.cool_white) / 3) # Default color mode (RGB) return int(v_255) def _determineMode(self) -> Optional[str]: assert self.raw_state is not None pattern_code = self.raw_state.preset_pattern if self.device_type == DeviceType.Switch: return MODE_SWITCH if pattern_code in (0x41, 0x61): if self.color_mode in {COLOR_MODE_DIM, COLOR_MODE_CCT}: return MODE_WW return MODE_COLOR if pattern_code == EFFECT_CUSTOM_CODE: return ( MODE_PRESET if self.protocol in CHRISTMAS_EFFECTS_PROTOCOLS else MODE_CUSTOM ) if pattern_code in (PRESET_MUSIC_MODE, PRESET_MUSIC_MODE_LEGACY): return MODE_MUSIC if PresetPattern.valid(pattern_code): return MODE_PRESET if BuiltInTimer.valid(pattern_code): return BuiltInTimer.valtostr(pattern_code) if self.protocol in ADDRESSABLE_PROTOCOLS: return MODE_PRESET return None @property def port(self) -> int: """Return the discovered port.""" if self._port: return self._port if ( self._discovery and "armacost" in (self._discovery.get(ATTR_MODEL_INFO) or "").lower() ): return ARMACOST_PORT return DEFAULT_PORT def set_unavailable(self, reason: str) -> None: _LOGGER.debug("%s: set_unavailable: %s", self.ipaddr, reason) self._unavailable_reason = reason self.available = False def set_available(self, reason: str) -> None: _LOGGER.debug("%s: set_available: %s", self.ipaddr, reason) self._unavailable_reason = None self.available = True def process_device_config_response(self, msg: bytes) -> None: """Process an IC (strip config) response.""" assert isinstance(self._protocol, ALL_IC_PROTOCOLS) self._device_config = self._protocol.parse_strip_setting(msg) _LOGGER.debug("%s: device_config: %s", self.ipaddr, self._device_config) def process_state_response(self, rx: bytes) -> bool: assert self._protocol is not None if not self._protocol.is_valid_state_response(rx): _LOGGER.warning( "%s: Recieved invalid response: %s", self.ipaddr, utils.raw_state_to_dec(rx), ) return False raw_state: Union[ LEDENETOriginalRawState, LEDENETRawState ] = self._protocol.named_raw_state(rx) _LOGGER.debug("%s: State: %s", self.ipaddr, raw_state) if raw_state != self.raw_state: _LOGGER.debug( "%s: unmapped raw state: %s", self.ipaddr, utils.raw_state_to_dec(raw_state), ) now_time = time.monotonic() transition_states = set() if now_time < self._power_state_transition_complete_time: transition_states.add(STATE_POWER_STATE) if now_time < self._transition_complete_time: # Do not update the channel states if a transition is # in progress as the state will not be correct # until the transition is completed since devices # "FADE" into the state requested. transition_states |= CHANNEL_STATES if now_time < self._preset_pattern_transition_complete_time: transition_states.add("preset_pattern") if transition_states: self._replace_raw_state( { name: value for name, value in raw_state._asdict().items() if name not in transition_states } ) else: self._set_raw_state(raw_state) _LOGGER.debug("%s: Mapped State: %s", self.ipaddr, self.raw_state) mode = self._determineMode() if mode is None: _LOGGER.debug( "%s: Unable to determine mode from raw state: %s", self.ipaddr, utils.raw_state_to_dec(rx), ) return False self._mode = mode return True def process_power_state_response(self, msg: bytes) -> bool: """Process a power state change message.""" assert self._protocol is not None if not self._protocol.is_valid_power_state_response(msg): _LOGGER.warning( "%s: Recieved invalid power state response: %s", self.ipaddr, utils.raw_state_to_dec(msg), ) return False _LOGGER.debug("%s: Setting power state to: %s", self.ipaddr, f"0x{msg[2]:02X}") self._set_power_state(msg[2]) return True def _set_raw_state( self, raw_state: Union[LEDENETOriginalRawState, LEDENETRawState], updated: Optional[Set[str]] = None, ) -> None: """Set the raw state remapping channels as needed. The goal is to normalize the data so the raw state is always in the same format reguardless of the protocol Some devices need to have channels remapped Other devices uses color_temp/brightness format which needs to be converted back to 0-255 values for warm_white and cool_white """ channel_map = self.model_data.channel_map # Only remap updated states as we do not want to switch any # state that have not changed since they will already be in # the correct slot # # If updated is None than all raw_state values have been sent # if self._whites_are_temp_brightness: assert isinstance(raw_state, LEDENETRawState) # Only convert on a full update since we still use 0-255 internally if updated is not None: self.raw_state = raw_state return # warm_white is the color temp from 1-100 temp = raw_state.warm_white # cold_white is the brightness from 1-100 brightness = raw_state.cool_white warm_white, cool_white = scaled_color_temp_to_white_levels(temp, brightness) self.raw_state = raw_state._replace( warm_white=warm_white, cool_white=cool_white ) return if channel_map: if updated is None: updated = set(channel_map.keys()) self.raw_state = raw_state._replace( **{ name: getattr(raw_state, source) if source in updated else getattr(raw_state, name) for name, source in channel_map.items() } ) return if isinstance(self._protocol, ProtocolLEDENETAddressableA3): if updated is not None: self.raw_state = raw_state return # A3 uses a unique scale for warm white self.raw_state = raw_state._replace( warm_white=utils.A3WarmWhiteToByte(raw_state.warm_white) ) return self.raw_state = raw_state def __str__(self) -> str: # noqa: C901 assert self.raw_state is not None assert self._protocol is not None rx = self.raw_state if not rx: return "No state data" mode = self.mode color_mode = self.color_mode power_str = "Unknown power state" if rx.power_state == self._protocol.on_byte: power_str = "ON " elif rx.power_state == self._protocol.off_byte: power_str = "OFF " if mode in STATIC_MODES: if color_mode in COLOR_MODES_RGB: mode_str = f"Color: {(rx.red, rx.green, rx.blue)}" # Should add ability to get CCT from rgbwcapable* if self.rgbwcapable: mode_str += f" White: {rx.warm_white}" else: mode_str += f" Brightness: {round(self.brightness * 100 / 255)}%" elif color_mode == COLOR_MODE_DIM: mode_str = f"Warm White: {utils.byteToPercent(rx.warm_white)}%" elif color_mode == COLOR_MODE_CCT: cct_value = self.getWhiteTemperature() mode_str = "CCT: {}K Brightness: {}%".format( cct_value[0], round(cct_value[1] * 100 / 255) ) elif mode == MODE_PRESET: mode_str = f"Pattern: {self.effect} (Speed {self.speed}%)" elif mode == MODE_CUSTOM: mode_str = f"Custom pattern (Speed {self.speed}%)" elif BuiltInTimer.valid(rx.preset_pattern): mode_str = BuiltInTimer.valtostr(rx.preset_pattern) elif mode == MODE_MUSIC: mode_str = "Music" elif mode == MODE_SWITCH: mode_str = "Switch" else: mode_str = f"Unknown mode 0x{rx.preset_pattern:x}" mode_str += " raw state: " mode_str += utils.raw_state_to_dec(rx) return f"{power_str} [{mode_str}]" def _set_power_state(self, new_power_state: int) -> None: """Set the power state in the raw state.""" self._replace_raw_state({"power_state": new_power_state}) self._set_transition_complete_time() def _replace_raw_state(self, new_states: Dict[str, int]) -> None: assert self.raw_state is not None _LOGGER.debug("%s: _replace_raw_state: %s", self.ipaddr, new_states) self._set_raw_state( self.raw_state._replace(**new_states), set(new_states.keys()) ) def isOn(self) -> bool: return self.is_on def getWarmWhite255(self) -> int: if self.color_mode not in {COLOR_MODE_CCT, COLOR_MODE_DIM}: return 255 return self.brightness def getWhiteTemperature(self) -> Tuple[int, int]: """Returns the color temp and brightness""" # Assume input temperature of between 2700 and 6500 Kelvin, and scale # the warm and cold LEDs linearly to provide that assert self.raw_state is not None raw_state = self.raw_state warm_white = raw_state.warm_white if self.rgbw_color_temp_support(self.color_modes): cool_white = raw_state.red if self.color_is_white_only else 0 else: cool_white = raw_state.cool_white temp, brightness = white_levels_to_color_temp( warm_white, cool_white, self.min_temp, self.max_temp ) return temp, brightness def getRgbw(self) -> Tuple[int, int, int, int]: """Returns red,green,blue,white (usually warm).""" if self.color_mode not in COLOR_MODES_RGB: return (255, 255, 255, 255) return self.rgbw @property def rgbw(self) -> Tuple[int, int, int, int]: """Returns red,green,blue,white (usually warm).""" assert self.raw_state is not None raw_state = self.raw_state return ( raw_state.red, raw_state.green, raw_state.blue, raw_state.warm_white, ) def getRgbww(self) -> Tuple[int, int, int, int, int]: """Returns red,green,blue,warm,cool.""" if self.color_mode not in COLOR_MODES_RGB: return (255, 255, 255, 255, 255) return self.rgbww @property def rgbww(self) -> Tuple[int, int, int, int, int]: """Returns red,green,blue,warm,cool.""" raw_state = self.raw_state assert raw_state is not None return ( raw_state.red, raw_state.green, raw_state.blue, raw_state.warm_white, raw_state.cool_white, ) def getRgbcw(self) -> Tuple[int, int, int, int, int]: """Returns red,green,blue,cool,warm.""" if self.color_mode not in COLOR_MODES_RGB: return (255, 255, 255, 255, 255) return self.rgbcw @property def rgbcw(self) -> Tuple[int, int, int, int, int]: """Returns red,green,blue,cool,warm.""" raw_state = self.raw_state assert raw_state is not None return ( raw_state.red, raw_state.green, raw_state.blue, raw_state.cool_white, raw_state.warm_white, ) def getCCT(self) -> Tuple[int, int]: if self.color_mode != COLOR_MODE_CCT: return (255, 255) raw_state = self.raw_state assert raw_state is not None return (raw_state.warm_white, raw_state.cool_white) @property def speed(self) -> int: assert self.raw_state is not None if self.protocol in ADDRESSABLE_PROTOCOLS: return self.raw_state.speed if self.protocol in CHRISTMAS_EFFECTS_PROTOCOLS: return utils.delayToSpeed(self.raw_state.green) return utils.delayToSpeed(self.raw_state.speed) def getSpeed(self) -> int: return self.speed def _generate_random_levels_change(self) -> Tuple[List[bytearray], Dict[str, int]]: """Generate a random levels change.""" channels = {STATE_WARM_WHITE} if COLOR_MODES_RGB.intersection(self.color_modes): channels = {STATE_RED, STATE_GREEN, STATE_BLUE} elif COLOR_MODE_CCT in self.color_modes: channels = {STATE_WARM_WHITE, STATE_COOL_WHITE} return self._generate_levels_change( { channel: random.randint(0, 255) if channel in channels else None for channel in CHANNEL_STATES } ) def _generate_levels_change( # noqa: C901 self, channels: Dict[str, Optional[int]], persist: bool = True, brightness: Optional[int] = None, ) -> Tuple[List[bytearray], Dict[str, int]]: """Generate the levels change request.""" channel_map = self.model_data.channel_map if channel_map: mapped_channels = { channel: channels[channel_map.get(channel, channel)] for channel in channels } else: mapped_channels = channels r = mapped_channels[STATE_RED] g = mapped_channels[STATE_GREEN] b = mapped_channels[STATE_BLUE] w = mapped_channels[STATE_WARM_WHITE] w2 = mapped_channels[STATE_COOL_WHITE] if (r or g or b) and (w or w2) and not self.rgbwcapable: raise ValueError("RGB&CW command sent to non-RGB&CW device") if brightness is not None and r is not None and g is not None and b is not None: (r, g, b) = self._calculateBrightness((r, g, b), brightness) r_value = None if r is None else int(r) g_value = None if g is None else int(g) b_value = None if b is None else int(b) w_value = None if w is None else int(w) # ProtocolLEDENET9Byte devices support two white outputs for cold and warm. if w2 is None: if w is not None and self.color_mode in {COLOR_MODE_CCT, COLOR_MODE_RGBWW}: # If we're only setting a single white value, we preserve the cold white value w2_value: Optional[int] = self.cold_white else: # If we're only setting a single white value, we set the second output to be the same as the first w2_value = w_value else: w2_value = int(w2) write_mode = LevelWriteMode.ALL # rgbwprotocol always overwrite both color & whites if not self.rgbwprotocol: if w is None and w2 is None: write_mode = LevelWriteMode.COLORS elif r is None and g is None and b is None: write_mode = LevelWriteMode.WHITES assert self._protocol is not None msgs = self._protocol.construct_levels_change( persist, r_value, g_value, b_value, w_value, w2_value, write_mode ) updates = {} multi_mode = self.multi_color_mode if multi_mode or write_mode in WRITE_ALL_COLORS: updates.update( {"red": r_value or 0, "green": g_value or 0, "blue": b_value or 0} ) if multi_mode or write_mode in WRITE_ALL_WHITES: updates.update({"warm_white": w_value or 0, "cool_white": w2_value or 0}) return msgs, updates def _set_transition_complete_time(self) -> None: """Set the time we expect the transition will be completed. Devices fade to a specific state so we want to avoid consuming state updates into self.raw_state while a transition is in progress as this will provide unexpected results and the brightness values will be wrong until the transition completes. """ assert self.raw_state is not None latency = STATE_CHANGE_LATENCY if self.protocol in ADDRESSABLE_PROTOCOLS: latency = ADDRESSABLE_STATE_CHANGE_LATENCY transition_time = latency + utils.speedToDelay(self.raw_state.speed) / 100 self._transition_complete_time = time.monotonic() + transition_time _LOGGER.debug( "%s: Transition time is %s, set _transition_complete_time to %s", self.ipaddr, transition_time, self._transition_complete_time, ) # If we are doing a state transition cancel and preset pattern transition self._preset_pattern_transition_complete_time = NEVER_TIME def _set_preset_pattern_transition_complete_time(self) -> None: """Set the time we expect the preset_pattern transition will be completed.""" assert self.raw_state is not None self._preset_pattern_transition_complete_time = ( time.monotonic() + PRESET_PATTERN_CHANGE_LATENCY ) _LOGGER.debug( "%s: Mode transition time is %s, set _preset_pattern_transition_complete_time to %s", self.ipaddr, PRESET_PATTERN_CHANGE_LATENCY, self._preset_pattern_transition_complete_time, ) def _set_power_transition_complete_time(self) -> None: """Set the time we expect the power transition will be completed.""" assert self.raw_state is not None self._power_state_transition_complete_time = ( time.monotonic() + POWER_STATE_CHANGE_LATENCY ) _LOGGER.debug( "%s: Mode transition time is %s, set _power_state_transition_complete_time to %s", self.ipaddr, POWER_STATE_CHANGE_LATENCY, self._power_state_transition_complete_time, ) def getRgb(self) -> Tuple[int, int, int]: if self.color_mode not in COLOR_MODES_RGB: return (255, 255, 255) return self.rgb @property def rgb(self) -> Tuple[int, int, int]: assert self.raw_state is not None raw_state = self.raw_state return (raw_state.red, raw_state.green, raw_state.blue) @property def rgb_unscaled(self) -> Tuple[int, int, int]: """Return the unscaled RGB.""" r, g, b = self.rgb hsv = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0) r_p, g_p, b_p = colorsys.hsv_to_rgb(hsv[0], hsv[1], 1) return round(r_p * 255), round(g_p * 255), round(b_p * 255) def _calculateBrightness( self, rgb: Tuple[int, int, int], level: int ) -> Tuple[int, int, int]: hsv = colorsys.rgb_to_hsv(*rgb) r, g, b = colorsys.hsv_to_rgb(hsv[0], hsv[1], level) return int(r), int(g), int(b) def setProtocol(self, protocol: str) -> None: cls = PROTOCOL_NAME_TO_CLS.get(protocol) if cls is None: raise ValueError(f"Invalid protocol: {protocol}") self._protocol = cls() # type: ignore def _set_protocol_from_msg( self, full_msg: bytes, fallback_protocol: str, ) -> None: self._model_num = full_msg[1] self._model_data = get_model(self._model_num, fallback_protocol) version_num = full_msg[10] if len(full_msg) > 10 else 1 self.setProtocol(self._model_data.protocol_for_version_num(version_num)) def _generate_preset_pattern( self, pattern: int, speed: int, brightness: int ) -> bytearray: """Generate the preset pattern protocol bytes.""" protocol = self.protocol if protocol in OLD_EFFECTS_PROTOCOLS: if pattern not in ORIGINAL_ADDRESSABLE_EFFECT_ID_NAME: raise ValueError("Pattern must be between 1 and 302") elif protocol in NEW_EFFECTS_PROTOCOLS: if pattern not in ADDRESSABLE_EFFECT_ID_NAME: raise ValueError("Pattern must be between 1 and 100") elif protocol in CHRISTMAS_EFFECTS_PROTOCOLS: if pattern not in CHRISTMAS_ADDRESSABLE_EFFECT_ID_NAME: raise ValueError("Pattern must be between 1 and 100") else: PresetPattern.valid_or_raise(pattern) if not (1 <= brightness <= 100): raise ValueError("Brightness must be between 1 and 100") self._last_effect_brightness = brightness assert self._protocol is not None return self._protocol.construct_preset_pattern(pattern, speed, brightness) def _generate_custom_patterm( self, rgb_list: List[Tuple[int, int, int]], speed: int, transition_type: str ) -> bytearray: """Generate the custom pattern protocol bytes.""" # truncate if more than 16 if len(rgb_list) > 16: _LOGGER.warning( "Too many colors in %s, truncating list to %s", len(rgb_list), 16 ) del rgb_list[16:] # quit if too few if len(rgb_list) == 0: raise ValueError("setCustomPattern requires at least one color tuples") assert self._protocol is not None return self._protocol.construct_custom_effect(rgb_list, speed, transition_type) def _effect_to_pattern(self, effect: str) -> int: """Convert an effect to a pattern code.""" protocol = self.protocol if protocol in CHRISTMAS_EFFECTS_PROTOCOLS: return CHRISTMAS_ADDRESSABLE_EFFECT_NAME_ID[effect] if protocol in NEW_EFFECTS_PROTOCOLS: return ADDRESSABLE_EFFECT_NAME_ID[effect] if protocol in OLD_EFFECTS_PROTOCOLS: return ORIGINAL_ADDRESSABLE_EFFECT_NAME_ID[effect] return PresetPattern.str_to_val(effect) @property def diagnostics(self) -> Dict[str, Any]: """Return diagnostics for the device.""" data: Dict[str, Any] = {"device_state": {}, "last_messages": {}} last_messages = data["last_messages"] for name, msg in self._last_message.items(): last_messages[name] = " ".join(f"0x{x:02X}" for x in msg) device_state = data["device_state"] for name in dir(self): if name.startswith("_") or name == "diagnostics" or not hasattr(self, name): continue value: Any = getattr(self, name) if is_dataclass(value): value = asdict(value) if hasattr(value, "value"): value = value.value if value is None or isinstance(value, SERIALIZABLE_TYPES): device_state[name] = value return data Danielhiversen-flux_led-bfd1bbe/flux_led/const.py000077500000000000000000000076271447734565100224420ustar00rootroot00000000000000"""FluxLED Models Database.""" import sys from enum import Enum if sys.version_info >= (3, 8): from typing import Final # pylint: disable=no-name-in-module else: from typing_extensions import Final MIN_TEMP: Final = 2700 MAX_TEMP: Final = 6500 class WhiteChannelType(Enum): WARM = MIN_TEMP NATURAL = MAX_TEMP - ((MAX_TEMP - MIN_TEMP) / 2) COLD = MAX_TEMP class LevelWriteMode(Enum): ALL = 0x00 COLORS = 0xF0 WHITES = 0x0F class MultiColorEffects(Enum): STATIC = 0x01 RUNNING_WATER = 0x02 STROBE = 0x03 JUMP = 0x04 BREATHING = 0x05 DEFAULT_WHITE_CHANNEL_TYPE: Final = WhiteChannelType.WARM PRESET_MUSIC_MODE: Final = 0x62 PRESET_MUSIC_MODE_LEGACY: Final = 0x5D PRESET_MUSIC_MODES: Final = {PRESET_MUSIC_MODE, PRESET_MUSIC_MODE_LEGACY} ATTR_IPADDR: Final = "ipaddr" ATTR_ID: Final = "id" ATTR_MODEL: Final = "model" ATTR_MODEL_NUM: Final = "model_num" ATTR_VERSION_NUM: Final = "version_num" ATTR_FIRMWARE_DATE: Final = "firmware_date" ATTR_MODEL_INFO: Final = "model_info" ATTR_MODEL_DESCRIPTION: Final = "model_description" ATTR_REMOTE_ACCESS_ENABLED: Final = "remote_access_enabled" ATTR_REMOTE_ACCESS_HOST: Final = "remote_access_host" ATTR_REMOTE_ACCESS_PORT: Final = "remote_access_port" # Color modes COLOR_MODE_DIM: Final = "DIM" COLOR_MODE_CCT: Final = "CCT" COLOR_MODE_RGB: Final = "RGB" COLOR_MODE_RGBW: Final = "RGBW" COLOR_MODE_RGBWW: Final = "RGBWW" COLOR_MODE_ADDRESSABLE: Final = "ADDRESSABLE" POWER_STATE_CHANGE_LATENCY: Final = 3 STATE_CHANGE_LATENCY: Final = 2 ADDRESSABLE_STATE_CHANGE_LATENCY: Final = 5 PRESET_PATTERN_CHANGE_LATENCY: Final = 40 # Time to switch to music mode WRITE_ALL_COLORS = (LevelWriteMode.ALL, LevelWriteMode.COLORS) WRITE_ALL_WHITES = (LevelWriteMode.ALL, LevelWriteMode.WHITES) DEFAULT_RETRIES: Final = 2 # Modes MODE_SWITCH: Final = "switch" MODE_COLOR: Final = "color" MODE_WW: Final = "ww" MODE_CUSTOM: Final = "custom" MODE_MUSIC: Final = "music" MODE_PRESET: Final = "preset" # Transitions TRANSITION_JUMP: Final = "jump" TRANSITION_STROBE: Final = "strobe" TRANSITION_GRADUAL: Final = "gradual" STATIC_MODES = {MODE_COLOR, MODE_WW} # Non light device models MODEL_NUMS_SWITCHS = {0x19, 0x93, 0x0B, 0x93, 0x94, 0x95, 0x96, 0x97} COLOR_MODES_RGB = {COLOR_MODE_RGB, COLOR_MODE_RGBW, COLOR_MODE_RGBWW} COLOR_MODES_RGB_CCT = { # AKA Split RGB & CCT modes used for bulbs/lamps COLOR_MODE_RGB, COLOR_MODE_CCT, } COLOR_MODES_RGB_W = { # AKA RGB/W in the Magic Home Pro app COLOR_MODE_RGB, COLOR_MODE_DIM, } COLOR_MODES_ADDRESSABLE = {COLOR_MODE_RGB} DEFAULT_MODE: Final = COLOR_MODE_RGB # States STATE_HEAD: Final = "head" STATE_MODEL_NUM: Final = "model_num" STATE_POWER_STATE: Final = "power_state" STATE_PRESET_PATTERN: Final = "preset_pattern" STATE_MODE: Final = "mode" STATE_SPEED: Final = "speed" STATE_RED: Final = "red" STATE_GREEN: Final = "green" STATE_BLUE: Final = "blue" STATE_WARM_WHITE: Final = "warm_white" STATE_VERSION_NUMBER: Final = "version_number" STATE_COOL_WHITE: Final = "cool_white" STATE_COLOR_MODE: Final = "color_mode" STATE_CHECK_SUM: Final = "check_sum" CHANNEL_STATES = { STATE_RED, STATE_GREEN, STATE_BLUE, STATE_WARM_WHITE, STATE_COOL_WHITE, } EFFECT_RANDOM = "random" EFFECT_MUSIC = "music" # Addressable limits SEGMENTS_MAX: Final = 2048 PIXELS_MAX: Final = 2048 PIXELS_PER_SEGMENT_MAX: Final = 300 MUSIC_SEGMENTS_MAX: Final = 64 MUSIC_PIXELS_MAX: Final = 960 MUSIC_PIXELS_PER_SEGMENT_MAX: Final = 150 # # PUSH_UPDATE_INTERVAL reduces polling the device for state when its off # since we do not care about the state when its off. When it turns on # the device will push its new state to us anyways (except for buggy firmwares # are identified in protocol.py) # # The downside to a longer polling interval for OFF is the # time to declare the device offline is MAX_UPDATES_WITHOUT_RESPONSE*PUSH_UPDATE_INTERVAL # PUSH_UPDATE_INTERVAL = 90 # seconds NEVER_TIME = -PUSH_UPDATE_INTERVAL Danielhiversen-flux_led-bfd1bbe/flux_led/device.py000066400000000000000000000314761447734565100225470ustar00rootroot00000000000000import datetime import logging import select import socket import threading import time from typing import Dict, List, Optional, Tuple from flux_led.protocol import LEDENET_TIME_RESPONSE_LEN, ProtocolLEDENETOriginal from .base_device import LEDENETDevice from .const import ( DEFAULT_RETRIES, EFFECT_RANDOM, STATE_BLUE, STATE_COOL_WHITE, STATE_GREEN, STATE_RED, STATE_WARM_WHITE, ) from .scanner import FluxLEDDiscovery from .sock import _socket_retry from .timer import LedTimer from .utils import color_temp_to_white_levels, utils _LOGGER = logging.getLogger(__name__) class WifiLedBulb(LEDENETDevice): """A LEDENET Wifi bulb device.""" def __init__( self, ipaddr: str, port: int = 5577, timeout: float = 5, discovery: Optional[FluxLEDDiscovery] = None, ) -> None: """Init and setup the bulb.""" super().__init__(ipaddr, port, timeout, discovery) self._socket: Optional[socket.socket] = None self._lock = threading.Lock() self.setup() def setup(self) -> None: """Setup the connection and fetch initial state.""" self.connect(retry=DEFAULT_RETRIES) self.update_state() def _connect_if_disconnected(self) -> None: """Connect only if not already connected.""" if self._socket is None: self.connect() @_socket_retry(attempts=0) # type: ignore def connect(self) -> None: self.close() self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._socket.settimeout(self.timeout) _LOGGER.debug("%s: connect", self.ipaddr) self._socket.connect((self.ipaddr, self.port)) def close(self) -> None: if self._socket is None: return try: self._socket.close() except OSError: pass finally: self._socket = None def turnOn(self, retry: int = DEFAULT_RETRIES) -> None: self._change_state(retry=retry, turn_on=True) def turnOff(self, retry: int = DEFAULT_RETRIES) -> None: self._change_state(retry=retry, turn_on=False) @_socket_retry(attempts=DEFAULT_RETRIES) # type: ignore def _change_state(self, turn_on: bool = True) -> None: assert self._protocol is not None _LOGGER.debug("%s: Changing state to %s", self.ipaddr, turn_on) with self._lock: self._connect_if_disconnected() self._send_msg(self._protocol.construct_state_change(turn_on)) # After changing state, the device replies with expected_response_len = 4 # - 0x0F 0x71 [0x23|0x24] [CHECK DIGIT] rx = self._read_msg(expected_response_len) _LOGGER.debug("%s: state response %s", self.ipaddr, rx) if rx is not None and len(rx) == expected_response_len: # We cannot use the power state workaround here # since we are not listening for power state changes # like the aio version new_power_state = ( self._protocol.on_byte if turn_on else self._protocol.off_byte ) self._set_power_state(new_power_state) # The device will send back a state change here # but it will likely be stale so we want to recycle # the connetion so we do not have to wait as sometimes # it stalls self.close() def setWarmWhite( self, level: int, persist: bool = True, retry: int = DEFAULT_RETRIES ) -> None: self.set_levels(w=utils.percentToByte(level), persist=persist, retry=retry) def setWarmWhite255( self, level: int, persist: bool = True, retry: int = DEFAULT_RETRIES ) -> None: self.set_levels(w=level, persist=persist, retry=retry) def setColdWhite( self, level: int, persist: bool = True, retry: int = DEFAULT_RETRIES ) -> None: self.set_levels(w2=utils.percentToByte(level), persist=persist, retry=retry) def setColdWhite255( self, level: int, persist: bool = True, retry: int = DEFAULT_RETRIES ) -> None: self.set_levels(w2=level, persist=persist, retry=retry) def setWhiteTemperature( self, temperature: int, brightness: int, persist: bool = True, retry: int = DEFAULT_RETRIES, ) -> None: warm, cold = color_temp_to_white_levels( temperature, brightness, self.min_temp, self.max_temp ) if self.rgbw_color_temp_support(self.color_modes): self.set_levels(cold, cold, cold, warm, 0, persist=persist, retry=retry) else: self.set_levels(None, None, None, warm, cold, persist=persist, retry=retry) def setRgb( self, r: int, g: int, b: int, persist: bool = True, brightness: Optional[int] = None, retry: int = DEFAULT_RETRIES, ) -> None: self.set_levels(r, g, b, persist=persist, brightness=brightness, retry=retry) def setRgbw( self, r: Optional[int] = None, g: Optional[int] = None, b: Optional[int] = None, w: Optional[int] = None, persist: bool = True, brightness: Optional[int] = None, w2: Optional[int] = None, retry: int = DEFAULT_RETRIES, ) -> None: self.set_levels(r, g, b, w, w2, persist, brightness, retry=retry) def set_levels( self, r: Optional[int] = None, g: Optional[int] = None, b: Optional[int] = None, w: Optional[int] = None, w2: Optional[int] = None, persist: bool = True, brightness: Optional[int] = None, retry: int = DEFAULT_RETRIES, ) -> None: self._process_levels_change( *self._generate_levels_change( { STATE_RED: r, STATE_GREEN: g, STATE_BLUE: b, STATE_WARM_WHITE: w, STATE_COOL_WHITE: w2, }, persist, brightness, ), retry=retry, ) @_socket_retry(attempts=2) # type: ignore def _process_levels_change( self, msgs: List[bytearray], updates: Dict[str, int] ) -> None: # send the message with self._lock: self._connect_if_disconnected() self._set_transition_complete_time() for msg in msgs: self._send_msg(msg) if updates: self._replace_raw_state(updates) def _send_msg(self, bytes: bytearray) -> None: assert self._socket is not None _LOGGER.debug( "%s => %s (%d)", self.ipaddr, " ".join(f"0x{x:02X}" for x in bytes), len(bytes), ) self._socket.send(bytes) def _read_msg(self, expected: int) -> bytearray: assert self._socket is not None remaining = expected rx = bytearray() begin = time.monotonic() while remaining > 0: timeout_left = self.timeout - (time.monotonic() - begin) if timeout_left <= 0: break try: self._socket.setblocking(False) read_ready, _, _ = select.select([self._socket], [], [], timeout_left) if not read_ready: _LOGGER.debug( "%s: timed out reading %d bytes", self.ipaddr, expected ) break chunk = self._socket.recv(remaining) _LOGGER.debug( "%s <= %s (%d)", self.ipaddr, " ".join(f"0x{x:02X}" for x in chunk), len(chunk), ) if chunk: begin = time.monotonic() remaining -= len(chunk) rx.extend(chunk) except OSError as ex: _LOGGER.debug("%s: socket error: %s", self.ipaddr, ex) pass finally: self._socket.setblocking(True) return rx def getClock(self) -> Optional[datetime.datetime]: assert self._protocol is not None return self._protocol.parse_get_time( self._send_and_read_with_retry( self._protocol.construct_get_time(), LEDENET_TIME_RESPONSE_LEN ) ) def setClock(self) -> None: assert self._protocol is not None self._send_and_read_with_retry( self._protocol.construct_set_time(datetime.datetime.now()), 0 ) # Setting the clock does not always respond so we # cycle the connection self.close() def _determine_protocol(self) -> bytearray: """Determine the type of protocol based of first 2 bytes.""" read_bytes = 2 for protocol_cls in self._protocol_probes(): protocol = protocol_cls() rx = self._send_and_read_with_retry( protocol.construct_state_query(), read_bytes ) # if any response is recieved, use the protocol if rx is None or len(rx) != read_bytes: # We just sent a garage query which the old procotol # cannot process, recycle the connection self.close() continue full_msg = rx + self._read_msg(protocol.state_response_length - read_bytes) if not protocol.is_valid_state_response(full_msg): self.close() continue assert isinstance(full_msg, bytearray) self._set_protocol_from_msg(full_msg, protocol.name) return full_msg raise Exception("Cannot determine protocol") def setPresetPattern( self, pattern: int, speed: int, brightness: int = 100, retry: int = DEFAULT_RETRIES, ) -> None: self._set_transition_complete_time() self._send_and_read_with_retry( self._generate_preset_pattern(pattern, speed, brightness), 0, retry=retry ) def set_effect( self, effect: str, speed: int, brightness: int = 100, retry: int = DEFAULT_RETRIES, ) -> None: """Set an effect.""" if effect == EFFECT_RANDOM: self.set_random() return self.setPresetPattern( self._effect_to_pattern(effect), speed, brightness, retry=retry ) def set_random(self, retry: int = DEFAULT_RETRIES) -> None: """Set levels randomly.""" self._process_levels_change(*self._generate_random_levels_change(), retry=retry) @_socket_retry(attempts=2) # type: ignore def _send_and_read_with_retry( self, msg: bytearray, read_len: int ) -> Optional[bytearray]: with self._lock: self._connect_if_disconnected() self._send_msg(msg) if read_len == 0: return None return self._read_msg(read_len) def getTimers(self) -> List[LedTimer]: assert self._protocol is not None if isinstance(self._protocol, ProtocolLEDENETOriginal): led_timers: List[LedTimer] = [] return led_timers msg = self._protocol.construct_get_timers() return self._protocol.parse_get_timers( self._send_and_read_with_retry(msg, self._protocol.timer_response_len) ) def sendTimers(self, timer_list: List[LedTimer]) -> None: assert self._protocol is not None self._send_and_read_with_retry( self._protocol.construct_set_timers(timer_list), 4 # b'\x94\x00\x00\x00' ) @_socket_retry(attempts=2) # type: ignore def query_state(self, led_type: Optional[str] = None) -> bytearray: if led_type: self.setProtocol(led_type) elif not self._protocol: return self._determine_protocol() assert self._protocol is not None with self._lock: self.connect() self._send_msg(self._protocol.construct_state_query()) return self._read_msg(self._protocol.state_response_length) def update_state(self, retry: int = 2) -> None: rx = self.query_state(retry=retry) if rx and self.process_state_response(rx): self.set_available("successfully processed state response") return self.set_unavailable("failed to process state response") def setCustomPattern( self, rgb_list: List[Tuple[int, int, int]], speed: int, transition_type: str, retry: int = DEFAULT_RETRIES, ) -> None: """Set a custom pattern on the device.""" self._send_and_read_with_retry( self._generate_custom_patterm(rgb_list, speed, transition_type), 0, retry=retry, ) def refreshState(self) -> None: return self.update_state() Danielhiversen-flux_led-bfd1bbe/flux_led/fluxled.py000066400000000000000000000655201447734565100227500ustar00rootroot00000000000000#!/usr/bin/env python """ This is a utility for controlling stand-alone Flux WiFi LED light bulbs. The protocol was reverse-engineered by studying packet captures between a bulb and the controlling "Magic Home" mobile app. The code here dealing with the network protocol is littered with magic numbers, and ain't so pretty. But it does seem to work! So far most of the functionality of the apps is available here via the CLI and/or programmatically. The classes in this project could very easily be used as an API, and incorporated into a GUI app written in PyQt, Kivy, or some other framework. ##### Available: * Discovering bulbs on LAN * Turning on/off bulb * Get state information * Setting "warm white" mode * Setting single color mode * Setting preset pattern mode * Setting custom pattern mode * Reading timers * Setting timers ##### Some missing pieces: * Initial administration to set up WiFi SSID and passphrase/key. * Remote access administration * Music-relating pulsing. This feature isn't so impressive on the Magic Home app, and looks like it might be a bit of work. ##### Cool feature: * Specify colors with names or web hex values. Requires that python "webcolors" package is installed. (Easily done via pip, easy_install, or apt-get, etc.) See the following for valid color names: http://www.w3schools.com/html/html_colornames.asp """ import asyncio import datetime import logging import sys from optparse import OptionGroup, OptionParser, Values from typing import Any, List, Optional, Tuple from .aio import AIOWifiLedBulb from .aioscanner import AIOBulbScanner from .const import ATTR_ID, ATTR_IPADDR from .pattern import PresetPattern from .scanner import FluxLEDDiscovery from .timer import LedTimer from .utils import utils _LOGGER = logging.getLogger(__name__) # ======================================================================= def showUsageExamples() -> None: example_text = """ Examples: Scan network: %prog% -s Scan network and show info about all: %prog% -sSti Turn on: %prog% 192.168.1.100 --on %prog% 192.168.1.100 -192.168.1.101 -1 Turn on all bulbs on LAN: %prog% -sS --on Turn off: %prog% 192.168.1.100 --off %prog% 192.168.1.100 --0 %prog% -sS --off Set warm white, 75% %prog% 192.168.1.100 -w 75 Set cold white, 55% %prog% 192.168.1.100 -d 55 Set CCT, 3500 85% %prog% 192.168.1.100 -k 3500 85 Set fixed color red : %prog% 192.168.1.100 -c Red %prog% 192.168.1.100 -c 255,0,0 %prog% 192.168.1.100 -c "#FF0000" Set RGBW 25 100 200 50: %prog% 192.168.1.100 -c 25,100,200,50 Set RGBWW 25 100 200 50 30: %prog% 192.168.1.100 -c 25,100,200,50,30 Set preset pattern #35 with 40% speed: %prog% 192.168.1.100 -p 35 40 Set custom pattern 25% speed, red/green/blue, gradual change: %prog% 192.168.1.100 -C gradual 25 "red green (0,0,255)" Sync all bulb's clocks with this computer's: %prog% -sS --setclock Set timer #1 to turn on red at 5:30pm on weekdays: %prog% 192.168.1.100 -T 1 color "time:1730;repeat:12345;color:red" Deactivate timer #4: %prog% 192.168.1.100 -T 4 inactive "" Use --timerhelp for more details on setting timers """ print(example_text.replace("%prog%", sys.argv[0])) def showTimerHelp() -> None: timerhelp_text = """ There are 6 timers available for each bulb. Mode Details: inactive: timer is inactive and unused poweroff: turns off the light default: turns on the light in default mode color: turns on the light with specified color preset: turns on the light with specified preset and speed warmwhite: turns on the light with warm white at specified brightness Settings available for each mode: Timer Mode | Settings -------------------------------------------- inactive: [none] poweroff: time, (repeat | date) default: time, (repeat | date) color: time, (repeat | date), color preset: time, (repeat | date), code, speed warmwhite: time, (repeat | date), level sunrise: time, (repeat | date), startBrightness, endBrightness, duration sunset: time, (repeat | date), startBrightness, endBrightness, duration Setting Details: time: 4 digit string with zeros, no colons e.g: "1000" - for 10:00am "2312" - for 11:23pm "0315" - for 3:15am repeat: Days of the week that the timer should repeat (Mutually exclusive with date) 0=Sun, 1=Mon, 2=Tue, 3=Wed, 4=Thu, 5=Fri, 6=Sat e.g: "0123456" - everyday "06" - weekends "12345" - weekdays "2" - only Tuesday date: Date that the one-time timer should fire (Mutually exclusive with repeat) e.g: "2015-09-13" "2016-12-03" color: Color name, hex code, or rgb triple level: Level of the warm while light (0-100) code: Code of the preset pattern (use -l to list them) speed: Speed of the preset pattern transitions (0-100) startBrightness: starting brightness of warmlight (0-100) endBrightness: ending brightness of warmlight (0-100) duration: transition time in minutes Example setting strings: "time:2130;repeat:0123456" "time:2130;date:2015-08-11" "time:1245;repeat:12345;color:123,345,23" "time:1245;repeat:12345;color:green" "time:1245;repeat:06;code:50;speed:30" "time:0345;date:2015-08-11;level:100" """ print(timerhelp_text) def processSetTimerArgs(parser: OptionParser, args: Any) -> LedTimer: # noqa: C901 mode = args[1] num = args[0] settings = args[2] if not num.isdigit() or int(num) > 6 or int(num) < 1: parser.error("Timer number must be between 1 and 6") # create a dict from the settings string settings_list = settings.split(";") settings_dict = {} for s in settings_list: pair = s.split(":") key = pair[0].strip().lower() val = "" if len(pair) > 1: val = pair[1].strip().lower() settings_dict[key] = val keys = list(settings_dict.keys()) timer = LedTimer() if mode == "inactive": # no setting needed timer.setActive(False) elif mode in [ "poweroff", "default", "color", "preset", "warmwhite", "sunrise", "sunset", ]: timer.setActive(True) if "time" not in keys: parser.error(f"This mode needs a time: {mode}") if "repeat" in keys and "date" in keys: parser.error(f"This mode only a repeat or a date, not both: {mode}") # validate time format if len(settings_dict["time"]) != 4 or not settings_dict["time"].isdigit(): parser.error("time must be a 4 digits") hour = int(settings_dict["time"][0:2:]) minute = int(settings_dict["time"][2:4:]) if hour > 23: parser.error("timer hour can't be greater than 23") if minute > 59: parser.error("timer minute can't be greater than 59") timer.setTime(hour, minute) # validate date format if "repeat" not in keys and "date" not in keys: # Generate date for next occurance of time print("No time or repeat given. Defaulting to next occurance of time") now = datetime.datetime.now() dt = now.replace(hour=hour, minute=minute) if utils.date_has_passed(dt): dt = dt + datetime.timedelta(days=1) # settings_dict["date"] = date timer.setDate(dt.year, dt.month, dt.day) elif "date" in keys: try: dt = datetime.datetime.strptime(settings_dict["date"], "%Y-%m-%d") timer.setDate(dt.year, dt.month, dt.day) except ValueError: parser.error("date is not properly formatted: YYYY-MM-DD") # validate repeat format if "repeat" in keys: if len(settings_dict["repeat"]) == 0: parser.error("Must specify days to repeat") days = set() for c in list(settings_dict["repeat"]): if c not in ["0", "1", "2", "3", "4", "5", "6"]: parser.error("repeat can only contain digits 0-6") days.add(int(c)) repeat = 0 if 0 in days: repeat |= LedTimer.Su if 1 in days: repeat |= LedTimer.Mo if 2 in days: repeat |= LedTimer.Tu if 3 in days: repeat |= LedTimer.We if 4 in days: repeat |= LedTimer.Th if 5 in days: repeat |= LedTimer.Fr if 6 in days: repeat |= LedTimer.Sa timer.setRepeatMask(repeat) if mode == "default": timer.setModeDefault() if mode == "poweroff": timer.setModeTurnOff() if mode == "color": if "color" not in keys: parser.error("color mode needs a color setting") # validate color val c = utils.color_object_to_tuple(settings_dict["color"]) # type: ignore if c is None: parser.error("Invalid color value: {}".format(settings_dict["color"])) assert c is not None timer.setModeColor(c[0], c[1], c[2]) # type: ignore if mode == "preset": if "code" not in keys: parser.error(f"preset mode needs a code: {mode}") if "speed" not in keys: parser.error(f"preset mode needs a speed: {mode}") code = settings_dict["code"] speed = settings_dict["speed"] if not speed.isdigit() or int(speed) > 100: parser.error("preset speed must be a percentage (0-100)") if not code.isdigit() or not PresetPattern.valid(int(code)): parser.error("preset code must be in valid range") timer.setModePresetPattern(int(code), int(speed)) if mode == "warmwhite": if "level" not in keys: parser.error(f"warmwhite mode needs a level: {mode}") level = settings_dict["level"] if not level.isdigit() or int(level) > 100: parser.error("warmwhite level must be a percentage (0-100)") timer.setModeWarmWhite(int(level)) if mode == "sunrise" or mode == "sunset": if "startbrightness" not in keys: parser.error(f"{mode} mode needs a startBrightness (0% -> 100%)") startBrightness = int(settings_dict["startbrightness"]) if "endbrightness" not in keys: parser.error(f"{mode} mode needs an endBrightness (0% -> 100%)") endBrightness = int(settings_dict["endbrightness"]) if "duration" not in keys: parser.error(f"{mode} mode needs a duration (minutes)") duration = int(settings_dict["duration"]) if mode == "sunrise": timer.setModeSunrise(startBrightness, endBrightness, duration) elif mode == "sunset": timer.setModeSunset(startBrightness, endBrightness, duration) else: parser.error(f"Not a valid timer mode: {mode}") return timer def processCustomArgs( parser: OptionParser, args: Any ) -> Optional[Tuple[Any, int, List[Tuple[int, ...]]]]: if args[0] not in ["gradual", "jump", "strobe"]: parser.error(f"bad pattern type: {args[0]}") return None speed = int(args[1]) # convert the string to a list of RGB tuples # it should have space separated items of either # color names, hex values, or byte triples try: color_list_str = args[2].strip() str_list = color_list_str.split(" ") color_list = [] for s in str_list: c = utils.color_object_to_tuple(s) if c is not None: color_list.append(c) else: raise Exception except Exception: parser.error( "COLORLIST isn't formatted right. It should be a space separated list of RGB tuples, color names or web hex values" ) return args[0], speed, color_list def parseArgs() -> Tuple[Values, Any]: # noqa: C901 parser = OptionParser() parser.description = "A utility to control Flux WiFi LED Bulbs. " # parser.description += "" # parser.description += "." power_group = OptionGroup(parser, "Power options (mutually exclusive)") mode_group = OptionGroup(parser, "Mode options (mutually exclusive)") info_group = OptionGroup(parser, "Program help and information option") other_group = OptionGroup(parser, "Other options") parser.add_option_group(info_group) info_group.add_option( "--debug", action="store_true", dest="debug", default=False, help="Enable debug logging", ) info_group.add_option( "-e", "--examples", action="store_true", dest="showexamples", default=False, help="Show usage examples", ) info_group.add_option( "", "--timerhelp", action="store_true", dest="timerhelp", default=False, help="Show detailed help for setting timers", ) info_group.add_option( "-l", "--listpresets", action="store_true", dest="listpresets", default=False, help="List preset codes", ) info_group.add_option( "--listcolors", action="store_true", dest="listcolors", default=False, help="List color names", ) parser.add_option( "-s", "--scan", action="store_true", dest="scan", default=False, help="Search for bulbs on local network", ) parser.add_option( "-S", "--scanresults", action="store_true", dest="scanresults", default=False, help="Operate on scan results instead of arg list", ) power_group.add_option( "-1", "--on", action="store_true", dest="on", default=False, help="Turn on specified bulb(s)", ) power_group.add_option( "-0", "--off", action="store_true", dest="off", default=False, help="Turn off specified bulb(s)", ) parser.add_option_group(power_group) mode_group.add_option( "-c", "--color", dest="color", default=None, help="""For setting a single color mode. Can be either color name, web hex, or comma-separated RGB triple. For setting an RGBW can be a comma-seperated RGBW list For setting an RGBWW can be a comma-seperated RGBWW list""", metavar="COLOR", ) mode_group.add_option( "-w", "--warmwhite", dest="ww", default=None, help="Set warm white mode (LEVELWW is percent)", metavar="LEVELWW", type="int", ) mode_group.add_option( "-d", "--coldwhite", dest="cw", default=None, help="Set cold white mode (LEVELCW is percent)", metavar="LEVELCW", type="int", ) mode_group.add_option( "-k", "--CCT", dest="cct", default=None, help="Temperture and brightness (CCT Kelvin, brightness percent)", metavar="LEVELCCT", type="int", nargs=2, ) mode_group.add_option( "-p", "--preset", dest="preset", default=None, help="Set preset pattern mode (SPEED is percent)", metavar="CODE SPEED", type="int", nargs=2, ) mode_group.add_option( "-C", "--custom", dest="custom", metavar="TYPE SPEED COLORLIST", default=None, nargs=3, help="Set custom pattern mode. " + "TYPE should be jump, gradual, or strobe. SPEED is percent. " + "COLORLIST is a space-separated list of color names, web hex values, or comma-separated RGB triples", ) parser.add_option_group(mode_group) parser.add_option( "-i", "--info", action="store_true", dest="info", default=False, help="Info about bulb(s) state", ) parser.add_option( "", "--getclock", action="store_true", dest="getclock", default=False, help="Get clock", ) parser.add_option( "", "--setclock", action="store_true", dest="setclock", default=False, help="Set clock to same as current time on this computer", ) parser.add_option( "-t", "--timers", action="store_true", dest="showtimers", default=False, help="Show timers", ) parser.add_option( "-T", "--settimer", dest="settimer", metavar="NUM MODE SETTINGS", default=None, nargs=3, help="Set timer. " + "NUM: number of the timer (1-6). " + "MODE: inactive, poweroff, default, color, preset, or warmwhite. " + "SETTINGS: a string of settings including time, repeatdays or date, " + "and other mode specific settings. Use --timerhelp for more details.", ) parser.add_option( "--protocol", dest="protocol", default=None, metavar="PROTOCOL", help="Set the device protocol. Currently only supports LEDENET", ) other_group.add_option( "-v", "--volatile", action="store_true", dest="volatile", default=False, help="Don't persist mode setting with hard power cycle (RGB and WW modes only).", ) parser.add_option_group(other_group) parser.usage = "usage: %prog [-sS10cwdkpCiltThe] [addr1 [addr2 [addr3] ...]." (options, args) = parser.parse_args() if options.debug: logging.basicConfig(level=logging.DEBUG) if options.showexamples: showUsageExamples() sys.exit(0) if options.timerhelp: showTimerHelp() sys.exit(0) if options.listpresets: for c in range( PresetPattern.seven_color_cross_fade, PresetPattern.seven_color_jumping + 1 ): print(f"{c:2} {PresetPattern.valtostr(c)}") sys.exit(0) if options.listcolors: for c in utils.get_color_names_list(): # type: ignore print(f"{c}, ") print("") sys.exit(0) if options.settimer: new_timer = processSetTimerArgs(parser, options.settimer) options.new_timer = new_timer else: options.new_timer = None mode_count = 0 if options.color: mode_count += 1 if options.ww: mode_count += 1 if options.cw: mode_count += 1 if options.cct: mode_count += 1 if options.preset: mode_count += 1 if options.custom: mode_count += 1 if mode_count > 1: parser.error( "options --color, --*white, --preset, --CCT, and --custom are mutually exclusive" ) if options.on and options.off: parser.error("options --on and --off are mutually exclusive") if options.custom: options.custom = processCustomArgs(parser, options.custom) if options.color: options.color = utils.color_object_to_tuple(options.color) if options.color is None: parser.error("bad color specification") if options.preset: if not PresetPattern.valid(options.preset[0]): parser.error("Preset code is not in range") # asking for timer info, implicitly gets the state if options.showtimers: options.info = True op_count = mode_count if options.on: op_count += 1 if options.off: op_count += 1 if options.info: op_count += 1 if options.getclock: op_count += 1 if options.setclock: op_count += 1 if options.listpresets: op_count += 1 if options.settimer: op_count += 1 if (not options.scan or options.scanresults) and (op_count == 0): parser.error("An operation must be specified") # if we're not scanning, IP addresses must be specified as positional args if not options.scan and not options.scanresults and not options.listpresets: if len(args) == 0: parser.error( "You must specify at least one IP address as an argument, or use scan results" ) return (options, args) # ------------------------------------------- async def _async_run_commands( # noqa: C901 bulb: AIOWifiLedBulb, info: FluxLEDDiscovery, options: Any ) -> None: """Run requested commands on a bulb.""" buffer = "" def buf_in(str: str) -> None: nonlocal buffer buffer += str + "\n" if options.getclock: buf_in( "{} [{}] {}".format(info["id"], info["ipaddr"], await bulb.async_get_time()) ) if options.setclock: await bulb.async_set_time() if options.ww is not None: if options.ww > 100: raise ValueError("Input can not be higher than 100%") else: buf_in(f"Setting warm white mode, level: {options.ww}%") await bulb.async_set_levels( w=utils.percentToByte(options.ww), persist=not options.volatile ) if options.cw is not None: if options.cw > 100: raise ValueError("Input can not be higher than 100%") else: buf_in(f"Setting cold white mode, level: {options.cw}%") await bulb.async_set_levels( w2=utils.percentToByte(options.cw), persist=not options.volatile ) if options.cct is not None: if options.cct[1] > 100: raise ValueError("Brightness can not be higher than 100%") elif options.cct[0] < 2700 or options.cct[0] > 6500: buf_in("Color Temp must be between 2700 and 6500") else: buf_in( "Setting LED temperature {}K and brightness: {}%".format( options.cct[0], options.cct[1] ) ) await bulb.async_set_white_temp( options.cct[0], options.cct[1] * 2.55, persist=not options.volatile ) if options.color is not None: buf_in( f"Setting color RGB:{options.color}", ) name = utils.color_tuple_to_string(options.color) if name is None: buf_in("") else: buf_in(f"[{name}]") if any(i < 0 or i > 255 for i in options.color): raise ValueError("Invalid value received must be between 0-255") elif len(options.color) == 3: await bulb.async_set_levels( options.color[0], options.color[1], options.color[2], persist=not options.volatile, ) elif len(options.color) == 4: await bulb.async_set_levels( options.color[0], options.color[1], options.color[2], options.color[3], persist=not options.volatile, ) elif len(options.color) == 5: await bulb.async_set_levels( options.color[0], options.color[1], options.color[2], options.color[3], options.color[4], persist=not options.volatile, ) elif options.custom is not None: await bulb.async_set_custom_pattern( options.custom[2], options.custom[1], options.custom[0] ) buf_in( "Setting custom pattern: {}, Speed={}%, {}".format( options.custom[0], options.custom[1], options.custom[2] ) ) elif options.preset is not None: buf_in( "Setting preset pattern: {}, Speed={}%".format( PresetPattern.valtostr(options.preset[0]), options.preset[1] ) ) await bulb.async_set_preset_pattern(options.preset[0], options.preset[1]) if options.on: buf_in(f"Turning on bulb at {bulb.ipaddr}") await bulb.async_turn_on() elif options.off: buf_in(f"Turning off bulb at {bulb.ipaddr}") await bulb.async_turn_off() if options.info: buf_in("{} [{}] {} ({})".format(info["id"], info["ipaddr"], bulb, bulb.model)) if options.settimer: empty_timers: List[LedTimer] = [] timers = await bulb.async_get_timers() or empty_timers num = int(options.settimer[0]) buf_in(f"New Timer ---- #{num}: {options.new_timer}") if options.new_timer.isExpired(): buf_in("[timer is already expired, will be deactivated]") timers[num - 1] = options.new_timer await bulb.async_set_timers(timers) if options.showtimers: show_timers = await bulb.async_get_timers() if show_timers: for idx, t in enumerate(show_timers): buf_in(f" Timer #{idx + 1}: {t}") buf_in("") print(buffer.rstrip("\n")) async def _async_process_bulb( # noqa: C901 info: FluxLEDDiscovery, options: Any ) -> None: """Process a bulb.""" bulb = AIOWifiLedBulb(info["ipaddr"], discovery=info) await bulb.async_setup(lambda *args: None) try: await _async_run_commands(bulb, info, options) finally: await bulb.async_stop() async def async_main() -> None: # noqa: C901 (options, args) = parseArgs() scanner = AIOBulbScanner() if options.scan: await scanner.async_scan(timeout=6) bulb_info_list = scanner.getBulbInfo() # we have a list of buld info dicts addrs = [] if options.scanresults and len(bulb_info_list) > 0: for b in bulb_info_list: addrs.append(b["ipaddr"]) else: print(f"{len(bulb_info_list)} bulbs found") for b in bulb_info_list: print(" {} {}".format(b["id"], b["ipaddr"])) return else: if options.info: for addr in args: await scanner.async_scan(timeout=6, address=addr) bulb_info_list = scanner.getBulbInfo() else: bulb_info_list = [] found_addrs = {discovery[ATTR_IPADDR] for discovery in bulb_info_list} for addr in args: if addr in found_addrs: continue bulb_info_list.append(FluxLEDDiscovery({ATTR_IPADDR: addr, ATTR_ID: "Unknown ID"})) # type: ignore # now we have our bulb list, perform same operation on all of them tasks = [_async_process_bulb(info, options) for info in bulb_info_list] results = await asyncio.gather( *tasks, return_exceptions=True, ) for idx, info in enumerate(bulb_info_list): if isinstance(results[idx], Exception): msg = str(results[idx]) or type(results[idx]) print(f"Error while processing {info}: {msg}") return def main() -> None: asyncio.run(async_main()) if __name__ == "__main__": main() sys.exit(0) Danielhiversen-flux_led-bfd1bbe/flux_led/models_db.py000077500000000000000000001400241447734565100232310ustar00rootroot00000000000000"""FluxLED Models Database.""" from dataclasses import dataclass from enum import Enum, auto from typing import Dict, List, Optional, Set from .const import ( COLOR_MODE_CCT, COLOR_MODE_DIM, COLOR_MODE_RGB, COLOR_MODE_RGBW, COLOR_MODE_RGBWW, COLOR_MODES_ADDRESSABLE, COLOR_MODES_RGB_CCT, COLOR_MODES_RGB_W, STATE_COOL_WHITE, STATE_GREEN, STATE_RED, STATE_WARM_WHITE, ) from .protocol import ( A1_NUM_TO_OPERATING_MODE, A1_NUM_TO_PROTOCOL, A1_OPERATING_MODE_TO_NUM, A1_PROTOCOL_TO_NUM, A2_NUM_TO_OPERATING_MODE, A2_NUM_TO_PROTOCOL, A2_OPERATING_MODE_TO_NUM, A2_PROTOCOL_TO_NUM, ADDRESSABLE_RGB_NUM_TO_WIRING, ADDRESSABLE_RGB_WIRING_TO_NUM, NEW_ADDRESSABLE_NUM_TO_OPERATING_MODE, NEW_ADDRESSABLE_NUM_TO_PROTOCOL, NEW_ADDRESSABLE_OPERATING_MODE_TO_NUM, NEW_ADDRESSABLE_PROTOCOL_TO_NUM, PROTOCOL_LEDENET_8BYTE, PROTOCOL_LEDENET_8BYTE_AUTO_ON, PROTOCOL_LEDENET_8BYTE_DIMMABLE_EFFECTS, PROTOCOL_LEDENET_9BYTE, PROTOCOL_LEDENET_9BYTE_AUTO_ON, PROTOCOL_LEDENET_9BYTE_DIMMABLE_EFFECTS, PROTOCOL_LEDENET_ADDRESSABLE_A1, PROTOCOL_LEDENET_ADDRESSABLE_A2, PROTOCOL_LEDENET_ADDRESSABLE_A3, PROTOCOL_LEDENET_ADDRESSABLE_CHRISTMAS, PROTOCOL_LEDENET_CCT, PROTOCOL_LEDENET_CCT_WRAPPED, PROTOCOL_LEDENET_ORIGINAL, PROTOCOL_LEDENET_ORIGINAL_CCT, PROTOCOL_LEDENET_SOCKET, RGB_NUM_TO_WIRING, RGB_WIRING_TO_NUM, RGBW_MODE_TO_NUM, RGBW_NUM_TO_MODE, RGBW_NUM_TO_WIRING, RGBW_WIRING_TO_NUM, RGBWW_MODE_TO_NUM, RGBWW_NUM_TO_MODE, RGBWW_NUM_TO_WIRING, RGBWW_WIRING_TO_NUM, ) # BL likely means BL602 chips MODEL_INFO_NAMES = { "ZG-LX-FL": "", # Seen on 24w Flood light "ZG-BL": "", # unknown "CL-BL": "", # Send on the 0x1C table lamp "ZG-BL-IR": "IR", "IR": "IR", "IR_MINI": "IR Mini", "ZG-BL-EH7W": "7w", "ZG-BL-IH9WL": "9w RF", "ZG-BL-IH9W": "9w RF", "ZG-BL-CB1": "Ceiling", "ZG-LX-EJ9W": "9w", # This might be a ceiling light "RF": "RF", "LWS-BL": "Ceiling", "LWS-LX-IR": "Ceiling IR", "ZG-BL-611HZ": "", # unknown "ZG-BL-5V": "5v", "ZG-LX": "", # Seen on floor lamp, v2 addressable, and Single channel controller "ZG-LX-UART": "", # Seen on UK xmas lights 0x33, fairy controller, and lytworx "ZG-BL-PWM": "", # Seen on 40w Flood Light "ZG-ZW2": "", # seen on 0x97 socket "ZGIR44": "44 Key IR", "IR_ZG": "IR", } @dataclass class MinVersionProtocol: min_version: int protocol: str class LEDENETChip(Enum): ESP8266 = auto() # aka ESP8285 BL602 = auto() # supports BLE as well S82GESNC = auto() HFLPB100 = auto() @dataclass class LEDENETHardware: model: str # The model string chip: LEDENETChip remote_rf: bool # legacy rf remote remote_24g: bool # 2.4ghz remote remote_24g_controls: bool # 2.4ghz remote controls (pair/unpair remotes) auto_on: bool # turns on when adjusting levels/setting effects dimmable_effects: bool # supports dimming effects BASE_MODE_MAP = { 0x01: {COLOR_MODE_DIM}, # AKA DIM 0x02: {COLOR_MODE_CCT}, # AKA CCT 0x03: {COLOR_MODE_RGB}, # AKA RGB 0x04: {COLOR_MODE_RGBW}, # AKA RGB&W 0x05: {COLOR_MODE_RGBWW}, # AKA RGB&CCT 0x06: COLOR_MODES_RGB_W, # AKA RGB/W 0x07: COLOR_MODES_RGB_CCT, # AKA RGB/CCT } MULTI_MODE_NUM_TO_MODE = { 0: COLOR_MODE_RGBWW, # Factory default, no mode set AKA RGBWW 1: COLOR_MODE_DIM, 2: COLOR_MODE_CCT, 3: COLOR_MODE_RGB, 4: COLOR_MODE_RGBW, 5: COLOR_MODE_RGBWW, } MULTI_MODE_MODE_TO_NUM = {v: k for k, v in MULTI_MODE_NUM_TO_MODE.items() if k != 0} GENERIC_RGB_MAP = { 0x13: {COLOR_MODE_RGB}, # RGB (RGB) verified on model 0x33 0x23: {COLOR_MODE_RGB}, # RGB (GRB) verified on model 0x33 0x33: {COLOR_MODE_RGB}, # RGB (BRG) verified on model 0x33 } GENERIC_RGBW_MAP = { 0x14: {COLOR_MODE_RGBW}, # RGB&W (RGBW) verified on model 0x06 0x24: {COLOR_MODE_RGBW}, # RGB&W (GRBW) verified on model 0x06 0x34: {COLOR_MODE_RGBW}, # RGB&W (BRGW) verified on model 0x06 0x16: COLOR_MODES_RGB_W, # RGB/W (RGBW) verified on model 0x06 0x26: COLOR_MODES_RGB_W, # RGB/W (GRBW) verified on model 0x06 0x36: COLOR_MODES_RGB_W, # RGB/W (BRGW) verified on model 0x06 } GENERIC_RGBWW_MAP = { 0x17: COLOR_MODES_RGB_CCT, # RGB/CCT (RGBCW) verified on model 0x07 0x27: COLOR_MODES_RGB_CCT, # RGB/CCT (GRBCW) verified on model 0x07 0x37: COLOR_MODES_RGB_CCT, # RGB/CCT (BRGCW) verified on model 0x07 0x47: COLOR_MODES_RGB_CCT, # RGB/CCT (RGBWC) verified on model 0x07 0x57: COLOR_MODES_RGB_CCT, # RGB/CCT (GRBWC) verified on model 0x07 0x67: COLOR_MODES_RGB_CCT, # RGB/CCT (BRGWC) verified on model 0x07 0x77: COLOR_MODES_RGB_CCT, # RGB/CCT (WRGBC) verified on model 0x07 0x87: COLOR_MODES_RGB_CCT, # RGB/CCT (WGRBC) verified on model 0x07 0x97: COLOR_MODES_RGB_CCT, # RGB/CCT (WBRGC) verified on model 0x07 0xA7: COLOR_MODES_RGB_CCT, # RGB/CCT (CRGBW) verified on model 0x07 0xB7: COLOR_MODES_RGB_CCT, # RGB/CCT (CBRBW) verified on model 0x07 0xC7: COLOR_MODES_RGB_CCT, # RGB/CCT (CBRGW) verified on model 0x07 0xD7: COLOR_MODES_RGB_CCT, # RGB/CCT (WCRGB) verified on model 0x07 0xE7: COLOR_MODES_RGB_CCT, # RGB/CCT (WCGRB) verified on model 0x07 0xF7: COLOR_MODES_RGB_CCT, # RGB/CCT (WCBRG) verified on model 0x07 0x15: {COLOR_MODE_RGBWW}, # RGB&CCT (RGBCW) verified on model 0x07 0x25: {COLOR_MODE_RGBWW}, # RGB&CCT (GRBCW) verified on model 0x07 0x35: {COLOR_MODE_RGBWW}, # RGB&CCT (BRGCW) verified on model 0x07 0x45: {COLOR_MODE_RGBWW}, # RGB&CCT (RGBWC) verified on model 0x07 0x55: {COLOR_MODE_RGBWW}, # RGB&CCT (GRBWC) verified on model 0x07 0x65: {COLOR_MODE_RGBWW}, # RGB&CCT (BRGWC) verified on model 0x07 0x75: {COLOR_MODE_RGBWW}, # RGB&CCT (WRGBC) verified on model 0x07 0x85: {COLOR_MODE_RGBWW}, # RGB&CCT (WGRBC) verified on model 0x07 0x95: {COLOR_MODE_RGBWW}, # RGB&CCT (WBRGC) verified on model 0x07 0xA5: {COLOR_MODE_RGBWW}, # RGB&CCT (CRGBW) verified on model 0x07 0xB5: {COLOR_MODE_RGBWW}, # RGB&CCT (CBRBW) verified on model 0x07 0xC5: {COLOR_MODE_RGBWW}, # RGB&CCT (CBRGW) verified on model 0x07 0xD5: {COLOR_MODE_RGBWW}, # RGB&CCT (WCRGB) verified on model 0x07 0xE5: {COLOR_MODE_RGBWW}, # RGB&CCT (WCGRB) verified on model 0x07 0xF5: {COLOR_MODE_RGBWW}, # RGB&CCT (WCBRG) verified on model 0x07 } @dataclass class LEDENETDeviceConfigurationOptions: wiring: bool # supports changing strip order num_to_wiring: Dict[int, str] wiring_to_num: Dict[str, int] operating_modes: bool # has color modes ie RGB&W or RGB/W num_to_operating_mode: Dict[int, str] operating_mode_to_num: Dict[str, int] pixels: bool segments: bool music_pixels: bool music_segments: bool ic_type: bool num_to_ic_type: Dict[int, str] ic_type_to_num: Dict[str, int] IMMUTABLE_DEVICE_CONFIG = LEDENETDeviceConfigurationOptions( # aka fixed models wiring=False, num_to_wiring={}, wiring_to_num={}, operating_modes=False, num_to_operating_mode={}, operating_mode_to_num={}, pixels=False, segments=False, music_pixels=False, music_segments=False, ic_type=False, num_to_ic_type={}, ic_type_to_num={}, ) MULTI_MODE_DEVICE_CONFIG = LEDENETDeviceConfigurationOptions( # aka 0x25 wiring=False, num_to_wiring={}, wiring_to_num={}, operating_modes=True, num_to_operating_mode=MULTI_MODE_NUM_TO_MODE, operating_mode_to_num=MULTI_MODE_MODE_TO_NUM, pixels=False, segments=False, music_pixels=False, music_segments=False, ic_type=False, num_to_ic_type={}, ic_type_to_num={}, ) RGB_DEVICE_CONFIG = LEDENETDeviceConfigurationOptions( wiring=True, num_to_wiring=RGB_NUM_TO_WIRING, wiring_to_num=RGB_WIRING_TO_NUM, operating_modes=False, num_to_operating_mode={}, operating_mode_to_num={}, pixels=False, segments=False, music_pixels=False, music_segments=False, ic_type=False, num_to_ic_type={}, ic_type_to_num={}, ) RGBW_DEVICE_CONFIG = LEDENETDeviceConfigurationOptions( wiring=True, num_to_wiring=RGBW_NUM_TO_WIRING, wiring_to_num=RGBW_WIRING_TO_NUM, operating_modes=True, num_to_operating_mode=RGBW_NUM_TO_MODE, operating_mode_to_num=RGBW_MODE_TO_NUM, pixels=False, segments=False, music_pixels=False, music_segments=False, ic_type=False, num_to_ic_type={}, ic_type_to_num={}, ) RGBWW_DEVICE_CONFIG = LEDENETDeviceConfigurationOptions( wiring=True, num_to_wiring=RGBWW_NUM_TO_WIRING, wiring_to_num=RGBWW_WIRING_TO_NUM, operating_modes=True, num_to_operating_mode=RGBWW_NUM_TO_MODE, operating_mode_to_num=RGBWW_MODE_TO_NUM, pixels=False, segments=False, music_pixels=False, music_segments=False, ic_type=False, num_to_ic_type={}, ic_type_to_num={}, ) A1_DEVICE_CONFIG = LEDENETDeviceConfigurationOptions( wiring=True, num_to_wiring=ADDRESSABLE_RGB_NUM_TO_WIRING, wiring_to_num=ADDRESSABLE_RGB_WIRING_TO_NUM, operating_modes=False, num_to_operating_mode=A1_NUM_TO_OPERATING_MODE, operating_mode_to_num=A1_OPERATING_MODE_TO_NUM, pixels=True, segments=False, music_pixels=False, music_segments=False, ic_type=True, num_to_ic_type=A1_NUM_TO_PROTOCOL, ic_type_to_num=A1_PROTOCOL_TO_NUM, ) A2_DEVICE_CONFIG = LEDENETDeviceConfigurationOptions( wiring=True, num_to_wiring=ADDRESSABLE_RGB_NUM_TO_WIRING, wiring_to_num=ADDRESSABLE_RGB_WIRING_TO_NUM, operating_modes=False, num_to_operating_mode=A2_NUM_TO_OPERATING_MODE, operating_mode_to_num=A2_OPERATING_MODE_TO_NUM, pixels=True, segments=True, music_pixels=True, music_segments=True, ic_type=True, num_to_ic_type=A2_NUM_TO_PROTOCOL, ic_type_to_num=A2_PROTOCOL_TO_NUM, ) NEW_ADDRESABLE_DEVICE_CONFIG = LEDENETDeviceConfigurationOptions( wiring=True, num_to_wiring=ADDRESSABLE_RGB_NUM_TO_WIRING, wiring_to_num=ADDRESSABLE_RGB_WIRING_TO_NUM, operating_modes=False, # can only be changed by changing protocol num_to_operating_mode=NEW_ADDRESSABLE_NUM_TO_OPERATING_MODE, operating_mode_to_num=NEW_ADDRESSABLE_OPERATING_MODE_TO_NUM, pixels=True, segments=True, music_pixels=True, music_segments=True, ic_type=True, num_to_ic_type=NEW_ADDRESSABLE_NUM_TO_PROTOCOL, ic_type_to_num=NEW_ADDRESSABLE_PROTOCOL_TO_NUM, ) @dataclass class LEDENETModel: model_num: int # The model number aka byte 1 models: List[str] # The model names from discovery description: str # Description of the model ({type} {color_mode}) always_writes_white_and_colors: bool # Devices that don't require a separate rgb/w bit aka rgbwprotocol protocols: List[ MinVersionProtocol ] # The device protocols, must be ordered highest version to lowest version mode_to_color_mode: Dict[ int, Set[str] ] # A mapping of mode aka byte 2 to color mode that overrides color_modes color_modes: Set[ str ] # The color modes to use if there is no mode_to_color_mode_mapping channel_map: Dict[str, str] # Used to remap channels microphone: bool device_config: LEDENETDeviceConfigurationOptions def protocol_for_version_num(self, version_num: int) -> str: protocol = self.protocols[-1].protocol for min_version_protocol in self.protocols: if version_num >= min_version_protocol.min_version: protocol = min_version_protocol.protocol break return protocol UNKNOWN_MODEL = "Unknown Model" # Assumed model version scheme # # Example AK001-ZJ2149 # # 0 1 2 3 4 5 # Z J 2 1 4 9 # | | | | | | # | | | | | | # | | | | | Minor Version # | | | | Major Version # | | | Chip # | | Generation # | Unknown # Zengge # HARDWARE = [ LEDENETHardware( model="AK001-ZJ100", chip=LEDENETChip.ESP8266, remote_rf=False, # verified remote_24g=False, remote_24g_controls=False, auto_on=False, dimmable_effects=False, ), LEDENETHardware( model="AK001-ZJ200", chip=LEDENETChip.ESP8266, remote_rf=False, # verified remote_24g=False, remote_24g_controls=False, auto_on=False, dimmable_effects=False, ), LEDENETHardware( model="AK001-ZJ210", chip=LEDENETChip.ESP8266, # verified remote_rf=False, # verified remote_24g=False, remote_24g_controls=False, auto_on=True, dimmable_effects=False, ), LEDENETHardware( model="AK001-ZJ2101", chip=LEDENETChip.ESP8266, remote_rf=False, remote_24g=False, remote_24g_controls=False, auto_on=True, dimmable_effects=False, ), LEDENETHardware( model="AK001-ZJ2104", chip=LEDENETChip.ESP8266, remote_rf=False, # verified remote_24g=False, remote_24g_controls=False, auto_on=True, dimmable_effects=False, ), LEDENETHardware( model="AK001-ZJ2131", # seen on 0x33 larger mini ones chip=LEDENETChip.S82GESNC, # couldn't get the device appart remote_rf=False, remote_24g=False, remote_24g_controls=False, auto_on=True, dimmable_effects=False, ), LEDENETHardware( model="AK001-ZJ2134", # seen in smart plugs only? chip=LEDENETChip.S82GESNC, # couldn't get the device appart remote_rf=False, remote_24g=False, remote_24g_controls=False, auto_on=True, dimmable_effects=False, ), LEDENETHardware( model="AK001-ZJ2145", chip=LEDENETChip.BL602, remote_rf=False, # verified remote_24g=False, remote_24g_controls=False, auto_on=True, dimmable_effects=True, ), LEDENETHardware( model="AK001-ZJ2146", # seen in smart plugs & Controller RGBCW, but RF remote isn't supported on plugs chip=LEDENETChip.BL602, # verified remote_rf=False, # verified remote_24g=True, remote_24g_controls=False, auto_on=True, dimmable_effects=True, ), LEDENETHardware( model="AK001-ZJ2147", # seen on Controller RGBW chip=LEDENETChip.BL602, remote_rf=False, # verified remote_24g=True, remote_24g_controls=False, auto_on=True, dimmable_effects=True, ), LEDENETHardware( model="AK001-ZJ2148", # seen on older Addressable v3 chip=LEDENETChip.BL602, remote_rf=True, # verified remote_24g=True, remote_24g_controls=True, auto_on=True, dimmable_effects=True, ), LEDENETHardware( model="AK001-ZJ2149", # seen on newer Addressable v3 chip=LEDENETChip.BL602, remote_rf=True, # verified remote_24g=True, remote_24g_controls=True, auto_on=True, dimmable_effects=True, ), LEDENETHardware( model="AK001-ZJ21410", # seen on newer 0x1E Ceiling lights chip=LEDENETChip.BL602, remote_rf=True, # verified remote_24g=True, remote_24g_controls=True, auto_on=True, dimmable_effects=True, ), LEDENETHardware( model="AK001-ZJ21411", # seen on newer floor lamps chip=LEDENETChip.BL602, remote_rf=True, # verified remote_24g=True, remote_24g_controls=True, auto_on=True, dimmable_effects=True, # Also has the ability to change the power restored # state to on/off/memory but this is not supported # by the library yet ), LEDENETHardware( model="AK001-ZJ21412", # seen on newer floor lamps chip=LEDENETChip.BL602, remote_rf=True, # verified remote_24g=True, remote_24g_controls=True, auto_on=True, dimmable_effects=True, # Also has the ability to change the power restored # state to on/off/memory but this is not supported # by the library yet ), LEDENETHardware( model="HF-A11", # reported older large box controllers (may be original proto) chip=LEDENETChip.HFLPB100, remote_rf=False, # unverified remote_24g=False, remote_24g_controls=False, auto_on=False, dimmable_effects=False, ), LEDENETHardware( model="HF-A11-ZJ002", # reported older large box controllers (may be original proto) chip=LEDENETChip.HFLPB100, remote_rf=False, # unverified remote_24g=False, remote_24g_controls=False, auto_on=False, dimmable_effects=False, ), LEDENETHardware( model="HF-A11-ZJ004", # reported older large box controllers (may be original proto) chip=LEDENETChip.HFLPB100, remote_rf=False, # unverified remote_24g=False, remote_24g_controls=False, auto_on=False, dimmable_effects=False, ), LEDENETHardware( model="HF-A11-ZJ2", # reported older large box controllers (may be original proto) chip=LEDENETChip.HFLPB100, remote_rf=False, # unverified remote_24g=False, remote_24g_controls=False, auto_on=False, dimmable_effects=False, ), LEDENETHardware( model="HF-A11-ZJ201", # reported older large box controllers (may be original proto) chip=LEDENETChip.HFLPB100, remote_rf=False, # unverified remote_24g=False, remote_24g_controls=False, auto_on=False, dimmable_effects=False, ), LEDENETHardware( model="HF-LPB100-ZJ2001", # reported older large box controllers (may be original proto) chip=LEDENETChip.HFLPB100, remote_rf=False, # unverified remote_24g=False, remote_24g_controls=False, auto_on=False, dimmable_effects=False, ), LEDENETHardware( model="HF-LPB100", # reported on older UFO chip=LEDENETChip.HFLPB100, remote_rf=False, # unverified remote_24g=False, remote_24g_controls=False, auto_on=False, dimmable_effects=False, ), LEDENETHardware( model="HF-LPB100-", chip=LEDENETChip.HFLPB100, remote_rf=False, # unverified remote_24g=False, remote_24g_controls=False, auto_on=False, dimmable_effects=False, ), LEDENETHardware( model="HF-LPB100-0", # reported on older UFO chip=LEDENETChip.HFLPB100, remote_rf=False, # unverified remote_24g=False, remote_24g_controls=False, auto_on=False, dimmable_effects=False, ), LEDENETHardware( model="HF-LPB100-1", # reported on older UFO chip=LEDENETChip.HFLPB100, remote_rf=False, # unverified remote_24g=False, remote_24g_controls=False, auto_on=False, dimmable_effects=False, ), LEDENETHardware( model="HF-LPB100-ZJ001", chip=LEDENETChip.HFLPB100, remote_rf=False, # unverified remote_24g=False, remote_24g_controls=False, auto_on=False, dimmable_effects=False, ), LEDENETHardware( model="HF-LPB100-ZJ011", chip=LEDENETChip.HFLPB100, remote_rf=False, # unverified remote_24g=False, remote_24g_controls=False, auto_on=False, dimmable_effects=False, ), LEDENETHardware( model="HF-LPB100-ZJ002", # seen on older UFO chip=LEDENETChip.HFLPB100, remote_rf=False, # unverified remote_24g=False, remote_24g_controls=False, auto_on=False, dimmable_effects=False, ), LEDENETHardware( model="HF-LPB100-ZJ200", # seen on RGBW Downlight chip=LEDENETChip.HFLPB100, remote_rf=False, # unverified remote_24g=False, remote_24g_controls=False, auto_on=False, dimmable_effects=False, ), ] HARDWARE_MAP: Dict[str, LEDENETHardware] = {model.model: model for model in HARDWARE} MODELS = [ LEDENETModel( model_num=0x01, models=["HF-A11-ZJ002"], description="Legacy Controller RGB", always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_ORIGINAL)], mode_to_color_mode={}, color_modes={COLOR_MODE_RGB}, channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x02, models=[], description="Legacy Controller Dimmable", always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_ORIGINAL)], mode_to_color_mode={}, # Only mode should be 0x02 color_modes={COLOR_MODE_DIM}, # Formerly rgbwcapable channel_map={STATE_WARM_WHITE: STATE_RED, STATE_RED: STATE_WARM_WHITE}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x03, models=["HF-A11-ZJ002"], description="Legacy Controller CCT", always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_ORIGINAL_CCT)], mode_to_color_mode={}, color_modes={COLOR_MODE_CCT}, # Formerly rgbwcapable channel_map={ STATE_WARM_WHITE: STATE_RED, STATE_RED: STATE_WARM_WHITE, STATE_COOL_WHITE: STATE_GREEN, STATE_GREEN: STATE_COOL_WHITE, }, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x04, # AK001-ZJ100 == v3.09, v3.15 (Illume) # AK001-ZJ200 == v4.17 (Mini), v5.20 # AK001-ZJ2104 == v6.20 (Dals) # There are a limited set of these devices that are the mini version but most are UFOs models=[ "HF-LPB100", "HF-LPB100-0", "HF-LPB100-1", "HF-LPB100-ZJ002", "AK001-ZJ100", "AK001-ZJ200", "AK001-ZJ2104", ], description="Controller RGB&W", # AKA ZJ-WFUF-170F always_writes_white_and_colors=True, # Formerly rgbwprotocol protocols=[ MinVersionProtocol(6, PROTOCOL_LEDENET_8BYTE_AUTO_ON), MinVersionProtocol(0, PROTOCOL_LEDENET_8BYTE), ], mode_to_color_mode={}, color_modes={COLOR_MODE_RGBW}, # Formerly rgbwcapable channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x06, # "AK001-ZJ2134" == v1.02 # "AK001-ZJ2104" == v1.11 has RF remote support # "AK001-ZJ2145" == v2.03, v2.06, v2.09 has IR remote support but not always pinned out # "AK001-ZJ2147" == v3.31 has 2.4ghz remote support models=[ "AK001-ZJ100", "AK001-ZJ200", "AK001-ZJ2134", "AK001-ZJ2104", "AK001-ZJ2145", "AK001-ZJ2147", ], description="Controller RGBW", always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[ MinVersionProtocol(2, PROTOCOL_LEDENET_8BYTE_DIMMABLE_EFFECTS), MinVersionProtocol(1, PROTOCOL_LEDENET_8BYTE_AUTO_ON), MinVersionProtocol(0, PROTOCOL_LEDENET_8BYTE), ], mode_to_color_mode=GENERIC_RGBW_MAP, color_modes={COLOR_MODE_RGBW}, # Formerly rgbwcapable channel_map={}, microphone=False, device_config=RGBW_DEVICE_CONFIG, ), LEDENETModel( model_num=0x07, # "AK001-ZJ2101" == v1.06 # "AK001-ZJ2146" == v2.06 has RF remote support models=["AK001-ZJ2101", "AK001-ZJ2146"], description="Controller RGBCW", always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[ MinVersionProtocol(2, PROTOCOL_LEDENET_9BYTE_DIMMABLE_EFFECTS), MinVersionProtocol(0, PROTOCOL_LEDENET_9BYTE), ], mode_to_color_mode=GENERIC_RGBWW_MAP, color_modes={COLOR_MODE_RGBWW}, # Formerly rgbwcapable channel_map={}, microphone=False, device_config=RGBWW_DEVICE_CONFIG, ), LEDENETModel( model_num=0x08, # AK001-ZJ2101 is v1.11 # AK001-ZJ2145 is v2.11 (Upgradable to v2.15) models=["AK001-ZJ2101", "AK001-ZJ2145", "AK001-ZJ2147"], description="Controller RGB with MIC", always_writes_white_and_colors=True, # Formerly rgbwprotocol protocols=[ MinVersionProtocol(2, PROTOCOL_LEDENET_8BYTE_DIMMABLE_EFFECTS), MinVersionProtocol(1, PROTOCOL_LEDENET_8BYTE_AUTO_ON), MinVersionProtocol(0, PROTOCOL_LEDENET_8BYTE), ], mode_to_color_mode=GENERIC_RGB_MAP, color_modes={COLOR_MODE_RGB}, channel_map={}, microphone=True, device_config=RGB_DEVICE_CONFIG, ), LEDENETModel( model_num=0x09, # same as 0xE1 but not wrapped models=[], description="High Voltage Bulb CCT", always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_CCT)], mode_to_color_mode={}, color_modes={COLOR_MODE_CCT}, # Formerly rgbwcapable channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x0B, models=[], description="Switch 1 Channel", always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_8BYTE)], mode_to_color_mode={}, color_modes=set(), # no color modes channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( # 'AK001-ZJ2104' likely supports turning on by effect/levels set # 'AK001-ZJ2104' is v7 # 'AK001-ZJ2148' is v9.75 with Remote and 2.4G remote settings # 'AK001-ZJ21411' is v11.78 with Remote and 2.4G remote settings model_num=0x0E, # Should be the same as 0x35 models=["AK001-ZJ2104", "AK001-ZJ2148", "AK001-ZJ21411", "AK001-ZJ21412"], description="Floor Lamp RGBCW", always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[ MinVersionProtocol(9, PROTOCOL_LEDENET_9BYTE_DIMMABLE_EFFECTS), MinVersionProtocol(7, PROTOCOL_LEDENET_9BYTE_AUTO_ON), MinVersionProtocol(0, PROTOCOL_LEDENET_9BYTE), ], mode_to_color_mode={0x01: COLOR_MODES_RGB_CCT, 0x17: COLOR_MODES_RGB_CCT}, color_modes=COLOR_MODES_RGB_CCT, channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x10, models=[], description="Christmas Light", always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_ADDRESSABLE_CHRISTMAS)], mode_to_color_mode={}, color_modes=COLOR_MODES_ADDRESSABLE, channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( # 'AK001-ZJ2147' is v1.25 model_num=0x1A, models=["AK001-ZJ2147"], description="String Lights", always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_ADDRESSABLE_CHRISTMAS)], mode_to_color_mode={}, color_modes=COLOR_MODES_ADDRESSABLE, channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x16, models=[], description="Magnetic Light CCT", always_writes_white_and_colors=True, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_8BYTE)], mode_to_color_mode={}, color_modes={COLOR_MODE_CCT}, # Formerly rgbwcapable channel_map={ STATE_WARM_WHITE: STATE_RED, STATE_RED: STATE_WARM_WHITE, STATE_COOL_WHITE: STATE_GREEN, STATE_GREEN: STATE_COOL_WHITE, }, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x17, models=[], description="Magnetic Light Dimable", always_writes_white_and_colors=True, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_8BYTE)], mode_to_color_mode={}, color_modes={COLOR_MODE_DIM}, # Formerly rgbwcapable channel_map={STATE_WARM_WHITE: STATE_RED, STATE_RED: STATE_WARM_WHITE}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x18, models=[], description="Plant Grow Light", always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_8BYTE)], mode_to_color_mode={}, color_modes=set(), # no color modes -- UNVERIFIED channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x19, models=[], description="Socket with 2 USB", always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_SOCKET)], mode_to_color_mode={}, color_modes=set(), # no color modes channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x1B, models=[], description="Aroma Fragrance Light", always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_8BYTE)], mode_to_color_mode={}, color_modes={COLOR_MODE_RGB}, channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x1C, # AK001-ZJ2149 has RF remote support and can change 2.4G remote settings models=["AK001-ZJ2149"], description="Table Light CCT", always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_CCT_WRAPPED)], mode_to_color_mode={}, color_modes={COLOR_MODE_CCT}, # Formerly rgbwcapable channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x1D, models=[], description="Fill Light", always_writes_white_and_colors=True, # Formerly rgbwprotocol protocols=[MinVersionProtocol(2, PROTOCOL_LEDENET_8BYTE_AUTO_ON)], mode_to_color_mode={}, color_modes={COLOR_MODE_DIM}, # Formerly rgbwcapable channel_map={STATE_WARM_WHITE: STATE_RED, STATE_RED: STATE_WARM_WHITE}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x1E, # Should be the same as 0x35 # 'AK001-ZJ21410' is v9.9 (with RF remote control support + pairing) models=[], description="Ceiling Light RGBCW", always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[ MinVersionProtocol(9, PROTOCOL_LEDENET_9BYTE_DIMMABLE_EFFECTS), MinVersionProtocol(7, PROTOCOL_LEDENET_9BYTE_AUTO_ON), MinVersionProtocol(0, PROTOCOL_LEDENET_9BYTE), ], mode_to_color_mode={0x01: COLOR_MODES_RGB_CCT, 0x17: COLOR_MODES_RGB_CCT}, color_modes=COLOR_MODES_RGB_CCT, channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x21, # 'AK001-ZJ200' is v1 but with new firmware it will change to v2 # 'AK001-ZJ210' is v2.45 # 'AK001-ZJ2104' is v2 - likely supports turning on by effect/levels set models=["AK001-ZJ200", "AK001-ZJ210", "AK001-ZJ2101", "AK001-ZJ2104"], description="Bulb Dimmable", # Also seen on mini inline dimmers always_writes_white_and_colors=True, # Verified required with AK001-ZJ200 bulb protocols=[ MinVersionProtocol(2, PROTOCOL_LEDENET_8BYTE_AUTO_ON), MinVersionProtocol(0, PROTOCOL_LEDENET_8BYTE), ], mode_to_color_mode={}, color_modes={COLOR_MODE_DIM}, # Formerly rgbwcapable channel_map={STATE_WARM_WHITE: STATE_RED, STATE_RED: STATE_WARM_WHITE}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x25, # 'AK001-ZJ200' == v2.08 - some devices have RF remote support (the mini ones) models=["HF-LPB100-ZJ200", "AK001-ZJ200"], description="Controller RGB/WW/CW", always_writes_white_and_colors=True, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_9BYTE)], mode_to_color_mode=BASE_MODE_MAP, color_modes={COLOR_MODE_RGBWW}, # Formerly rgbwcapable channel_map={}, microphone=False, device_config=MULTI_MODE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x33, # 'AK001-ZJ100' == v3.03 WIFI370 version, v3.11 IR_mini # 'AK001-ZJ210' == v6.37 - Seen on the outdoor string lights from Lytworx -- May support auto on? # 'AK001-ZJ2104' == v7.07 - Seen on usb fairy lights - supports turning on by effect/levels set # 'AK001-ZJ2134' == v8.02 - seen on the water proof controllers for outdoor garden light # 'AK001-ZJ2101' == v8.61, 8.62 (44 key) - no dimmable effects confirmed, confirmed auto on # 'AK001-ZJ2131' == v8.38 larger mini controller - no dimmable effects confirmed, confirmed auto on # "AK001-ZJ2145" == v9.25, v9.27, 9.30, 9.33 # w/IR # "AK001-ZJ2146" == v10.48 # 2.4ghz support, some have IR # "AK001-ZJ2148" == v10.63 # 2.4ghz support, confirmed to be able to change 2.4G remote settings models=[ "AK001-ZJ100", "AK001-ZJ210", "AK001-ZJ2104", "AK001-ZJ2101", "AK001-ZJ2131", "AK001-ZJ2134", "AK001-ZJ2145", "AK001-ZJ2146", "AK001-ZJ2148", ], description="Controller RGB", always_writes_white_and_colors=True, # Formerly rgbwprotocol protocols=[ MinVersionProtocol(9, PROTOCOL_LEDENET_8BYTE_DIMMABLE_EFFECTS), MinVersionProtocol(6, PROTOCOL_LEDENET_8BYTE_AUTO_ON), MinVersionProtocol(0, PROTOCOL_LEDENET_8BYTE), ], mode_to_color_mode=GENERIC_RGB_MAP, color_modes={COLOR_MODE_RGB}, channel_map={}, microphone=False, device_config=RGB_DEVICE_CONFIG, ), LEDENETModel( model_num=0x35, # 'AK001-ZJ200' is v5.17, 5.33 # 'AK001-ZJ2101' is v7.63 # 'AK001-ZJ2104' is v7.07 # 'AK001-ZJ2145' is v8.47, v8.56 - seen on 7w bulbs # 'AK001-ZJ2146' is v9.62, 40w flood light, newer smart bulbs (with RF remote control support) # 'AK001-ZJ2147' is v9.7 (with RF remote control support) # 'AK001-ZJ21410' is v9.91 seen on the Bunpeon smart floor light ASIN:B09MN65324 models=[ "AK001-ZJ200", "AK001-ZJ2101", "AK001-ZJ2104", "AK001-ZJ2145", "AK001-ZJ2146", "AK001-ZJ2147", "AK001-ZJ21410", ], description="Bulb RGBCW", always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[ MinVersionProtocol(9, PROTOCOL_LEDENET_9BYTE_DIMMABLE_EFFECTS), MinVersionProtocol(7, PROTOCOL_LEDENET_9BYTE_AUTO_ON), MinVersionProtocol(0, PROTOCOL_LEDENET_9BYTE), ], mode_to_color_mode={0x01: COLOR_MODES_RGB_CCT, 0x17: COLOR_MODES_RGB_CCT}, color_modes=COLOR_MODES_RGB_CCT, channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x41, # 'AK001-ZJ2104' is v2.51 and supports turning on by effect/levels set models=["AK001-ZJ2104"], description="Controller Dimmable", always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[ MinVersionProtocol(2, PROTOCOL_LEDENET_8BYTE_AUTO_ON), MinVersionProtocol(0, PROTOCOL_LEDENET_8BYTE), ], mode_to_color_mode={}, # Only mode should be 0x41 color_modes={COLOR_MODE_DIM}, # Formerly rgbwcapable channel_map={STATE_WARM_WHITE: STATE_RED, STATE_RED: STATE_WARM_WHITE}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x44, # v5 - HF-LPB100-ZJ200 - Schultze imports 7w bulb # v8 - AK001-ZJ200 aka old flux # v9.34 - AK001-ZJ210 # v10.49 - AK001-ZJ2101 models=["HF-LPB100-ZJ200", "AK001-ZJ200", "AK001-ZJ210", "AK001-ZJ2101"], description="Bulb RGBW", always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[ MinVersionProtocol(9, PROTOCOL_LEDENET_8BYTE_AUTO_ON), MinVersionProtocol(0, PROTOCOL_LEDENET_8BYTE), ], mode_to_color_mode={}, color_modes=COLOR_MODES_RGB_W, # Formerly rgbwcapable channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x45, models=[], description=UNKNOWN_MODEL, # Unknown always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_8BYTE)], mode_to_color_mode={}, color_modes={COLOR_MODE_RGB, COLOR_MODE_DIM}, # Formerly rgbwcapable channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x52, models=["AK001-ZJ200"], description="Bulb CCT", always_writes_white_and_colors=True, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_8BYTE)], mode_to_color_mode={}, color_modes={COLOR_MODE_CCT}, # Formerly rgbwcapable channel_map={ STATE_WARM_WHITE: STATE_RED, STATE_RED: STATE_WARM_WHITE, STATE_COOL_WHITE: STATE_GREEN, STATE_GREEN: STATE_COOL_WHITE, }, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x54, models=["HF-LPB100-ZJ200"], description="Downlight RGBW", always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_8BYTE)], mode_to_color_mode={}, color_modes=COLOR_MODES_RGB_W, # Formerly rgbwcapable channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x62, models=[], description="Controller CCT", always_writes_white_and_colors=True, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_8BYTE)], mode_to_color_mode={}, color_modes={COLOR_MODE_CCT}, # Formerly rgbwcapable channel_map={ STATE_WARM_WHITE: STATE_RED, STATE_RED: STATE_WARM_WHITE, STATE_COOL_WHITE: STATE_GREEN, STATE_GREEN: STATE_COOL_WHITE, }, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x81, models=[], description=UNKNOWN_MODEL, # Unknown always_writes_white_and_colors=True, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_8BYTE)], mode_to_color_mode={}, color_modes={COLOR_MODE_RGBW}, # Formerly rgbwcapable channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x93, # AK001-ZJ2146 == v3 models=["AK001-ZJ2146"], description="Switch", # 1 channel always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_SOCKET)], mode_to_color_mode={}, color_modes=set(), # no color modes channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x94, models=[], description="Switch Watt", # 1 channel always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_SOCKET)], mode_to_color_mode={}, color_modes=set(), # no color modes channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x95, models=[], description="Switch 2 Channel", # 2 channels always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_SOCKET)], mode_to_color_mode={}, color_modes=set(), # no color modes channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x96, models=[], description="Switch 4 Channel", # 4 channels always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_SOCKET)], mode_to_color_mode={}, color_modes=set(), # no color modes channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0x97, # 0x97 # AK001-ZJ210 = v2.28 # AK001-ZJ2146 = v3.11, 3.12 (has BLE) models=["AK001-ZJ210", "AK001-ZJ2134", "AK001-ZJ2146"], description="Socket", # 1 channel always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_SOCKET)], mode_to_color_mode={}, color_modes=set(), # no color modes channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0xA1, # AK001-ZJ210 = v3.18 models=["AK001-ZJ210"], description="Addressable v1", always_writes_white_and_colors=False, protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_ADDRESSABLE_A1)], mode_to_color_mode={}, color_modes=COLOR_MODES_ADDRESSABLE, channel_map={}, microphone=False, device_config=A1_DEVICE_CONFIG, ), LEDENETModel( model_num=0xA2, # 'AK001-ZJ2104' = v2.33 supports turning on by effect/levels set models=["AK001-ZJ2104"], description="Addressable v2", always_writes_white_and_colors=False, protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_ADDRESSABLE_A2)], mode_to_color_mode={}, color_modes=COLOR_MODES_ADDRESSABLE, channel_map={}, microphone=True, device_config=A2_DEVICE_CONFIG, ), LEDENETModel( model_num=0xA3, # AK001-ZJ2147 v1.17, v1.19 has RF remote support # AK001-ZJ2148 v1.26, v1.27 has RF remote support, confirmed to be able to change 2.4G remote settings models=["AK001-ZJ2147", "AK001-ZJ2148"], description="Addressable v3", always_writes_white_and_colors=False, protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_ADDRESSABLE_A3)], mode_to_color_mode={}, color_modes=COLOR_MODES_ADDRESSABLE, channel_map={}, microphone=True, device_config=NEW_ADDRESABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0xA4, models=[], description="Addressable v4", always_writes_white_and_colors=False, protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_ADDRESSABLE_A3)], mode_to_color_mode={}, color_modes=COLOR_MODES_ADDRESSABLE, channel_map={}, microphone=False, # confirmed false device_config=NEW_ADDRESABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0xA6, models=[], description="Addressable v6", always_writes_white_and_colors=False, protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_ADDRESSABLE_A3)], mode_to_color_mode={}, color_modes=COLOR_MODES_ADDRESSABLE, channel_map={}, microphone=True, # confirmed with mocks to be true device_config=NEW_ADDRESABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0xA7, models=[], description="Addressable v7", always_writes_white_and_colors=False, protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_ADDRESSABLE_A3)], mode_to_color_mode={}, color_modes=COLOR_MODES_ADDRESSABLE, channel_map={}, microphone=True, # confirmed with mocks to be true device_config=NEW_ADDRESABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0xD1, models=[], description="Digital Time Light", always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_8BYTE)], mode_to_color_mode={}, color_modes=set(), # no color modes -- UNVERIFIED channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0xE1, models=["AK001-ZJ2104"], description="Ceiling Light CCT", always_writes_white_and_colors=True, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_8BYTE)], mode_to_color_mode={}, color_modes={COLOR_MODE_CCT}, # Formerly rgbwcapable channel_map={ STATE_WARM_WHITE: STATE_RED, STATE_RED: STATE_WARM_WHITE, STATE_COOL_WHITE: STATE_GREEN, STATE_GREEN: STATE_COOL_WHITE, }, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), LEDENETModel( model_num=0xE2, models=[], description="Ceiling Light CCT Assist", always_writes_white_and_colors=True, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, PROTOCOL_LEDENET_8BYTE)], mode_to_color_mode={}, color_modes={COLOR_MODE_CCT}, # Formerly rgbwcapable channel_map={ STATE_WARM_WHITE: STATE_RED, STATE_RED: STATE_WARM_WHITE, STATE_COOL_WHITE: STATE_GREEN, STATE_GREEN: STATE_COOL_WHITE, }, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ), ] MODEL_MAP: Dict[int, LEDENETModel] = {model.model_num: model for model in MODELS} def get_model(model_num: int, fallback_protocol: Optional[str] = None) -> LEDENETModel: """Return the LEDNETModel for the model_num.""" return MODEL_MAP.get( model_num, _unknown_ledenet_model(model_num, fallback_protocol or PROTOCOL_LEDENET_8BYTE), ) def is_known_model(model_num: int) -> bool: """Return true of the model is known.""" return model_num in MODEL_MAP def _unknown_ledenet_model(model_num: int, fallback_protocol: str) -> LEDENETModel: """Create a LEDNETModel for an unknown model_num.""" return LEDENETModel( model_num=model_num, models=[], description=UNKNOWN_MODEL, always_writes_white_and_colors=False, # Formerly rgbwprotocol protocols=[MinVersionProtocol(0, fallback_protocol)], mode_to_color_mode={}, color_modes={COLOR_MODE_RGB}, channel_map={}, microphone=False, device_config=IMMUTABLE_DEVICE_CONFIG, ) def get_model_description(model_num: int, model_info: Optional[str]) -> str: """Return the description for a model.""" return format_model_description(get_model(model_num).description, model_info) def format_model_description(description: str, model_info: Optional[str]) -> str: """Format the description for a model.""" if model_info: extra = MODEL_INFO_NAMES.get(model_info.upper()) if extra: return f"{description} {extra}" return description Danielhiversen-flux_led-bfd1bbe/flux_led/pattern.py000066400000000000000000001147471447734565100227700ustar00rootroot00000000000000from typing import Dict, Optional, cast from .const import MultiColorEffects EFFECT_COLORLOOP = "colorloop" EFFECT_RED_FADE = "red_fade" EFFECT_GREEN_FADE = "green_fade" EFFECT_BLUE_FADE = "blue_fade" EFFECT_YELLOW_FADE = "yellow_fade" EFFECT_CYAN_FADE = "cyan_fade" EFFECT_PURPLE_FADE = "purple_fade" EFFECT_WHITE_FADE = "white_fade" EFFECT_RED_GREEN_BLUE_CROSS_FADE = "rgb_cross_fade" EFFECT_RED_GREEN_CROSS_FADE = "rg_cross_fade" EFFECT_RED_BLUE_CROSS_FADE = "rb_cross_fade" EFFECT_GREEN_BLUE_CROSS_FADE = "gb_cross_fade" EFFECT_COLORSTROBE = "colorstrobe" EFFECT_RED_STROBE = "red_strobe" EFFECT_GREEN_STROBE = "green_strobe" EFFECT_BLUE_STROBE = "blue_strobe" EFFECT_YELLOW_STROBE = "yellow_strobe" EFFECT_CYAN_STROBE = "cyan_strobe" EFFECT_PURPLE_STROBE = "purple_strobe" EFFECT_WHITE_STROBE = "white_strobe" EFFECT_CYCLE_RGB = "cycle_rgb" EFFECT_CYCLE_SEVEN_COLORS = "cycle_seven_colors" EFFECT_COLORJUMP = "colorjump" EFFECT_CUSTOM = "custom" EFFECT_WARM_FLASH = "Warm Flash" EFFECT_COOL_FLASH = "Cool Flash" EFFECT_WARM_GRADUAL = "Warm Gradual" EFFECT_COOL_GRADUAL = "Cool Gradual" EFFECT_MAP = { EFFECT_COLORLOOP: 0x25, EFFECT_RED_FADE: 0x26, EFFECT_GREEN_FADE: 0x27, EFFECT_BLUE_FADE: 0x28, EFFECT_YELLOW_FADE: 0x29, EFFECT_CYAN_FADE: 0x2A, EFFECT_PURPLE_FADE: 0x2B, EFFECT_WHITE_FADE: 0x2C, EFFECT_RED_GREEN_CROSS_FADE: 0x2D, EFFECT_RED_BLUE_CROSS_FADE: 0x2E, EFFECT_GREEN_BLUE_CROSS_FADE: 0x2F, EFFECT_COLORSTROBE: 0x30, EFFECT_RED_STROBE: 0x31, EFFECT_GREEN_STROBE: 0x32, EFFECT_BLUE_STROBE: 0x33, EFFECT_YELLOW_STROBE: 0x34, EFFECT_CYAN_STROBE: 0x35, EFFECT_PURPLE_STROBE: 0x36, EFFECT_WHITE_STROBE: 0x37, EFFECT_COLORJUMP: 0x38, } EFFECT_MAP_DIMMABLE = { EFFECT_RED_GREEN_BLUE_CROSS_FADE: 0x24, **EFFECT_MAP, EFFECT_CYCLE_RGB: 0x39, EFFECT_CYCLE_SEVEN_COLORS: 0x3A, # cycle_seven_colors Doesn't work on the bulbs, but no way to tell ahead of time # since the firmware version is v9 for both but it seems like only the AK001-ZJ2147 # model actually has support for it } EFFECT_MAP_LEGACY_CCT = { EFFECT_WARM_GRADUAL: 0x3A, EFFECT_WARM_FLASH: 0x3C, EFFECT_COOL_GRADUAL: 0x4A, EFFECT_COOL_FLASH: 0x4C, } EFFECT_ID_NAME_LEGACY_CCT = {v: k for k, v in EFFECT_MAP_LEGACY_CCT.items()} EFFECT_ID_NAME = {v: k for k, v in EFFECT_MAP_DIMMABLE.items()} EFFECT_CUSTOM_CODE = 0x60 EFFECT_LIST = sorted(EFFECT_MAP) EFFECT_LIST_DIMMABLE = sorted(EFFECT_MAP_DIMMABLE) EFFECT_LIST_LEGACY_CCT = sorted(EFFECT_MAP_LEGACY_CCT) ADDRESSABLE_EFFECT_ID_NAME = { 1: "RBM 1", 2: "RBM 2", 3: "RBM 3", 4: "RBM 4", 5: "RBM 5", 6: "RBM 6", 7: "RBM 7", 8: "RBM 8", 9: "RBM 9", 10: "RBM 10", 11: "RBM 11", 12: "RBM 12", 13: "RBM 13", 14: "RBM 14", 15: "RBM 15", 16: "RBM 16", 17: "RBM 17", 18: "RBM 18", 19: "RBM 19", 20: "RBM 20", 21: "RBM 21", 22: "RBM 22", 23: "RBM 23", 24: "RBM 24", 25: "RBM 25", 26: "RBM 26", 27: "RBM 27", 28: "RBM 28", 29: "RBM 29", 30: "RBM 30", 31: "RBM 31", 32: "RBM 32", 33: "RBM 33", 34: "RBM 34", 35: "RBM 35", 36: "RBM 36", 37: "RBM 37", 38: "RBM 38", 39: "RBM 39", 40: "RBM 40", 41: "RBM 41", 42: "RBM 42", 43: "RBM 43", 44: "RBM 44", 45: "RBM 45", 46: "RBM 46", 47: "RBM 47", 48: "RBM 48", 49: "RBM 49", 50: "RBM 50", 51: "RBM 51", 52: "RBM 52", 53: "RBM 53", 54: "RBM 54", 55: "RBM 55", 56: "RBM 56", 57: "RBM 57", 58: "RBM 58", 59: "RBM 59", 60: "RBM 60", 61: "RBM 61", 62: "RBM 62", 63: "RBM 63", 64: "RBM 64", 65: "RBM 65", 66: "RBM 66", 67: "RBM 67", 68: "RBM 68", 69: "RBM 69", 70: "RBM 70", 71: "RBM 71", 72: "RBM 72", 73: "RBM 73", 74: "RBM 74", 75: "RBM 75", 76: "RBM 76", 77: "RBM 77", 78: "RBM 78", 79: "RBM 79", 80: "RBM 80", 81: "RBM 81", 82: "RBM 82", 83: "RBM 83", 84: "RBM 84", 85: "RBM 85", 86: "RBM 86", 87: "RBM 87", 88: "RBM 88", 89: "RBM 89", 90: "RBM 90", 91: "RBM 91", 92: "RBM 92", 93: "RBM 93", 94: "RBM 94", 95: "RBM 95", 96: "RBM 96", 97: "RBM 97", 98: "RBM 98", 99: "RBM 99", 100: "RBM 100", 101: "RBM 101", # Not in the Magic Home App (only set by remote) 102: "RBM 102", # Not in the Magic Home App (only set by remote) 255: "Circulate all modes", # Cycles all } ADDRESSABLE_EFFECT_NAME_ID = {v: k for k, v in ADDRESSABLE_EFFECT_ID_NAME.items()} ASSESSABLE_MULTI_COLOR_ID_NAME = { MultiColorEffects.STATIC.value: "Multi Color Static", MultiColorEffects.RUNNING_WATER.value: "Multi Color Running Water", MultiColorEffects.STROBE.value: "Multi Color Strobe", MultiColorEffects.JUMP.value: "Multi Color Jump", MultiColorEffects.BREATHING.value: "Multi Color Breathing", } ASSESSABLE_MULTI_COLOR_NAME_ID = { v: k for k, v in ASSESSABLE_MULTI_COLOR_ID_NAME.items() } ORIGINAL_ADDRESSABLE_EFFECT_ID_NAME = { 1: "Circulate all modes", 2: "7 colors change gradually", 3: "7 colors run in olivary", 4: "7 colors change quickly", 5: "7 colors strobe-flash", 6: "7 colors running, 1 point from start to end and return back", 7: "7 colors running, multi points from start to end and return back", 8: "7 colors overlay, multi points from start to end and return back", 9: "7 colors overlay, multi points from the middle to the both ends and return back", 10: "7 colors flow gradually, from start to end and return back", 11: "Fading out run, 7 colors from start to end and return back", 12: "Runs in olivary, 7 colors from start to end and return back", 13: "Fading out run, 7 colors start with white color from start to end and return back", 14: "Run circularly, 7 colors with black background, 1point from start to end", 15: "Run circularly, 7 colors with red background, 1point from start to end", 16: "Run circularly, 7 colors with green background, 1point from start to end", 17: "Run circularly, 7 colors with blue background, 1point from start to end", 18: "Run circularly, 7 colors with yellow background, 1point from start to end", 19: "Run circularly, 7 colors with purple background, 1point from start to end", 20: "Run circularly, 7 colors with cyan background, 1point from start to end", 21: "Run circularly, 7 colors with white background, 1point from start to end", 22: "Run circularly, 7 colors with black background, 1point from end to start", 23: "Run circularly, 7 colors with red background, 1point from end to start", 24: "Run circularly, 7 colors with green background, 1point from end to start", 25: "Run circularly, 7 colors with blue background, 1point from end to start", 26: "Run circularly, 7 colors with yellow background, 1point from end to start", 27: "Run circularly, 7 colors with purple background, 1point from end to start", 28: "Run circularly, 7 colors with cyan background, 1point from end to start", 29: "Run circularly, 7 colors with white background, 1point from end to start", 30: "Run circularly, 7 colors with black background, 1point from start to end and return back", 31: "Run circularly, 7 colors with red background, 1point from start to end and return back", 32: "Run circularly, 7 colors with green background, 1point from start to end and return back", 33: "Run circularly, 7 colors with blue background, 1point from start to end and return back", 34: "Run circularly, 7 colors with yellow background, 1point from start to end and return back", 35: "Run circularly, 7 colors with purple background, 1point from start to end and return back", 36: "Run circularly, 7 colors with cyan background, 1point from start to end and return back", 37: "Run circularly, 7 colors with white background, 1point from start to end and return back", 38: "Run circularly, 7 colors with black background, 1point from middle to both ends", 39: "Run circularly, 7 colors with red background, 1point from middle to both ends", 40: "Run circularly, 7 colors with green background, 1point from middle to both ends", 41: "Run circularly, 7 colors with blue background, 1point from middle to both ends", 42: "Run circularly, 7 colors with yellow background, 1point from middle to both ends", 43: "Run circularly, 7 colors with purple background, 1point from middle to both ends", 44: "Run circularly, 7 colors with cyan background, 1point from middle to both ends", 45: "Run circularly, 7 colors with white background, 1point from middle to both ends", 46: "Run circularly, 7 colors with black background, 1point from both ends to middle", 47: "Run circularly, 7 colors with red background, 1point from both ends to middle", 48: "Run circularly, 7 colors with green background, 1point from both ends to middle", 49: "Run circularly, 7 colors with blue background, 1point from both ends to middle", 50: "Run circularly, 7 colors with yellow background, 1point from both ends to middle", 51: "Run circularly, 7 colors with purple background, 1point from both ends to middle", 52: "Run circularly, 7 colors with cyan background, 1point from both ends to middle", 53: "Run circularly, 7 colors with white background, 1point from both ends to middle", 54: "Run circularly, 7 colors with black background, 1point from middle to both ends and return back", 55: "Run circularly, 7 colors with red background, 1point from middle to both ends and return back", 56: "Run circularly, 7 colors with green background, 1point from middle to both ends and return back", 57: "Run circularly, 7 colors with blue background, 1point from middle to both ends and return back", 58: "Run circularly, 7 colors with yellow background, 1point from middle to both ends and return back", 59: "Run circularly, 7 colors with purple background, 1point from middle to both ends and return back", 60: "Run circularly, 7 colors with cyan background, 1point from middle to both ends and return back", 61: "Run circularly, 7 colors with white background, 1point from middle to both ends and return back", 62: "Overlay circularly, 7 colors with black background from start to end", 63: "Overlay circularly, 7 colors with red background from start to end", 64: "Overlay circularly, 7 colors with green background from start to end", 65: "Overlay circularly, 7 colors with blue background from start to end", 66: "Overlay circularly, 7 colors with yellow background from start to end", 67: "Overlay circularly, 7 colors with purple background from start to end", 68: "Overlay circularly, 7 colors with cyan background from start to end", 69: "Overlay circularly, 7 colors with white background from start to end", 70: "Overlay circularly, 7 colors with black background from end to start", 71: "Overlay circularly, 7 colors with red background from end to start", 72: "Overlay circularly, 7 colors with green background from end to start", 73: "Overlay circularly, 7 colors with blue background from end to start", 74: "Overlay circularly, 7 colors with yellow background from end to start", 75: "Overlay circularly, 7 colors with purple background from end to start", 76: "Overlay circularly, 7 colors with cyan background from end to start", 77: "Overlay circularly, 7 colors with white background from end to start", 78: "Overlay circularly, 7 colors with black background from start to end and return back", 79: "Overlay circularly, 7 colors with red background from start to end and return back", 80: "Overlay circularly, 7 colors with green background from start to end and return back", 81: "Overlay circularly, 7 colors with blue background from start to end and return back", 82: "Overlay circularly, 7 colors with yellow background from start to end and return back", 83: "Overlay circularly, 7 colors with purple background from start to end and return back", 84: "Overlay circularly, 7 colors with cyan background from start to end and return back", 85: "Overlay circularly, 7 colors with white background from start to end and return back", 86: "Overlay circularly, 7 colors with black background from middle to both ends", 87: "Overlay circularly, 7 colors with red background from middle to both ends", 88: "Overlay circularly, 7 colors with green background from middle to both ends", 89: "Overlay circularly, 7 colors with blue background from middle to both ends", 90: "Overlay circularly, 7 colors with yellow background from middle to both ends", 91: "Overlay circularly, 7 colors with purple background from middle to both ends", 92: "Overlay circularly, 7 colors with cyan background from middle to both ends", 93: "Overlay circularly, 7 colors with white background from middle to both ends", 94: "Overlay circularly, 7 colors with black background from both ends to middle", 95: "Overlay circularly, 7 colors with red background from both ends to middle", 96: "Overlay circularly, 7 colors with green background from both ends to middle", 97: "Overlay circularly, 7 colors with blue background from both ends to middle", 98: "Overlay circularly, 7 colors with yellow background from both ends to middle", 99: "Overlay circularly, 7 colors with purple background from both ends to middle", 100: "Overlay circularly, 7 colors with cyan background from both ends to middle", 101: "Overlay circularly, 7 colors with white background from both ends to middle", 102: "Overlay circularly, 7 colors with black background from middle to both sides and return back", 103: "Overlay circularly, 7 colors with red background from middle to both sides and return back", 104: "Overlay circularly, 7 colors with green background from middle to both sides and return back", 105: "Overlay circularly, 7 colors with blue background from middle to both sides and return back", 106: "Overlay circularly, 7 colors with yellow background from middle to both sides and return back", 107: "Overlay circularly, 7 colors with purple background from middle to both sides and return back", 108: "Overlay circularly, 7 colors with cyan background from middle to both sides and return back", 109: "Overlay circularly, 7 colors with white background from middle to both sides and return back", 110: "Fading out run circularly, 1point with black background from start to end", 111: "Fading out run circularly, 1point with red background from start to end", 112: "Fading out run circularly, 1point with green background from start to end", 113: "Fading out run circularly, 1point with blue background from start to end", 114: "Fading out run circularly, 1point with yellow background from start to end", 115: "Fading out run circularly, 1point with purple background from start to end", 116: "Fading out run circularly, 1point with cyan background from start to end", 117: "Fading out run circularly, 1point with white background from start to end", 118: "Fading out run circularly, 1point with black background from end to start", 119: "Fading out run circularly, 1point with red background from end to start", 120: "Fading out run circularly, 1point with green background from end to start", 121: "Fading out run circularly, 1point with blue background from end to start", 122: "Fading out run circularly, 1point with yellow background from end to start", 123: "Fading out run circularly, 1point with purple background from end to start", 124: "Fading out run circularly, 1point with cyan background from end to start", 125: "Fading out run circularly, 1point with white background from end to start", 126: "Fading out run circularly, 1point with black background from start to end and return back", 127: "Fading out run circularly, 1point with red background from start to end and return back", 128: "Fading out run circularly, 1point with green background from start to end and return back", 129: "Fading out run circularly, 1point with blue background from start to end and return back", 130: "Fading out run circularly, 1point with yellow background from start to end and return back", 131: "Fading out run circularly, 1point with purple background from start to end and return back", 132: "Fading out run circularly, 1point with cyan background from start to end and return back", 133: "Fading out run circularly, 1point with white background from start to end and return back", 134: "Flows in olivary circularly, 7 colors with black background from start to end", 135: "Flows in olivary circularly, 7 colors with red background from start to end", 136: "Flows in olivary circularly, 7 colors with green background from start to end", 137: "Flows in olivary circularly, 7 colors with blue background from start to end", 138: "Flows in olivary circularly, 7 colors with yellow background from start to end", 139: "Flows in olivary circularly, 7 colors with purple background from start to end", 140: "Flows in olivary circularly, 7 colors with cyan background from start to end", 141: "Flows in olivary circularly, 7 colors with white background from start to end", 142: "Flows in olivary circularly, 7 colors with black background from end to start", 143: "Flows in olivary circularly, 7 colors with red background from end to start", 144: "Flows in olivary circularly, 7 colors with green background from end to start", 145: "Flows in olivary circularly, 7 colors with blue background from end to start", 146: "Flows in olivary circularly, 7 colors with yellow background from end to start", 147: "Flows in olivary circularly, 7 colors with purple background from end to start", 148: "Flows in olivary circularly, 7 colors with cyan background from end to start", 149: "Flows in olivary circularly, 7 colors with white background from end to start", 150: "Flows in olivary circularly, 7 colors with black background from start to end and return back", 151: "Flows in olivary circularly, 7 colors with red background from start to end and return back", 152: "Flows in olivary circularly, 7 colors with green background from start to end and return back", 153: "Flows in olivary circularly, 7 colors with blue background from start to end and return back", 154: "Flows in olivary circularly, 7 colors with yellow background from start to end and return back", 155: "Flows in olivary circularly, 7 colors with purple background from start to end and return back", 156: "Flows in olivary circularly, 7 colors with cyan background from start to end and return back", 157: "Flows in olivary circularly, 7 colors with white background from start to end and return back", 158: "7 colors run circularly, each color in every 1 point with black background from start to end", 159: "7 colors run circularly, each color in every 1 point with red background from start to end", 160: "7 colors run circularly, each color in every 1 point with green background from start to end", 161: "7 colors run circularly, each color in every 1 point with blue background from start to end", 162: "7 colors run circularly, each color in every 1 point with yellow background from start to end", 163: "7 colors run circularly, each color in every 1 point with purple background from start to end", 164: "7 colors run circularly, each color in every 1 point with cyan background from start to end", 165: "7 colors run circularly, each color in every 1 point with white background from start to end", 166: "7 colors run circularly, each color in every 1 point with black background from end to start", 167: "7 colors run circularly, each color in every 1 point with red background from end to start", 168: "7 colors run circularly, each color in every 1 point with green background from end to start", 169: "7 colors run circularly, each color in every 1 point with blue background from end to start", 170: "7 colors run circularly, each color in every 1 point with yellow background from end to start", 171: "7 colors run circularly, each color in every 1 point with purple background from end to start", 172: "7 colors run circularly, each color in every 1 point with cyan background from end to start", 173: "7 colors run circularly, each color in every 1 point with white background from end to start", 174: "7 colors run circularly, each color in every 1 point with black background from start to end and return back", 175: "7 colors run circularly, each color in every 1 point with red background from start to end and return back", 176: "7 colors run circularly, each color in every 1 point with green background from start to end and return back", 177: "7 colors run circularly, each color in every 1 point with blue background from start to end and return back", 178: "7 colors run circularly, each color in every 1 point with yellow background from start to end and return back", 179: "7 colors run circularly, each color in every 1 point with purple background from start to end and return back", 180: "7 colors run circularly, each color in every 1 point with cyan background from start to end and return back", 181: "7 colors run circularly, each color in every 1 point with white background from start to end and return back", 182: "7 colors run circularly, each color in multi points with red background from start to end", 183: "7 colors run circularly, each color in multi points with green background from start to end", 184: "7 colors run circularly, each color in multi points with blue background from start to end", 185: "7 colors run circularly, each color in multi points with yellow background from start to end", 186: "7 colors run circularly, each color in multi points with purple background from start to end", 187: "7 colors run circularly, each color in multi points with cyan background from start to end", 188: "7 colors run circularly, each color in multi points with white background from start to end", 189: "7 colors run circularly, each color in multi points with red background from end to start", 190: "7 colors run circularly, each color in multi points with green background from end to start", 191: "7 colors run circularly, each color in multi points with blue background from end to start", 192: "7 colors run circularly, each color in multi points with yellow background from end to start", 193: "7 colors run circularly, each color in multi points with purple background from end to start", 194: "7 colors run circularly, each color in multi points with cyan background from end to start", 195: "7 colors run circularly, each color in multi points with white background from end to start", 196: "7 colors run circularly, each color in multi points with red background from start to end and return back", 197: "7 colors run circularly, each color in multi points with green background from start to end and return back", 198: "7 colors run circularly, each color in multi points with blue background from start to end and return back", 199: "7 colors run circularly, each color in multi points with yellow background from start to end and return back", 200: "7 colors run circularly, each color in multi points with purple background from start to end and return back", 201: "7 colors run circularly, each color in multi points with cyan background from start to end and return back", 202: "7 colors run circularly, each color in multi points with white background from start to end and return back", 203: "Fading out run circularly, 7 colors each in red fading from start to end", 204: "Fading out run circularly, 7 colors each in green fading from start to end", 205: "Fading out run circularly, 7 colors each in blue fading from start to end", 206: "Fading out run circularly, 7 colors each in yellow fading from start to end", 207: "Fading out run circularly, 7 colors each in purple fading from start to end", 208: "Fading out run circularly, 7 colors each in cyan fading from start to end", 209: "Fading out run circularly, 7 colors each in white fading from start to end", 210: "Fading out run circularly, 7 colors each in red fading from end to start", 211: "Fading out run circularly, 7 colors each in green fading from end to start", 212: "Fading out run circularly, 7 colors each in blue fading from end to start", 213: "Fading out run circularly, 7 colors each in yellow fading from end to start", 214: "Fading out run circularly, 7 colors each in purple fading from end to start", 215: "Fading out run circularly, 7 colors each in cyan fading from end to start", 216: "Fading out run circularly, 7 colors each in white fading from end to start", 217: "Fading out run circularly, 7 colors each in red fading from start to end and return back", 218: "Fading out run circularly, 7 colors each in green fading from start to end and return back", 219: "Fading out run circularly, 7 colors each in blue fading from start to end and return back", 220: "Fading out run circularly, 7 colors each in yellow fading from start to end and return back", 221: "Fading out run circularly, 7 colors each in purple fading from start to end and return back", 222: "Fading out run circularly, 7 colors each in cyan fading from start to end and return back", 223: "Fading out run circularly, 7 colors each in white fading from start to end and return back", 224: "7 colors each in red run circularly, multi points from start to end", 225: "7 colors each in green run circularly, multi points from start to end", 226: "7 colors each in blue run circularly, multi points from start to end", 227: "7 colors each in yellow run circularly, multi points from start to end", 228: "7 colors each in purple run circularly, multi points from start to end", 229: "7 colors each in cyan run circularly, multi points from start to end", 230: "7 colors each in white run circularly, multi points from start to end", 231: "7 colors each in red run circularly, multi points from end to start", 232: "7 colors each in green run circularly, multi points from end to start", 233: "7 colors each in blue run circularly, multi points from end to start", 234: "7 colors each in yellow run circularly, multi points from end to start", 235: "7 colors each in purple run circularly, multi points from end to start", 236: "7 colors each in cyan run circularly, multi points from end to start", 237: "7 colors each in white run circularly, multi points from end to start", 238: "7 colors each in red run circularly, multi points from start to end and return back", 239: "7 colors each in green run circularly, multi points from start to end and return back", 240: "and return back7 colors each in blue run circularly, multi points from start to end", 241: "7 colors each in yellow run circularly, multi points from start to end and return back", 242: "7 colors each in purple run circularly, multi points from start to end and return back", 243: "7 colors each in cyan run circularly, multi points from start to end and return back", 244: "7 colors each in white run circularly, multi points from start to end and return back", 245: "Flows gradually and circularly, 6 colors with red background from start to end", 246: "Flows gradually and circularly, 6 colors with green background from start to end", 247: "Flows gradually and circularly, 6 colors with blue background from start to end", 248: "Flows gradually and circularly, 6 colors with yellow background from start to end", 249: "Flows gradually and circularly, 6 colors with purple background from start to end", 250: "Flows gradually and circularly, 6 colors with cyan background from start to end", 251: "Flows gradually and circularly, 6 colors with white background from start to end", 252: "Flows gradually and circularly, 6 colors with red background from end to start", 253: "Flows gradually and circularly, 6 colors with green background from end to start", 254: "Flows gradually and circularly, 6 colors with blue background from end to start", 255: "Flows gradually and circularly, 6 colors with yellow background from end to start", 256: "Flows gradually and circularly, 6 colors with purple background from end to start", 257: "Flows gradually and circularly, 6 colors with cyan background from end to start", 258: "Flows gradually and circularly, 6 colors with white background from end to start", 259: "Flows gradually and circularly, 6 colors with red background from start to end and return back", 260: "Flows gradually and circularly, 6 colors with green background from start to end and return back", 261: "Flows gradually and circularly, 6 colors with blue background from start to end and return back", 262: "Flows gradually and circularly, 6 colors with yellow background from start to end and return back", 263: "Flows gradually and circularly, 6 colors with purple background from start to end and return back", 264: "Flows gradually and circularly, 6 colors with cyan background from start to end and return back", 265: "Flows gradually and circularly, 6 colors with white background from start to end and return back", 266: "7 colors run with black background from start to end", 267: "7 colors run with red background from start to end", 268: "7 colors run with green background from start to end", 269: "7 colors run with blue background from start to end", 270: "7 colors run with yellow background from start to end", 271: "7 colors run with purple background from start to end", 272: "7 colors run with cyan background from start to end", 273: "7 colors run with white background from start to end", 274: "7 colors run with black background from end to start", 275: "7 colors run with red background from end to start", 276: "7 colors run with green background from end to start", 277: "7 colors run with blue background from end to start", 278: "7 colors run with yellow background from end to start", 279: "7 colors run with purple background from end to start", 280: "7 colors run with cyan background from end to start", 281: "7 colors run with white background from end to start", 282: "7 colors run with black background from start to end and return back", 283: "7 colors run with red background from start to end and return back", 284: "7 colors run with green background from start to end and return back", 285: "7 colors run with blue background from start to end and return back", 286: "7 colors run with yellow background from start to end and return back", 287: "7 colors run with purple background from start to end and return back", 288: "7 colors run with cyan background from start to end and return back", 289: "7 colors run with white background from start to end and return back", 290: "7 colors run gradually + 7 colors run in olivary", 291: "7 colors run gradually + 7 colors change quickly", 292: "7 colors run gradually + 7 colors flash", 293: "7 colors run in olivary + 7 colors change quickly", 294: "7 colors run in olivary + 7 colors flash", 295: "7 colors change quickly + 7 colors flash", 296: "7 colors run gradually + 7 colors run in olivary + 7 colors change quickly", 297: "7 colors run gradually + 7 colors run in olivary + 7 colors flash", 298: "7 colors run gradually + 7 colors change quickly + 7 colors flash", 299: "7 colors run in olivary + 7 colors change quickly + 7 colors flash", 300: "7 colors run gradually + 7 colors run in olivary + 7 colors change quickly + 7 color flash", } ORIGINAL_ADDRESSABLE_EFFECT_NAME_ID = { v: k for k, v in ORIGINAL_ADDRESSABLE_EFFECT_ID_NAME.items() } CHRISTMAS_ADDRESSABLE_EFFECT_ID_NAME = { 1: "Random Jump Async", 2: "Random Gradient Async", 3: "Fill-in Red, Green", 4: "Fill-in Green, Blue", 5: "Fill-in Green, Yellow", 6: "Fill-in Green, Cyan", 7: "Fill-in Green, White", 8: "Fill-in Green, Red", 9: "Twinkle Red", 10: "Twinkle Green", 11: "Twinkle Yellow", 12: "Twinkle Blue", 13: "Twinkle Purple", 14: "Twinkle Cyan", 15: "Twinkle White", 16: "Alternating Flash Red, Green, Blue", 17: "Alternating Flash Red, Green", 18: "Alternating Flash Green, Blue", 19: "Alternating Flash Blue, Yellow", 20: "Alternating Flash Yellow, Cyan", 21: "Alternating Flash Cyan, Purple", 22: "Alternating Flash Purple, White", 23: "Wave Seven-Color", 24: "Wave RGB", 25: "Wave Red", 26: "Wave Green", 27: "Wave Yellow", 28: "Wave Blue", 29: "Wave Purple", 30: "Wave Cyan", 31: "Wave White", 32: "Breathe Sync Seven-Color", 33: "Breathe Sync RGB", 34: "Breathe Sync Red, Green", 35: "Breathe Sync Red, Blue", 36: "Breathe Sync Green, Blue", 37: "Breathe Sync Red", 38: "Breathe Sync Green", 39: "Breathe Sync Yellow", 40: "Breathe Sync Blue", 41: "Breathe Sync Purple", 42: "Breathe Sync Cyan", 43: "Breathe Sync White", 44: "Fill-in and Reset Red, Green", 45: "Fill-in and Reset Green, Blue", 46: "Fill-in and Reset Blue, Yellow", 47: "Fill-in and Reset Yellow, Cyan", 48: "Fill-in and Reset Cyan, Purple", 49: "Fill-in and Reset Purple, White", 50: "Fill-in and Reset Red, Green, Blue, Yellow", 51: "Fill-in and Reset Red, Blue, Green, White", 52: "Fill-in and Reset Blue, White, Purple, Yellow", 53: "Fill-in and Reset White, Purple, Cyan, Green", 54: "Strobe Red, Green, Blue, Yellow, Cyan, Purple, White", 55: "Strobe Green, Red, Blue, Yellow, Cyan, Purple, White", 56: "Strobe Blue, Green, Red, Yellow, Cyan, Purple, White", 57: "Strobe Yellow, Green, Blue, Red, Cyan, Purple, White", 58: "Strobe Cyan, Green, Blue, Yellow, Red, Purple, White", 59: "Strobe Purple, Green, Blue, Yellow, Cyan, Red, White", 60: "Strobe White, Green, Blue, Yellow, Cyan, Purple, Red", 61: "Strobe Red, Green", 62: "Strobe Green, Blue", 63: "Strobe Blue, Yellow", 64: "Strobe Yellow, Cyan", 65: "Strobe Cyan, Purple", 66: "Strobe Purple, White", 67: "Strobe Black, White", 68: "Flash Sync Seven-color", 69: "Flash Sync RGB", 70: "Flash Sync Red", 71: "Flash Sync Green", 72: "Flash Sync Yellow", 73: "Flash Sync Blue", 74: "Flash Sync Purple", 75: "Flash Sync Cyan", 76: "Jump Sync Seven-color", 77: "Jump Sync RGB", 78: "Jump Sync Red", 79: "Jump Sync Green", 80: "Jump Sync Yellow", 81: "Jump Sync Blue", 82: "Jump Sync Purple", 83: "Jump Sync Cyan", 84: "Red Wave, Breathe Sync, Flash, Jump", 85: "Green Wave, Breathe Sync, Flash, Jump", 86: "Yellow Wave, Breathe Sync, Flash, Jump", 87: "Blue Wave, Breathe Sync, Flash, Jump", 88: "Purple Wave, Breathe Sync, Flash, Jump", 89: "Cyan Wave, Breathe Sync, Flash, Jump", 90: "White Wave, Breathe Sync, Flash, Jump", 91: "Seven-color Wave, Breathe Sync", 92: "Seven-color Breathe Sync, Flash Sync", 93: "Seven-color Flash Sync, Jump Sync", 94: "Seven-color Wave, Breathe Sync, Flash Sync, Jump Sync", 95: "Overlap Red, Green, Blue", 96: "Overlap Red, Green, Blue, Cyan, Purple, White", 97: "Overlap Green, Blue, Cyan", 98: "Overlap Blue, Cyan, Purple", 99: "Overlap Cyan, Purple, White", 100: "Overlap White, Black, Red", } CHRISTMAS_ADDRESSABLE_EFFECT_NAME_ID = { v: k for k, v in CHRISTMAS_ADDRESSABLE_EFFECT_ID_NAME.items() } class PresetPattern: _instance = None warm_flash = EFFECT_MAP_LEGACY_CCT[EFFECT_WARM_FLASH] cool_flash = EFFECT_MAP_LEGACY_CCT[EFFECT_COOL_FLASH] warm_gradual = EFFECT_MAP_LEGACY_CCT[EFFECT_WARM_GRADUAL] cool_gradual = EFFECT_MAP_LEGACY_CCT[EFFECT_COOL_GRADUAL] seven_color_cross_fade = EFFECT_MAP[EFFECT_COLORLOOP] red_gradual_change = EFFECT_MAP[EFFECT_RED_FADE] green_gradual_change = EFFECT_MAP[EFFECT_GREEN_FADE] blue_gradual_change = EFFECT_MAP[EFFECT_BLUE_FADE] yellow_gradual_change = EFFECT_MAP[EFFECT_YELLOW_FADE] cyan_gradual_change = EFFECT_MAP[EFFECT_CYAN_FADE] purple_gradual_change = EFFECT_MAP[EFFECT_PURPLE_FADE] white_gradual_change = EFFECT_MAP[EFFECT_WHITE_FADE] red_green_cross_fade = EFFECT_MAP[EFFECT_RED_GREEN_CROSS_FADE] red_blue_cross_fade = EFFECT_MAP[EFFECT_RED_BLUE_CROSS_FADE] green_blue_cross_fade = EFFECT_MAP[EFFECT_GREEN_BLUE_CROSS_FADE] seven_color_strobe_flash = EFFECT_MAP[EFFECT_COLORSTROBE] red_strobe_flash = EFFECT_MAP[EFFECT_RED_STROBE] green_strobe_flash = EFFECT_MAP[EFFECT_GREEN_STROBE] blue_strobe_flash = EFFECT_MAP[EFFECT_BLUE_STROBE] yellow_strobe_flash = EFFECT_MAP[EFFECT_YELLOW_STROBE] cyan_strobe_flash = EFFECT_MAP[EFFECT_CYAN_STROBE] purple_strobe_flash = EFFECT_MAP[EFFECT_PURPLE_STROBE] white_strobe_flash = EFFECT_MAP[EFFECT_WHITE_STROBE] seven_color_jumping = EFFECT_MAP[EFFECT_COLORJUMP] cycle_rgb = EFFECT_MAP_DIMMABLE[EFFECT_CYCLE_RGB] cycle_seven_colors = EFFECT_MAP_DIMMABLE[EFFECT_CYCLE_SEVEN_COLORS] red_green_blue_cross_fade = EFFECT_MAP_DIMMABLE[EFFECT_RED_GREEN_BLUE_CROSS_FADE] def __init__(self) -> None: self._value_to_str: Dict[int, str] = { **EFFECT_ID_NAME_LEGACY_CCT, **{ v: k.replace("_", " ").title() for k, v in PresetPattern.__dict__.items() if type(v) is int }, } self._hex_str_valid_values = {f"0x{byte:02X}" for byte in self._value_to_str} @classmethod def instance(cls) -> "PresetPattern": """Get preset pattern instance.""" if cls._instance is None: cls._instance = cls() return cls._instance @staticmethod def valid(pattern: int) -> bool: instance = PresetPattern.instance() return pattern in instance._value_to_str @staticmethod def valid_or_raise(pattern: int) -> None: instance = PresetPattern.instance() if pattern not in instance._value_to_str: raise ValueError(f"Pattern must be one of {instance._hex_str_valid_values}") @staticmethod def valtostr(pattern: int) -> Optional[str]: instance = PresetPattern.instance() return instance._value_to_str.get(pattern) @staticmethod def str_to_val(effect: str) -> int: if effect in EFFECT_MAP_DIMMABLE: return EFFECT_MAP_DIMMABLE[effect] mapped_effect = effect.replace(" ", "_").lower() if hasattr(PresetPattern, mapped_effect): return cast(int, getattr(PresetPattern, mapped_effect)) raise ValueError(f"{effect} is not a known effect name.") Danielhiversen-flux_led-bfd1bbe/flux_led/protocol.py000077500000000000000000002533531447734565100231540ustar00rootroot00000000000000"""FluxLED Protocols.""" import colorsys import contextlib import datetime import logging from abc import abstractmethod from dataclasses import dataclass from enum import Enum from typing import Dict, List, NamedTuple, Optional, Tuple, Union from .const import ( COLOR_MODE_RGB, COLOR_MODE_RGBW, MUSIC_PIXELS_MAX, MUSIC_PIXELS_PER_SEGMENT_MAX, MUSIC_SEGMENTS_MAX, PIXELS_MAX, PIXELS_PER_SEGMENT_MAX, SEGMENTS_MAX, TRANSITION_GRADUAL, TRANSITION_JUMP, TRANSITION_STROBE, LevelWriteMode, MultiColorEffects, ) from .timer import LedTimer from .utils import utils, white_levels_to_scaled_color_temp class RemoteConfig(Enum): DISABLED = 0x01 OPEN = 0x02 PAIRED_ONLY = 0x03 class PowerRestoreState(Enum): ALWAYS_OFF = 0xFF ALWAYS_ON = 0x0F LAST_STATE = 0xF0 class MusicMode(Enum): STRIP = 0x26 LIGHT_SCREEN = 0x27 @dataclass class LEDENETAddressableDeviceConfiguration: pixels_per_segment: int # pixels per segment segments: Optional[int] # number of segments music_pixels_per_segment: Optional[int] # music pixels per segment music_segments: Optional[int] # number of music segments wirings: List[str] # available wirings in the current mode wiring: Optional[str] # RGB/BRG/GBR etc wiring_num: Optional[int] # RGB/BRG/GBR number num_to_wiring: Dict[int, str] wiring_to_num: Dict[str, int] ic_type: Optional[str] # WS2812B UCS.. etc ic_type_num: Optional[int] # WS2812B UCS.. number etc operating_mode: Optional[str] # RGB, RGBW @dataclass class PowerRestoreStates: channel1: Optional[PowerRestoreState] channel2: Optional[PowerRestoreState] channel3: Optional[PowerRestoreState] channel4: Optional[PowerRestoreState] _LOGGER = logging.getLogger(__name__) # Protocol names PROTOCOL_LEDENET_ORIGINAL = "LEDENET_ORIGINAL" PROTOCOL_LEDENET_ORIGINAL_RGBW = "LEDENET_ORIGINAL_RGBW" PROTOCOL_LEDENET_ORIGINAL_CCT = "LEDENET_ORIGINAL_CCT" PROTOCOL_LEDENET_9BYTE = "LEDENET" PROTOCOL_LEDENET_9BYTE_AUTO_ON = "LEDENET_AUTO_ON" PROTOCOL_LEDENET_9BYTE_DIMMABLE_EFFECTS = "LEDENET_DIMMABLE_EFFECTS" PROTOCOL_LEDENET_SOCKET = "LEDENET_SOCKET" PROTOCOL_LEDENET_8BYTE = "LEDENET_8BYTE" # Previously was called None PROTOCOL_LEDENET_8BYTE_AUTO_ON = "LEDENET_8BYTES_AUTO_ON" PROTOCOL_LEDENET_8BYTE_DIMMABLE_EFFECTS = "LEDENET_8BYTE_DIMMABLE_EFFECTS" PROTOCOL_LEDENET_ADDRESSABLE_A1 = "LEDENET_ADDRESSABLE_A1" PROTOCOL_LEDENET_ADDRESSABLE_A2 = "LEDENET_ADDRESSABLE_A2" PROTOCOL_LEDENET_ADDRESSABLE_A3 = "LEDENET_ADDRESSABLE_A3" PROTOCOL_LEDENET_CCT = "LEDENET_CCT" PROTOCOL_LEDENET_CCT_WRAPPED = "LEDENET_CCT_WRAPPED" PROTOCOL_LEDENET_ADDRESSABLE_CHRISTMAS = "LEDENET_CHRISTMAS" TRANSITION_BYTES = { TRANSITION_JUMP: 0x3B, TRANSITION_STROBE: 0x3C, TRANSITION_GRADUAL: 0x3A, } LEDNET_MUSIC_MODE_RESPONSE_LEN = 13 # 72 01 26 01 00 00 00 00 00 00 64 64 62 LEDENET_POWER_RESTORE_RESPONSE_LEN = 7 LEDENET_ORIGINAL_STATE_RESPONSE_LEN = 11 LEDENET_STATE_RESPONSE_LEN = 14 LEDENET_POWER_RESPONSE_LEN = 4 LEDENET_ADDRESSABLE_STATE_RESPONSE_LEN = 25 LEDENET_A1_DEVICE_CONFIG_RESPONSE_LEN = 12 LEDENET_DEVICE_CONFIG_RESPONSE_LEN = 11 LEDENET_REMOTE_CONFIG_RESPONSE_LEN = 14 # 2b 03 00 00 00 00 29 00 00 00 00 00 00 57 LEDENET_TIME_RESPONSE_LEN = 12 # 10 14 16 01 02 10 26 20 07 00 0f a9 LEDENET_TIMERS_8BYTE_RESPONSE_LEN = 88 LEDENET_TIMERS_9BYTE_RESPONSE_LEN = 94 LEDENET_TIMERS_SOCKET_RESPONSE_LEN = 100 MSG_ORIGINAL_POWER_STATE = "original_power_state" MSG_ORIGINAL_STATE = "original_state" MSG_POWER_RESTORE_STATE = "power_restore_state" MSG_POWER_STATE = "power_state" MSG_STATE = "state" MSG_TIME = "time" MSG_TIMERS = "timers" MSG_MUSIC_MODE_STATE = "music_mode_state" MSG_ADDRESSABLE_STATE = "addressable_state" MSG_DEVICE_CONFIG = "device_config" MSG_A1_DEVICE_CONFIG = "a1_device_config" MSG_REMOTE_CONFIG = "remote_config" OUTER_MESSAGE_FIRST_BYTE = 0xB0 MSG_UNIQUE_START = { (0x01, 0x11): MSG_TIME, (0xF0, 0x11): MSG_TIME, (0x0F, 0x11): MSG_TIME, (0x00, 0x11): MSG_TIME, (0x01, 0x22): MSG_TIMERS, (0xF0, 0x22): MSG_TIMERS, (0x0F, 0x22): MSG_TIMERS, (0x00, 0x22): MSG_TIMERS, (0x01, 0x71): MSG_POWER_STATE, (0xF0, 0x71): MSG_POWER_STATE, (0x0F, 0x71): MSG_POWER_STATE, (0x00, 0x71): MSG_POWER_STATE, (0x01, 0x32): MSG_POWER_RESTORE_STATE, (0xF0, 0x32): MSG_POWER_RESTORE_STATE, (0x0F, 0x32): MSG_POWER_RESTORE_STATE, (0x00, 0x32): MSG_POWER_RESTORE_STATE, (0x78,): MSG_ORIGINAL_POWER_STATE, (0x66,): MSG_ORIGINAL_STATE, (0x81,): MSG_STATE, (0x01, 0x63): MSG_DEVICE_CONFIG, (0x00, 0x63): MSG_DEVICE_CONFIG, (0xF0, 0x63): MSG_DEVICE_CONFIG, (0x0F, 0x63): MSG_DEVICE_CONFIG, (0x63,): MSG_A1_DEVICE_CONFIG, (0x72,): MSG_MUSIC_MODE_STATE, (0x2B,): MSG_REMOTE_CONFIG, } MSG_LENGTHS = { MSG_TIME: LEDENET_TIME_RESPONSE_LEN, MSG_REMOTE_CONFIG: LEDENET_REMOTE_CONFIG_RESPONSE_LEN, MSG_MUSIC_MODE_STATE: LEDNET_MUSIC_MODE_RESPONSE_LEN, MSG_POWER_STATE: LEDENET_POWER_RESPONSE_LEN, MSG_POWER_RESTORE_STATE: LEDENET_POWER_RESTORE_RESPONSE_LEN, MSG_ORIGINAL_POWER_STATE: LEDENET_POWER_RESPONSE_LEN, MSG_ORIGINAL_STATE: LEDENET_ORIGINAL_STATE_RESPONSE_LEN, MSG_STATE: LEDENET_STATE_RESPONSE_LEN, MSG_ADDRESSABLE_STATE: LEDENET_ADDRESSABLE_STATE_RESPONSE_LEN, MSG_DEVICE_CONFIG: LEDENET_DEVICE_CONFIG_RESPONSE_LEN, MSG_A1_DEVICE_CONFIG: LEDENET_A1_DEVICE_CONFIG_RESPONSE_LEN, } OUTER_MESSAGE_WRAPPER_FIRST_BYTES = [OUTER_MESSAGE_FIRST_BYTE, 0xB1, 0xB2, 0xB3, 0x00] OUTER_MESSAGE_WRAPPER = [*OUTER_MESSAGE_WRAPPER_FIRST_BYTES, 0x01, 0x01] OUTER_MESSAGE_WRAPPER_START_LEN = 10 CHECKSUM_LEN = 1 POWER_RESTORE_BYTES_TO_POWER_RESTORE = { restore_state.value: restore_state for restore_state in PowerRestoreState } REMOTE_CONFIG_BYTES_TO_REMOTE_CONFIG = { remote_config.value: remote_config for remote_config in RemoteConfig } def _message_type_from_start_of_msg(data: bytes) -> Optional[str]: if len(data) > 1: return MSG_UNIQUE_START.get( (data[0], data[1]), MSG_UNIQUE_START.get((data[0],)) ) return MSG_UNIQUE_START.get((data[0],)) if len(data) else None class LEDENETOriginalRawState(NamedTuple): head: int model_num: int power_state: int preset_pattern: int mode: int speed: int red: int green: int blue: int warm_white: int check_sum: int cool_white: int # typical response: # pos 0 1 2 3 4 5 6 7 8 9 10 # 66 01 24 39 21 0a ff 00 00 01 99 # | | | | | | | | | | | # | | | | | | | | | | checksum # | | | | | | | | | warmwhite # | | | | | | | | blue # | | | | | | | green # | | | | | | red # | | | | | speed: 0f = highest f0 is lowest # | | | | # | | | preset pattern # | | off(24)/on(23) # | model_num (type) # msg head # class LEDENETRawState(NamedTuple): head: int model_num: int power_state: int preset_pattern: int mode: int speed: int red: int green: int blue: int warm_white: int version_number: int cool_white: int color_mode: int check_sum: int # response from a 5-channel LEDENET controller: # pos 0 1 2 3 4 5 6 7 8 9 10 11 12 13 # 81 25 23 61 21 06 38 05 06 f9 01 00 0f 9d # | | | | | | | | | | | | | | # | | | | | | | | | | | | | checksum # | | | | | | | | | | | | color mode (f0 colors were set, 0f whites, 00 all were set) # | | | | | | | | | | | cool-white 0x00 to 0xFF # | | | | | | | | | | version number # | | | | | | | | | warmwhite 0x00 to 0xFF # | | | | | | | | blue 0x00 to 0xFF # | | | | | | | green 0x00 to 0xFF # | | | | | | red 0x00 to 0xFF # | | | | | speed: 0x01 = highest 0x1f is lowest # | | | | Mode WW(01), WW+CW(02), RGB(03), RGBW(04), RGBWW(05) # | | | preset pattern # | | off(24)/on(23) # | model_num (type) # msg head # RGB_NUM_TO_WIRING = {1: "RGB", 2: "GRB", 3: "BRG"} RGB_WIRING_TO_NUM = {v: k for k, v in RGB_NUM_TO_WIRING.items()} RGBW_NUM_TO_WIRING = {1: "RGBW", 2: "GRBW", 3: "BRGW"} RGBW_WIRING_TO_NUM = {v: k for k, v in RGBW_NUM_TO_WIRING.items()} RGBW_NUM_TO_MODE = {4: "RGB&W", 6: "RGB/W"} RGBW_MODE_TO_NUM = {v: k for k, v in RGBW_NUM_TO_MODE.items()} RGBWW_NUM_TO_WIRING = { 1: "RGBCW", 2: "GRBCW", 3: "BRGCW", 4: "RGBWC", 5: "GRBWC", 6: "BRGWC", 7: "WRGBC", 8: "WGRBC", 9: "WBRGC", 10: "CRGBW", 11: "CBRBW", 12: "CBRGW", 13: "WCRGB", 14: "WCGRB", 15: "WCBRG", } RGBWW_WIRING_TO_NUM = {v: k for k, v in RGBWW_NUM_TO_WIRING.items()} RGBWW_NUM_TO_MODE = {5: "RGB&CCT", 7: "RGB/CCT"} RGBWW_MODE_TO_NUM = {v: k for k, v in RGBWW_NUM_TO_MODE.items()} ADDRESSABLE_RGB_NUM_TO_WIRING = { 0: "RGB", 1: "RBG", 2: "GRB", 3: "GBR", 4: "BRG", 5: "BGR", } ADDRESSABLE_RGB_WIRING_TO_NUM = {v: k for k, v in ADDRESSABLE_RGB_NUM_TO_WIRING.items()} ADDRESSABLE_RGBW_NUM_TO_WIRING = { 0: "RGBW", 1: "RBGW", 2: "GRBW", 3: "GBRW", 4: "BRGW", 5: "BGRW", 6: "WRGB", 7: "WRBG", 8: "WGRB", 9: "WGBR", 10: "WBRG", 11: "WBGR", } ADDRESSABLE_RGBW_WIRING_TO_NUM = { v: k for k, v in ADDRESSABLE_RGBW_NUM_TO_WIRING.items() } A1_NUM_TO_PROTOCOL = { 1: "UCS1903", 2: "SM16703", 3: "WS2811", 4: "WS2812B", 5: "SK6812", 6: "INK1003", 7: "WS2801", 8: "LB1914", } A1_PROTOCOL_TO_NUM = {v: k for k, v in A1_NUM_TO_PROTOCOL.items()} A1_NUM_TO_OPERATING_MODE = { 1: COLOR_MODE_RGB, 2: COLOR_MODE_RGB, 3: COLOR_MODE_RGB, 4: COLOR_MODE_RGB, 5: COLOR_MODE_RGB, 6: COLOR_MODE_RGB, 7: COLOR_MODE_RGB, 8: COLOR_MODE_RGB, } A1_OPERATING_MODE_TO_NUM = {v: k for k, v in A1_NUM_TO_OPERATING_MODE.items()} A2_NUM_TO_PROTOCOL = { 1: "UCS1903", 2: "SM16703", 3: "WS2811", 4: "WS2811B", 5: "SK6812", 6: "INK1003", 7: "WS2801", 8: "WS2815", 9: "APA102", 10: "TM1914", 11: "UCS2904B", } A2_PROTOCOL_TO_NUM = {v: k for k, v in A2_NUM_TO_PROTOCOL.items()} A2_NUM_TO_OPERATING_MODE = { 1: COLOR_MODE_RGB, 2: COLOR_MODE_RGB, 3: COLOR_MODE_RGB, 4: COLOR_MODE_RGB, 5: COLOR_MODE_RGB, 6: COLOR_MODE_RGB, 7: COLOR_MODE_RGB, 8: COLOR_MODE_RGB, 9: COLOR_MODE_RGB, 10: COLOR_MODE_RGB, 11: COLOR_MODE_RGB, } A2_OPERATING_MODE_TO_NUM = {v: k for k, v in A2_NUM_TO_OPERATING_MODE.items()} NEW_ADDRESSABLE_NUM_TO_PROTOCOL = { 1: "WS2812B", 2: "SM16703", 3: "SM16704", 4: "WS2811", 5: "UCS1903", 6: "SK6812", 7: "SK6812RGBW", 8: "INK1003", 9: "UCS2904B", } NEW_ADDRESSABLE_PROTOCOL_TO_NUM = { v: k for k, v in NEW_ADDRESSABLE_NUM_TO_PROTOCOL.items() } NEW_ADDRESSABLE_NUM_TO_OPERATING_MODE = { 1: COLOR_MODE_RGB, 2: COLOR_MODE_RGB, 3: COLOR_MODE_RGB, 4: COLOR_MODE_RGB, 5: COLOR_MODE_RGB, 6: COLOR_MODE_RGB, 7: COLOR_MODE_RGBW, 8: COLOR_MODE_RGB, 9: COLOR_MODE_RGB, } NEW_ADDRESSABLE_OPERATING_MODE_TO_NUM = { v: k for k, v in NEW_ADDRESSABLE_NUM_TO_OPERATING_MODE.items() } class ProtocolBase: """The base protocol.""" power_state_response_length = MSG_LENGTHS[MSG_POWER_STATE] def __init__(self) -> None: self._counter = -1 super().__init__() @property def requires_turn_on(self) -> bool: """If True the device must be turned on before setting level/patterns/modes.""" return True @property def power_push_updates(self) -> bool: """If True the protocol pushes power state updates when controlled via ir/rf/app.""" return False @property def state_push_updates(self) -> bool: """If True the protocol pushes state updates when controlled via ir/rf/app.""" return False @property def zones(self) -> bool: """If the protocol supports zones.""" return False def _increment_counter(self) -> int: """Increment the counter byte.""" self._counter += 1 if self._counter == 255: self._counter = 0 return self._counter def is_valid_power_restore_state_response(self, msg: bytes) -> bool: """Check if a power state response is valid.""" return ( _message_type_from_start_of_msg(msg) == MSG_POWER_RESTORE_STATE and len(msg) == LEDENET_POWER_RESTORE_RESPONSE_LEN and self.is_checksum_correct(msg) ) def is_valid_outer_message(self, data: bytes) -> bool: """Check if a message is a valid outer message.""" if not data.startswith(bytearray(OUTER_MESSAGE_WRAPPER_FIRST_BYTES)): return False return self.is_checksum_correct(data) def extract_inner_message(self, msg: bytes) -> bytes: """Extract the inner message from a wrapped message.""" return msg[10:-1] def is_valid_device_config_response(self, data: bytes) -> bool: """Check if a message is a valid ic state response.""" return False def expected_response_length(self, data: bytes) -> int: """Return the number of bytes expected in the response. If the response is unknown, we assume the response is a complete message since we have no way of knowing otherwise. """ if data[0] == OUTER_MESSAGE_FIRST_BYTE: # This is a wrapper message if len(data) < OUTER_MESSAGE_WRAPPER_START_LEN: return OUTER_MESSAGE_WRAPPER_START_LEN inner_msg_len = (data[8] << 8) + data[9] return ( OUTER_MESSAGE_WRAPPER_START_LEN # Includes the two bytes that are the size of the inner message + inner_msg_len # The inner message itself (with checksum) + CHECKSUM_LEN # The checksum of the full message ) msg_type = _message_type_from_start_of_msg(data) if msg_type is None: return len(data) if msg_type == MSG_TIMERS: return self.timer_response_len return MSG_LENGTHS[msg_type] @abstractmethod def construct_state_query(self) -> bytearray: """The bytes to send for a query request.""" @abstractmethod def is_valid_state_response(self, raw_state: bytes) -> bool: """Check if a state response is valid.""" def is_checksum_correct(self, msg: bytes) -> bool: """Check a checksum of a message.""" expected_sum = sum(msg[0:-1]) & 0xFF if expected_sum != msg[-1]: _LOGGER.warning( "Checksum mismatch: Expected %s, got %s", expected_sum, msg[-1] ) return False return True @abstractmethod def is_valid_power_state_response(self, msg: bytes) -> bool: """Check if a power state response is valid.""" @property def on_byte(self) -> int: """The on byte.""" return 0x23 @property def off_byte(self) -> int: """The off byte.""" return 0x24 @property def dimmable_effects(self) -> bool: """Protocol supports dimmable effects.""" return False @abstractmethod def construct_state_change(self, turn_on: int) -> bytearray: """The bytes to send for a state change request.""" def construct_power_restore_state_query(self) -> bytearray: """The bytes to send for a query power restore state.""" return self.construct_message(bytearray([0x32, 0x3A, 0x3B, 0x0F])) def construct_get_time(self) -> bytearray: """Construct a get time command.""" return self.construct_message(bytearray([0x11, 0x1A, 0x1B, 0x0F])) def is_valid_get_time_response(self, msg: bytes) -> bool: """Check if the response is a valid time response.""" return ( _message_type_from_start_of_msg(msg) == MSG_TIME and len(msg) == LEDENET_TIME_RESPONSE_LEN and self.is_checksum_correct(msg) ) def parse_get_time(self, rx: bytes) -> Optional[datetime.datetime]: """Parse a get time command.""" if self.is_valid_get_time_response(rx): with contextlib.suppress(Exception): return datetime.datetime( rx[3] + 2000, rx[4], rx[5], rx[6], rx[7], rx[8] ) return None def construct_set_time(self, time: Optional[datetime.datetime]) -> bytearray: """Construct a set time command.""" dt = time or datetime.datetime.now() return self.construct_message( bytearray( [ 0x10, 0x14, dt.year - 2000, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.isoweekday(), # day of week 0x00, 0x0F, ] ) ) def construct_get_timers(self) -> bytearray: """The bytes to get timers.""" return self.construct_message(bytearray([0x22, 0x2A, 0x2B, 0x0F])) @property def timer_response_len(self) -> int: """Return the time response len.""" return LEDENET_TIMERS_8BYTE_RESPONSE_LEN @property def timer_len(self) -> int: """Return a single timer len.""" return 14 @property def timer_count(self) -> int: """Return the number of timers.""" return 6 def is_valid_timers_response(self, msg: bytes) -> bool: """Check if the response is a valid timers response.""" return ( _message_type_from_start_of_msg(msg) == MSG_TIMERS and len(msg) == self.timer_response_len and self.is_checksum_correct(msg) ) def parse_get_timers(self, msg: bytes) -> List[LedTimer]: """Parse get timers.""" if not self.is_valid_timers_response(msg): raise ValueError(f"Timers response not valid: {msg!r}") start = 2 timer_list = [] timer_bytes_len = self.timer_len # pass in the timer_len-byte timer structs for _ in range(self.timer_count): timer_bytes = msg[start:][:timer_bytes_len] timer = LedTimer(timer_bytes) timer_list.append(timer) start += timer_bytes_len return timer_list def construct_set_timers(self, timer_list: List[LedTimer]) -> bytearray: """Construct a set timers message.""" # remove inactive or expired timers from list for t in timer_list: t.length = self.timer_len if not t.isActive() or t.isExpired(): timer_list.remove(t) # truncate if more than 6 if len(timer_list) > self.timer_count: _LOGGER.warning("too many timers, truncating list") del timer_list[self.timer_count :] # pad list to 6 with inactive timers if len(timer_list) != self.timer_count: for i in range(self.timer_count - len(timer_list)): timer_list.append(LedTimer(length=self.timer_len)) msg = bytearray([0x21]) for t in timer_list: msg.extend(t.toBytes()) msg.extend(bytearray([0x00, 0xF0])) return self.construct_message(msg) def construct_power_restore_state_change( self, restore_state: PowerRestoreStates ) -> bytearray: """The bytes to send for a power restore state change. Set power on state to keep last state 31f0f0f0f0f0e1 Set power on state to always on 310ff0f0f0f000 Set power on state to always off 31fff0f0f0f0f0 """ return self.construct_message( bytearray( [ 0x31, restore_state.channel1.value if restore_state.channel1 else 0x00, restore_state.channel2.value if restore_state.channel2 else 0x00, restore_state.channel3.value if restore_state.channel3 else 0x00, restore_state.channel4.value if restore_state.channel4 else 0x00, 0xF0, ] ) ) @abstractmethod def construct_music_mode( self, sensitivity: int, brightness: int, mode: Optional[int], effect: Optional[int], foreground_color: Optional[Tuple[int, int, int]] = None, background_color: Optional[Tuple[int, int, int]] = None, ) -> List[bytearray]: """The bytes to send to set music mode.""" @abstractmethod def construct_levels_change( self, persist: int, red: Optional[int], green: Optional[int], blue: Optional[int], warm_white: Optional[int], cool_white: Optional[int], write_mode: LevelWriteMode, ) -> List[bytearray]: """The bytes to send for a level change request.""" @abstractmethod def construct_preset_pattern( self, pattern: int, speed: int, brightness: int ) -> bytearray: """The bytes to send for a preset pattern.""" def construct_custom_effect( self, rgb_list: List[Tuple[int, int, int]], speed: int, transition_type: str ) -> bytearray: """The bytes to send for a custom effect.""" msg = bytearray() first_color = True for rgb in rgb_list: if first_color: lead_byte = 0x51 first_color = False else: lead_byte = 0 r, g, b = rgb msg.extend(bytearray([lead_byte, r, g, b])) # pad out empty slots if len(rgb_list) != 16: for i in range(16 - len(rgb_list)): msg.extend(bytearray([0, 1, 2, 3])) msg.append(0x00) msg.append(utils.speedToDelay(speed)) msg.append( TRANSITION_BYTES.get(transition_type, TRANSITION_BYTES[TRANSITION_GRADUAL]) ) # default to "gradual" msg.append(0xFF) msg.append(0x0F) return self.construct_message(msg) @property @abstractmethod def name(self) -> str: """The name of the protocol.""" @property @abstractmethod def state_response_length(self) -> int: """The length of the query response.""" @abstractmethod def construct_message(self, raw_bytes: bytearray) -> bytearray: """Original protocol uses no checksum.""" def construct_wrapped_message( self, msg: bytearray, inner_pre_constructed: bool = False ) -> bytearray: """Construct a wrapped message.""" if inner_pre_constructed: # msg has already been inner_pre_constructed inner_msg = msg else: inner_msg = self.construct_message(msg) inner_msg_len = len(inner_msg) return self.construct_message( bytearray( [ *OUTER_MESSAGE_WRAPPER, self._increment_counter(), inner_msg_len >> 8, inner_msg_len & 0xFF, *inner_msg, ] ) ) @abstractmethod def named_raw_state( self, raw_state: bytes ) -> Union[LEDENETOriginalRawState, LEDENETRawState]: """Convert raw_state to a namedtuple.""" @abstractmethod def is_valid_remote_config_response(self, msg: bytes) -> bool: """Check if a remote config response is valid.""" return _message_type_from_start_of_msg( msg ) == MSG_REMOTE_CONFIG and self.is_checksum_correct(msg) def construct_query_remote_config(self) -> bytearray: """Construct a remote config query""" return self.construct_wrapped_message(bytearray([0x2B, 0x2C, 0x2D])) def construct_remote_config(self, remote_config: RemoteConfig) -> bytearray: """Construct an remote config.""" # 2a 02 ff ff ff ff ff 00 00 00 00 00 00 00 0f return self.construct_wrapped_message( bytearray( [ 0x2A, remote_config.value, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0F, ] ) ) def construct_unpair_remotes(self) -> bytearray: """Construct an unpair remotes command.""" return self.construct_wrapped_message( bytearray( [ 0x2A, 0xFF, 0xFF, 0x01, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, ] ) ) class ProtocolLEDENETOriginal(ProtocolBase): """The original LEDENET protocol with no checksums.""" @property def name(self) -> str: """The name of the protocol.""" return PROTOCOL_LEDENET_ORIGINAL @property def state_response_length(self) -> int: """The length of the query response.""" return LEDENET_ORIGINAL_STATE_RESPONSE_LEN def is_valid_power_state_response(self, msg: bytes) -> bool: """Check if a power state response is valid.""" return len(msg) == self.power_state_response_length and msg[0] == 0x78 def is_valid_state_response(self, raw_state: bytes) -> bool: """Check if a state response is valid.""" return len(raw_state) == self.state_response_length and raw_state[0] == 0x66 def construct_preset_pattern( self, pattern: int, speed: int, brightness: int ) -> bytearray: """The bytes to send for a preset pattern.""" delay = utils.speedToDelay(speed) return self.construct_message(bytearray([0xBB, pattern, delay, 0x44])) def construct_state_query(self) -> bytearray: """The bytes to send for a query request.""" return self.construct_message(bytearray([0xEF, 0x01, 0x77])) def construct_state_change(self, turn_on: int) -> bytearray: """The bytes to send for a state change request.""" return self.construct_message( bytearray([0xCC, self.on_byte if turn_on else self.off_byte, 0x33]) ) def construct_levels_change( self, persist: int, red: Optional[int], green: Optional[int], blue: Optional[int], warm_white: Optional[int], cool_white: Optional[int], write_mode: LevelWriteMode, ) -> List[bytearray]: """The bytes to send for a level change request.""" # sample message for original LEDENET protocol (w/o checksum at end) # 0 1 2 3 4 # 56 90 fa 77 aa # | | | | | # | | | | terminator # | | | blue # | | green # | red # head return [ self.construct_message( bytearray([0x56, red or 0x00, green or 0x00, blue or 0x00, 0xAA]) ) ] def construct_message(self, raw_bytes: bytearray) -> bytearray: """Original protocol uses no checksum.""" return raw_bytes def named_raw_state(self, raw_state: bytes) -> LEDENETOriginalRawState: """Convert raw_state to a namedtuple.""" raw_bytearray = bytearray([*raw_state, 0]) return LEDENETOriginalRawState(*raw_bytearray) class ProtocolLEDENETOriginalRGBW(ProtocolLEDENETOriginal): @property def name(self) -> str: """The name of the protocol.""" return PROTOCOL_LEDENET_ORIGINAL_RGBW def construct_levels_change( self, persist: int, red: Optional[int], green: Optional[int], blue: Optional[int], warm_white: Optional[int], cool_white: Optional[int], write_mode: LevelWriteMode, ) -> List[bytearray]: """The bytes to send for a level change request.""" # sample message for original LEDENET RGBW protocol (w/o checksum at end) return [ self.construct_message( bytearray( [ 0x56, red or 0x00, green or 0x00, blue or 0x00, warm_white or 0x00, write_mode.value, 0xAA, ] ) ) ] class ProtocolLEDENETOriginalCCT(ProtocolLEDENETOriginal): @property def name(self) -> str: """The name of the protocol.""" return PROTOCOL_LEDENET_ORIGINAL_CCT def construct_levels_change( self, persist: int, red: Optional[int], green: Optional[int], blue: Optional[int], warm_white: Optional[int], cool_white: Optional[int], write_mode: LevelWriteMode, ) -> List[bytearray]: """The bytes to send for a level change request.""" # sample message for original LEDENET protocol (w/o checksum at end) # 0 1 2 3 4 # 56 90 fa 77 aa # | | | | | # | | | | terminator # | | | blue # | | green # | red # head return [ self.construct_message(bytearray([0x56, red or 0x00, green or 0x00, 0xAA])) ] class ProtocolLEDENET8Byte(ProtocolBase): """The newer LEDENET protocol with checksums that uses 8 bytes to set state.""" @property def name(self) -> str: """The name of the protocol.""" return PROTOCOL_LEDENET_8BYTE @property def state_response_length(self) -> int: """The length of the query response.""" return LEDENET_STATE_RESPONSE_LEN def is_valid_power_state_response(self, msg: bytes) -> bool: """Check if a power state response is valid.""" if ( len(msg) != self.power_state_response_length or not self._is_start_of_power_state_response(msg) or msg[1] != 0x71 or msg[2] not in (self.on_byte, self.off_byte) ): return False return True # checksum does not always match def _is_start_of_power_state_response(self, data: bytes) -> bool: """Check if a message is the start of a state response.""" return _message_type_from_start_of_msg(data) == MSG_POWER_STATE def is_valid_state_response(self, raw_state: bytes) -> bool: """Check if a state response is valid.""" if len(raw_state) != self.state_response_length: return False if not raw_state[0] == 0x81: return False return self.is_checksum_correct(raw_state) def construct_state_change(self, turn_on: int) -> bytearray: """The bytes to send for a state change request. Alternate messages Off 3b 24 00 00 00 00 00 00 00 32 00 00 91 On 3b 23 00 00 00 00 00 00 00 32 00 00 90 """ return self.construct_message( bytearray([0x71, self.on_byte if turn_on else self.off_byte, 0x0F]) ) def construct_preset_pattern( self, pattern: int, speed: int, brightness: int ) -> bytearray: """The bytes to send for a preset pattern.""" delay = utils.speedToDelay(speed) return self.construct_message(bytearray([0x61, pattern, delay, 0x0F])) def construct_levels_change( self, persist: int, red: Optional[int], green: Optional[int], blue: Optional[int], warm_white: Optional[int], cool_white: Optional[int], write_mode: LevelWriteMode, ) -> List[bytearray]: """The bytes to send for a level change request.""" # sample message for 8-byte protocols (w/ checksum at end) # 0 1 2 3 4 5 6 # 31 90 fa 77 00 00 0f # | | | | | | | # | | | | | | terminator # | | | | | write mask / white2 (see below) # | | | | white # | | | blue # | | green # | red # persistence (31 for true / 41 for false) # # byte 5 can have different values depending on the type # of device: # For devices that support 2 types of white value (warm and cold # white) this value is the cold white value. These use the LEDENET # protocol. If a second value is not given, reuse the first white value. # # For devices that cannot set both rbg and white values at the same time # (including devices that only support white) this value # specifies if this command is to set white value (0f) or the rgb # value (f0). # # For all other rgb and rgbw devices, the value is 00 return [ self.construct_message( bytearray( [ 0x31 if persist else 0x41, red or 0x00, green or 0x00, blue or 0x00, warm_white or 0x00, write_mode.value, 0x0F, ] ) ) ] def construct_message(self, raw_bytes: bytearray) -> bytearray: """Calculate checksum of byte array and add to end.""" csum = sum(raw_bytes) & 0xFF raw_bytes.append(csum) return raw_bytes def construct_state_query(self) -> bytearray: """The bytes to send for a query request.""" return self.construct_message(bytearray([0x81, 0x8A, 0x8B])) def named_raw_state(self, raw_state: bytes) -> LEDENETRawState: """Convert raw_state to a namedtuple.""" return LEDENETRawState(*raw_state) def construct_music_mode( self, sensitivity: int, brightness: int, mode: Optional[int], effect: Optional[int], foreground_color: Optional[Tuple[int, int, int]] = None, background_color: Optional[Tuple[int, int, int]] = None, ) -> List[bytearray]: """The bytes to send for music mode. Known messages 73 01 4d 0f d0 ^^ Likely sensitivity from 0-100 (0x64) 73 01 64 0f e7 73 01 4a 0f cd 73 01 4b 0f ce 73 01 00 0f 83 73 01 1b 0f 9e 73 01 05 0f 88 73 01 02 0f 85 73 01 06 0f 89 73 01 05 0f 88 73 01 10 0f 93 73 01 4d 0f d0 73 01 64 0f e7 Pause music mode 73 00 59 0f db ^^ On/off byte Mic 37 00 00 37 Fade In ^^ Mic effect 37 01 00 38 Gradual 37 02 00 39 Jump 37 03 00 3a Strobe """ # Valid modes for old protocol # 0x01 - Gradual return [self.construct_message(bytearray([0x73, 0x01, sensitivity, 0x0F]))] def construct_device_config( self, operating_mode: Optional[int], wiring: Optional[int], ic_type: Optional[int], # ic type pixels_per_segment: Optional[int], # pixels per segment segments: Optional[int], # number of segments music_pixels_per_segment: Optional[int], # music pixels per segment music_segments: Optional[int], # number of music segments ) -> bytearray: """The bytes to send to change device config. RGBW 0x06 62 06 02 0f 79 - RGB/W GRB W 62 04 02 0f 77 - RGB&W GRB W 62 04 01 0f 77 - RGB&W RGB W 62 04 03 0f 77 - RGB&W BRG W RGBCW 0x07 62 05 0f 0f 85 - RGB&CCT / WCBRG 62 07 0f 0f 87 - RGB/CCT / WCBRG 62 07 01 0f 79 - RGB/CCT / RGBCW 62 07 02 0f 7a - RGB/CCT / GRBCW 62 07 0c 0f 84 - RGB/CCT / CBRGW RGB 0x33 / 0x08 62 00 01 0f 73 - RGB 62 00 02 0f 73 - GRB 62 00 03 0f 73 - BRG 0x25 62 01 0f 72 - DIM 62 02 0f 73 - CCT 62 03 0f 74 - RGB 62 04 0f 74 - RGB&W 62 05 0f 74 - RGB&CCT """ msg = bytearray([0x62, operating_mode or 0x00]) if wiring: msg.append(wiring) msg.append(0x0F) return self.construct_message(msg) class ProtocolLEDENET8ByteAutoOn(ProtocolLEDENET8Byte): """Protocol that uses 8 bytes, and turns on by changing levels or effects.""" @property def requires_turn_on(self) -> bool: """If True the device must be turned on before setting level/patterns/modes.""" return False @property def name(self) -> str: """The name of the protocol.""" return PROTOCOL_LEDENET_8BYTE_AUTO_ON # This protocol also supports Candle mode but its not currently implemented here class ProtocolLEDENET8ByteDimmableEffects(ProtocolLEDENET8ByteAutoOn): """Protocol that uses 8 bytes, and supports dimmable effects and auto on by changing levels or effects.""" @property def dimmable_effects(self) -> bool: """Protocol supports dimmable effects.""" return True @property def power_push_updates(self) -> bool: """If True the protocol pushes power state updates when controlled via ir/rf/app.""" return True @property def state_push_updates(self) -> bool: """If True the protocol pushes state updates when controlled via ir/rf/app.""" return True @property def name(self) -> str: """The name of the protocol.""" return PROTOCOL_LEDENET_8BYTE_DIMMABLE_EFFECTS def construct_preset_pattern( self, pattern: int, speed: int, brightness: int ) -> bytearray: """The bytes to send for a preset pattern.""" delay = utils.speedToDelay(speed) return self.construct_message(bytearray([0x38, pattern, delay, brightness])) def construct_music_mode( self, sensitivity: int, brightness: int, mode: Optional[int], effect: Optional[int], foreground_color: Optional[Tuple[int, int, int]] = None, background_color: Optional[Tuple[int, int, int]] = None, ) -> List[bytearray]: """The bytes to send for music mode. Known messages 73 01 4d 0f d0 ^^ Likely sensitivity from 0-100 (0x64) 73 01 64 0f e7 73 01 4a 0f cd 73 01 4b 0f ce 73 01 00 0f 83 73 01 1b 0f 9e 73 01 05 0f 88 73 01 02 0f 85 73 01 06 0f 89 73 01 05 0f 88 73 01 10 0f 93 73 01 4d 0f d0 73 01 64 0f e7 Pause music mode 73 00 59 0f db ^^ On/off byte Mic 37 00 00 37 Fade In ^^ Mic effect 37 01 00 38 Gradual 37 02 00 39 Jump 37 03 00 3a Strobe """ # Valid effect # 0x00 - Fade In # 0x01 - Gradual # 0x02 - Jump # 0x03 - Strobe if effect and not (0x00 <= effect <= 0x03): raise ValueError( "Mode must be one of (0x00 - Fade In, 0x01 - Gradual, 0x02 - Jump, 0x03 - Strobe)" ) return [ self.construct_message(bytearray([0x73, 0x01, sensitivity, 0x0F])), self.construct_message(bytearray([0x37, effect or 0x00, 0x00])), ] class ProtocolLEDENET9Byte(ProtocolLEDENET8Byte): """The newer LEDENET protocol with checksums that uses 9 bytes to set state.""" @property def name(self) -> str: """The name of the protocol.""" return PROTOCOL_LEDENET_9BYTE @property def timer_response_len(self) -> int: """Return the time response len.""" return LEDENET_TIMERS_9BYTE_RESPONSE_LEN @property def timer_len(self) -> int: """Return a single timer len.""" return 15 def construct_levels_change( self, persist: int, red: Optional[int], green: Optional[int], blue: Optional[int], warm_white: Optional[int], cool_white: Optional[int], write_mode: LevelWriteMode, ) -> List[bytearray]: """The bytes to send for a level change request.""" # sample message for 9-byte LEDENET protocol (w/ checksum at end) # 0 1 2 3 4 5 6 7 # 31 bc c1 ff 00 00 f0 0f # | | | | | | | | # | | | | | | | terminator # | | | | | | write mode (f0 colors, 0f whites, 00 colors & whites) # | | | | | cold white # | | | | warm white # | | | blue # | | green # | red # persistence (31 for true / 41 for false) # return [ self.construct_message( bytearray( [ 0x31 if persist else 0x41, red or 0x00, green or 0x00, blue or 0x00, warm_white or 0x00, cool_white or 0x00, write_mode.value, 0x0F, ] ) ) ] class ProtocolLEDENET9ByteAutoOn(ProtocolLEDENET9Byte): """Protocol that uses 9 bytes, and turns on by changing levels or effects.""" @property def requires_turn_on(self) -> bool: """If True the device must be turned on before setting level/patterns/modes.""" return False @property def name(self) -> str: """The name of the protocol.""" return PROTOCOL_LEDENET_9BYTE_AUTO_ON # This protocol also supports Candle mode but its not currently implemented here class ProtocolLEDENET9ByteDimmableEffects(ProtocolLEDENET9ByteAutoOn): """The newer LEDENET protocol with checksums that uses 9 bytes to set state.""" @property def dimmable_effects(self) -> bool: """Protocol supports dimmable effects.""" return True @property def power_push_updates(self) -> bool: """If True the protocol pushes power state updates when controlled via ir/rf/app.""" return True @property def state_push_updates(self) -> bool: """If True the protocol pushes state updates when controlled via ir/rf/app.""" return True @property def name(self) -> str: """The name of the protocol.""" return PROTOCOL_LEDENET_9BYTE_DIMMABLE_EFFECTS def construct_preset_pattern( self, pattern: int, speed: int, brightness: int ) -> bytearray: """The bytes to send for a preset pattern.""" delay = utils.speedToDelay(speed) return self.construct_message(bytearray([0x38, pattern, delay, brightness])) class ProtocolLEDENETAddressableBase(ProtocolLEDENET9Byte): """Base class for addressable protocols.""" @property def timer_response_len(self) -> int: """Return the time response len.""" return LEDENET_TIMERS_8BYTE_RESPONSE_LEN @property def timer_len(self) -> int: """Return a single timer len.""" return 14 class ProtocolLEDENETAddressableA1(ProtocolLEDENETAddressableBase): def construct_request_strip_setting(self) -> bytearray: return bytearray([0x63, 0x12, 0x21, 0x36]) @property def name(self) -> str: """The name of the protocol.""" return PROTOCOL_LEDENET_ADDRESSABLE_A1 def is_valid_device_config_response(self, data: bytes) -> bool: """Check if a message is a valid ic state response.""" return ( len(data) == LEDENET_A1_DEVICE_CONFIG_RESPONSE_LEN and _message_type_from_start_of_msg(data) == MSG_A1_DEVICE_CONFIG and self.is_checksum_correct(data) ) @property def power_push_updates(self) -> bool: """If True the protocol pushes power state updates when controlled via ir/rf/app.""" return True @property def dimmable_effects(self) -> bool: """Protocol supports dimmable effects.""" return False @property def requires_turn_on(self) -> bool: """If True the device must be turned on before setting level/patterns/modes.""" return False def construct_preset_pattern( self, pattern: int, speed: int, brightness: int ) -> bytearray: """The bytes to send for a preset pattern.""" effect = pattern + 99 return self.construct_message( bytearray([0x61, effect >> 8, effect & 0xFF, speed, 0x0F]) ) def parse_strip_setting(self, msg: bytes) -> LEDENETAddressableDeviceConfiguration: """Parse a strip settings message.""" # pos 0 1 2 3 4 5 6 7 8 9 10 11 # 63 00 32 05 00 00 00 00 00 00 02 9c # | | | | | | | | | | | | # | | | | | | | | | | | checksum # | | | | | | | | | | wiring type (0 indexed, RGB or RGBW) # | | | | | | | | | ?? always 00 # | | | | | | | | ?? always 00 # | | | | | | | n?? always 00 # | | | | | | ?? always 00 # | | | | | ?? always 00 # | | | | ?? always 00 # | | | ic type (01=UCS1903, 02=SM16703, 03=WS2811, 04=WS2812B, 05=SK6812, 06=INK1003, 07=WS2801, 08=LB1914) # | | num pixels (16 bit, low byte) # | num pixels (16 bit, high byte) # msg head # high_byte = msg[1] low_byte = msg[2] pixels_per_segment = (high_byte << 8) + low_byte _LOGGER.debug( "Pixel count (high: %s, low: %s) is: %s", hex(high_byte), hex(low_byte), pixels_per_segment, ) return LEDENETAddressableDeviceConfiguration( pixels_per_segment=pixels_per_segment, segments=None, music_pixels_per_segment=None, music_segments=None, wirings=list(ADDRESSABLE_RGB_WIRING_TO_NUM), wiring_num=msg[10], wiring=ADDRESSABLE_RGB_NUM_TO_WIRING.get(msg[10]), num_to_wiring=ADDRESSABLE_RGB_NUM_TO_WIRING, wiring_to_num=ADDRESSABLE_RGB_WIRING_TO_NUM, ic_type=A1_NUM_TO_PROTOCOL.get(msg[3]), ic_type_num=msg[3], operating_mode=A1_NUM_TO_OPERATING_MODE.get(msg[3]), ) def construct_device_config( self, operating_mode: Optional[int], wiring: Optional[int], ic_type: Optional[int], # ic type pixels_per_segment: Optional[int], # pixels per segment segments: Optional[int], # number of segments music_pixels_per_segment: Optional[int], # music pixels per segment music_segments: Optional[int], # number of music segments ) -> bytearray: """The bytes to send to change device config. pos 0 1 2 3 4 5 6 7 8 9 10 11 12 62 04 00 04 00 00 00 00 00 00 02 f0 5c <- checksum | | | | | | | | | | | | | | | | | | | | | | | always 0xf0 | | | | | | | | | | wiring type (0 indexed, RGB or RGBW) | | | | | | | | | ?? always 00 | | | | | | | | ?? always 00 | | | | | | | n?? always 00 | | | | | | ?? always 00 | | | | | ?? always 00 | | | | ?? always 00 | | | ic type (01=UCS1903, 02=SM16703, 03=WS2811, 04=WS2812B, 05=SK6812, 06=INK1003, 07=WS2801, 08=LB1914) | | num pixels (16 bit, low byte) | num pixels (16 bit, high byte) msg head """ assert ic_type is not None assert pixels_per_segment is not None assert wiring is not None return self.construct_message( bytearray( [ 0x62, pixels_per_segment >> 8, pixels_per_segment & 0xFF, ic_type, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, wiring, 0xF0, ] ) ) class ProtocolLEDENETAddressableA2(ProtocolLEDENETAddressableBase): # ic response # 0x96 0x63 0x00 0x32 0x00 0x01 0x01 0x04 0x32 0x01 0x64 (11) def construct_request_strip_setting(self) -> bytearray: return self.construct_message(bytearray([0x63, 0x12, 0x21, 0x0F])) @property def name(self) -> str: """The name of the protocol.""" return PROTOCOL_LEDENET_ADDRESSABLE_A2 @property def power_push_updates(self) -> bool: """If True the protocol pushes power state updates when controlled via ir/rf/app.""" # This is likely due to buggy firmware return False @property def dimmable_effects(self) -> bool: """Protocol supports dimmable effects.""" return True @property def requires_turn_on(self) -> bool: """If True the device must be turned on before setting level/patterns/modes.""" return False def is_valid_device_config_response(self, data: bytes) -> bool: """Check if a message is a valid ic state response.""" return ( len(data) == LEDENET_DEVICE_CONFIG_RESPONSE_LEN and _message_type_from_start_of_msg(data) == MSG_DEVICE_CONFIG and self.is_checksum_correct(data) ) def construct_preset_pattern( self, pattern: int, speed: int, brightness: int ) -> bytearray: """The bytes to send for a preset pattern.""" return self.construct_message(bytearray([0x42, pattern, speed, brightness])) def construct_levels_change( self, persist: int, red: Optional[int], green: Optional[int], blue: Optional[int], warm_white: Optional[int], cool_white: Optional[int], write_mode: LevelWriteMode, ) -> List[bytearray]: """The bytes to send for a level change request. white 41 01 ff ff ff 00 00 00 60 ff 00 00 9e """ preset_number = 0x01 # aka fixed color msgs = [] if red is not None or green is not None or blue is not None: msgs.append( self.construct_message( bytearray( [ 0x41, preset_number, red or 0x00, green or 0x00, blue or 0x00, 0x00, 0x00, 0x00, 0x60, 0xFF, 0x00, 0x00, ] ) ) ) if warm_white is not None: msgs.append(self.construct_message(bytearray([0x47, warm_white or 0x00]))) return msgs def construct_music_mode( self, sensitivity: int, brightness: int, mode: Optional[int], effect: Optional[int], foreground_color: Optional[Tuple[int, int, int]] = None, background_color: Optional[Tuple[int, int, int]] = None, ) -> List[bytearray]: """The bytes to send for music mode. Known messages 73 01 27 01 00 00 00 00 ff ff 64 64 62 - lowest brightness music 73 01 27 01 ff ff ff 00 ff ff 64 64 5f - highest brightness music ^R ^G ^B <-- failling color 73 01 27 01 ff 00 00 00 ff ff 64 64 61 ^R ^G ^B <-- failling color 73 01 27 01 ff ff ff 00 ff ff 00 64 fb - lowest sensitivity 73 01 27 01 ff ff ff 00 ff ff 64 64 5f - highest sensitivity ^ sensitivity 73 01 27 13 00 ff 19 ff 00 00 64 64 8d ^R ^G ^B <-- failling color (light screen mode) 73 01 27 13 00 ff 19 ff 00 00 64 64 8d ^R ^G ^B <-- column color (light screen mode) 73 01 27 14 00 ff 19 ff 00 00 64 64 8e ^ effect 73 01 27 15 00 ff 19 ff 00 00 64 64 8f ^ effect 73 01 27 15 00 ff 19 ff 00 00 64 64 8f ^ mode - light screen mode 73 01 26 01 00 00 00 00 ff ff 64 64 61 ^ mode - led strip mode 73 01 26 0e 00 00 00 ff 00 00 64 64 6f ^R ^G ^B <-- led strip mode color 73 01 26 0e 00 00 00 ff 00 00 64 06 11 ^brightness <-- led strip mode color """ if foreground_color is None: foreground_color = (0xFF, 0xFF, 0xFF) if background_color is None: background_color = (0x00, 0x00, 0x00) if effect and not (1 <= effect <= 16): raise ValueError("Effect must be between 1 and 16") if mode and not (0x26 <= mode <= 0x27): raise ValueError("Mode must be between 0x26 and 0x27") return [ self.construct_message( bytearray( [ 0x73, 0x01, mode or MusicMode.STRIP.value, # strip mode 0x26, light screen mode 0x27 effect or 0x01, *foreground_color, *background_color, sensitivity, brightness, ] ) ) ] def parse_strip_setting(self, msg: bytes) -> LEDENETAddressableDeviceConfiguration: """Parse a strip settings message.""" # pos 0 1 2 3 4 5 6 7 8 9 10 # 00 63 01 2c 00 01 07 08 96 01 45 # | | | | | | | | | | | # | | | | | | | | | | checksum # | | | | | | | | | | # | | | | | | | | | segments (music mode) # | | | | | | | | num pixels (music mode) # | | | | | | | wiring type (0 indexed, RGB or RGBW) # | | | | | | ic type (01=UCS1903, 02=SM16703, 03=WS2811, 04=WS2811B, 05=SK6812, 06=INK1003, 07=WS2801, 08=WS2815, 09=APA102, 10=TM1914, 11=UCS2904B) # | | | | | segments # | | | | ?? (always 0x00) # | | | num pixels (16 bit, low byte) # | | num pixels (16 bit, high byte) # | msg head # msg head # high_byte = msg[2] low_byte = msg[3] pixels_per_segment = (high_byte << 8) + low_byte _LOGGER.debug("bytes: %s", msg) _LOGGER.debug( "Pixel count (high: %s, low: %s) is: %s", hex(high_byte), hex(low_byte), pixels_per_segment, ) segments = msg[5] _LOGGER.debug( "Segment count (%s) is: %s", hex(segments), segments, ) return LEDENETAddressableDeviceConfiguration( pixels_per_segment=pixels_per_segment, segments=segments, music_pixels_per_segment=msg[8], music_segments=msg[9], wirings=list(ADDRESSABLE_RGB_NUM_TO_WIRING.values()), wiring_num=msg[7], wiring=ADDRESSABLE_RGB_NUM_TO_WIRING.get(msg[7]), num_to_wiring=ADDRESSABLE_RGB_NUM_TO_WIRING, wiring_to_num=ADDRESSABLE_RGB_WIRING_TO_NUM, ic_type=A2_NUM_TO_PROTOCOL.get(msg[6]), ic_type_num=msg[6], operating_mode=A2_NUM_TO_OPERATING_MODE.get(msg[6]), ) def construct_device_config( self, operating_mode: Optional[int], wiring: Optional[int], ic_type: Optional[int], # ic type pixels_per_segment: Optional[int], # pixels per segment segments: Optional[int], # number of segments music_pixels_per_segment: Optional[int], # music pixels per segment music_segments: Optional[int], # number of music segments ) -> bytearray: """The bytes to send to change device config. pos 0 1 2 3 4 5 6 7 8 9 10 62 01 2c 00 06 01 04 32 01 0f dc | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | checksum | | | | | | | | | ?? always 0x0f | | | | | | | | segments (music mode) | | | | | | | num pixels (music mode) | | | | | | wiring type (0 indexed, RGB or RGBW) | | | | | ic type (01=WS2812B, 02=SM16703, 03=SM16704, 04=WS2811, 05=UCS1903, 06=SK6812, 07=SK6812RGBW, 08=INK1003, 09=UCS2904B) | | | | segments | | | ?? always 00 | | num pixels (16 bit, low byte) | num pixels (16 bit, high byte) msg head """ assert ic_type is not None assert pixels_per_segment is not None assert segments is not None assert music_pixels_per_segment is not None assert music_segments is not None assert wiring is not None pixels_per_segment = max(1, min(pixels_per_segment, PIXELS_PER_SEGMENT_MAX)) segments = max(1, min(segments, SEGMENTS_MAX)) if pixels_per_segment * segments > PIXELS_MAX: segments = int(PIXELS_MAX / pixels_per_segment) music_pixels_per_segment = max( 1, min(music_pixels_per_segment, MUSIC_PIXELS_PER_SEGMENT_MAX) ) music_segments = max(1, min(music_segments, MUSIC_SEGMENTS_MAX)) if music_pixels_per_segment * music_segments > MUSIC_PIXELS_MAX: music_segments = int(MUSIC_PIXELS_MAX / music_pixels_per_segment) if ( pixels_per_segment <= MUSIC_PIXELS_PER_SEGMENT_MAX and segments <= MUSIC_SEGMENTS_MAX and pixels_per_segment * segments <= MUSIC_PIXELS_MAX ): # If the pixels_per_segment and segments can accomate music # mode then we sync them music_pixels_per_segment = pixels_per_segment music_segments = segments return self.construct_message( bytearray( [ 0x62, pixels_per_segment >> 8, pixels_per_segment & 0xFF, 0x00, segments, ic_type, wiring, music_pixels_per_segment, music_segments, 0xF0, ] ) ) class ProtocolLEDENETAddressableA3(ProtocolLEDENETAddressableA2): def construct_request_strip_setting(self) -> bytearray: return self.construct_wrapped_message( super().construct_request_strip_setting(), inner_pre_constructed=True, ) def construct_state_query(self) -> bytearray: """The bytes to send for a query request.""" return self.construct_wrapped_message( super().construct_state_query(), inner_pre_constructed=True, ) def construct_state_change(self, turn_on: int) -> bytearray: """The bytes to send for a state change request.""" return self.construct_wrapped_message( super().construct_state_change(turn_on), inner_pre_constructed=True, ) # ic response # 0x00 0x63 0x00 0x32 0x00 0x01 0x04 0x03 0x32 0x01 0xD0 (11) # b0 b1 b2 b3 00 01 01 37 00 0b 00 63 00 32 00 01 04 03 32 01 d0 aa @property def power_push_updates(self) -> bool: """If True the protocol pushes power state updates when controlled via ir/rf/app.""" return True @property def state_push_updates(self) -> bool: """If True the protocol pushes state updates when controlled via ir/rf/app.""" return True @property def zones(self) -> bool: """If the protocol supports zones.""" return True @property def name(self) -> str: """The name of the protocol.""" return PROTOCOL_LEDENET_ADDRESSABLE_A3 @property def dimmable_effects(self) -> bool: """Protocol supports dimmable effects.""" return True @property def requires_turn_on(self) -> bool: """If True the device must be turned on before setting level/patterns/modes.""" return False def construct_preset_pattern( self, pattern: int, speed: int, brightness: int ) -> bytearray: """The bytes to send for a preset pattern.""" return self.construct_wrapped_message( super().construct_preset_pattern(pattern, speed, brightness), inner_pre_constructed=True, ) def parse_strip_setting(self, msg: bytes) -> LEDENETAddressableDeviceConfiguration: """Parse a strip settings message.""" # pos 0 1 2 3 4 5 6 7 8 9 10 # 00 63 01 2c 00 01 07 08 96 01 45 # | | | | | | | | | | | # | | | | | | | | | | checksum # | | | | | | | | | | # | | | | | | | | | segments (music mode) # | | | | | | | | num pixels (music mode) # | | | | | | | wiring type (0 indexed, RGB or RGBW) # | | | | | | ic type (01=WS2812B, 02=SM16703, 03=SM16704, 04=WS2811, 05=UCS1903, 06=SK6812, 07=SK6812RGBW, 08=INK1003, 09=UCS2904B) # | | | | | segments # | | | | ?? (always 0x00) # | | | num pixels (16 bit, low byte) # | | num pixels (16 bit, high byte) # | msg head # msg head # high_byte = msg[2] low_byte = msg[3] pixels_per_segment = (high_byte << 8) + low_byte _LOGGER.debug("bytes: %s", msg) _LOGGER.debug( "Pixel count (high: %s, low: %s) is: %s", hex(high_byte), hex(low_byte), pixels_per_segment, ) segments = msg[5] _LOGGER.debug( "Segment count (%s) is: %s", hex(segments), segments, ) if NEW_ADDRESSABLE_NUM_TO_OPERATING_MODE.get(msg[6]) == COLOR_MODE_RGBW: wirings = ADDRESSABLE_RGBW_NUM_TO_WIRING num_to_wiring = ADDRESSABLE_RGBW_NUM_TO_WIRING wiring_to_num = ADDRESSABLE_RGBW_WIRING_TO_NUM else: wirings = ADDRESSABLE_RGB_NUM_TO_WIRING num_to_wiring = ADDRESSABLE_RGB_NUM_TO_WIRING wiring_to_num = ADDRESSABLE_RGB_WIRING_TO_NUM return LEDENETAddressableDeviceConfiguration( pixels_per_segment=pixels_per_segment, segments=segments, music_pixels_per_segment=msg[8], music_segments=msg[9], wirings=list(wirings.values()), wiring_num=msg[7], wiring=wirings.get(msg[7]), ic_type=NEW_ADDRESSABLE_NUM_TO_PROTOCOL.get(msg[6]), num_to_wiring=num_to_wiring, wiring_to_num=wiring_to_num, ic_type_num=msg[6], operating_mode=NEW_ADDRESSABLE_NUM_TO_OPERATING_MODE.get(msg[6]), ) # To query music mode # Send -> b0 b1 b2 b3 00 01 01 1c 00 03 72 00 72 cb # Responds <- b0 b1 b2 b3 00 01 01 1c 00 0d 72 01 26 01 00 00 00 00 00 00 64 64 62 b5 def construct_music_mode( self, sensitivity: int, brightness: int, mode: Optional[int], effect: Optional[int], foreground_color: Optional[Tuple[int, int, int]] = None, background_color: Optional[Tuple[int, int, int]] = None, ) -> List[bytearray]: """The bytes to send for music mode. Known messages b0 b1 b2 b3 00 01 01 1f 00 0d 73 01 27 01 ff 00 00 ff 00 00 64 64 62 b8 - Music mode b0 b1 b2 b3 00 01 01 20 00 0d 73 01 27 01 00 ff 44 ff 00 00 64 64 a6 41 - Music mode b0 b1 b2 b3 00 01 01 21 00 0d 73 01 27 01 ff a6 00 ff 00 00 64 64 08 06 - Music mode b0 b1 b2 b3 00 01 01 22 00 0d 73 01 27 01 ff a6 00 ff 00 00 2e 64 d2 9b - Music mode b0 b1 b2 b3 00 01 01 2d 00 0d 73 01 27 01 ff a6 00 ff 00 00 4e 64 f2 e6 - Music mode (various sensitivity) b0 b1 b2 b3 00 01 01 2e 00 0d 73 01 27 01 ff a6 00 ff 00 00 5f 64 03 09 - Music mode (various sensitivity) b0 b1 b2 b3 00 01 01 2f 00 0d 73 01 27 01 ff a6 00 ff 00 00 64 64 08 14 - Music mode (various sensitivity) b0 b1 b2 b3 00 01 01 30 00 0d 73 01 27 01 ff a6 00 ff 00 00 37 64 db bb - Music mode (various sensitivity) ^^ Likely sensitivity from 0-100 (0x64) b0 b1 b2 b3 00 01 01 60 00 0d 73 01 27 01 ff a6 00 ff 00 00 64 64 08 45 - Music mode (various sensitivity) b0 b1 b2 b3 00 01 01 5f 00 0d 73 01 27 01 ff a6 00 ff 00 00 64 64 08 44 - Music mode (various sensitivity) b0 b1 b2 b3 00 01 01 69 00 0d 73 01 26 01 ff 00 00 ff 00 00 00 64 fd 38 - Music mode (various sensitivity) b0 b1 b2 b3 00 01 01 68 00 0d 73 01 26 01 ff 00 00 ff 00 00 64 64 61 ff - Music mode (various sensitivity) b0 b1 b2 b3 00 01 01 08 00 0d 73 01 26 02 00 00 00 00 ff ff 64 60 5e 99 -- red lines b0 b1 b2 b3 00 01 01 16 00 0d 73 01 26 02 00 00 00 00 ff ff 64 64 62 af -- red lines b0 b1 b2 b3 00 01 01 17 00 0d 73 01 26 01 00 00 00 00 ff ff 64 64 61 ae -- rainbow lines ^^ Likely brightness from 0-100 (0x64) """ return [ self.construct_wrapped_message(msg, inner_pre_constructed=True) for msg in super().construct_music_mode( sensitivity, brightness, mode, effect, foreground_color, background_color, ) ] def construct_levels_change( self, persist: int, red: Optional[int], green: Optional[int], blue: Optional[int], warm_white: Optional[int], cool_white: Optional[int], write_mode: LevelWriteMode, ) -> List[bytearray]: """The bytes to send for a level change request. b0 [unknown static?] b1 [unknown static?] b2 [unknown static?] b3 [unknown static?] 00 [unknown static?] 01 [unknown static?] 01 [unknown static?] 6a [incrementing sequence number] 00 [unknown static?] 0d [unknown, sometimes 0c] 41 [unknown static?] 02 [preset number] ff [foreground r] 00 [foreground g] 00 [foreground b] 00 [background red] ff [background green] 00 [background blue] 06 [speed or direction?] 00 [unknown static?] 00 [unknown static?] 00 [unknown static?] 47 [speed or direction?] cd [check sum] Known messages b0 b1 b2 b3 00 01 01 01 00 0c 10 14 15 0a 0b 0e 12 06 01 00 0f 84 dd - preset 1 b0 b1 b2 b3 00 01 01 03 00 0d 41 02 00 ff ff 00 00 00 06 00 00 00 47 66 - preset 2 b0 b1 b2 b3 00 01 01 04 00 0d 41 03 00 ff ff 00 00 00 06 00 00 00 48 69 - preset 3 b0 b1 b2 b3 00 01 01 02 00 0d 41 01 00 ff ff 00 00 00 06 ff 00 00 45 61 - preset 4 b0 b1 b2 b3 00 01 01 1f 00 0d 41 01 ff 00 00 00 00 00 06 ff 00 00 46 80 - preset 1 red or green b0 b1 b2 b3 00 01 01 27 00 0d 41 01 00 ff 00 00 00 00 06 ff 00 00 46 88 - preset 1 red or green b0 b1 b2 b3 00 01 01 2e 00 0d 41 01 ff 00 00 00 00 00 06 ff 00 00 46 8f - preset 1 red (foreground) b0 b1 b2 b3 00 01 01 27 00 0d 41 01 00 ff 00 00 00 00 06 ff 00 00 46 88 - preset 1 green (foreground) b0 b1 b2 b3 00 01 01 3e 00 0d 41 01 00 00 ff 00 00 00 06 ff 00 00 46 9f - preset 1 blue (foreground) b0 b1 b2 b3 00 01 01 54 00 0d 41 02 00 ff 00 00 00 00 06 00 00 00 48 b9 - preset 2 green (foreground) b0 b1 b2 b3 00 01 01 55 00 0d 41 02 ff 00 00 00 00 00 06 00 00 00 48 ba - preset 2 red (foreground) b0 b1 b2 b3 00 01 01 67 00 0d 41 02 ff 00 00 ff 00 00 06 00 00 00 47 ca - preset 2 red (foreground), red (background) b0 b1 b2 b3 00 01 01 67 00 0d 41 02 ff 00 00 ff 00 00 06 00 00 00 47 ca - preset 2 red (foreground), red (background) b0 b1 b2 b3 00 01 01 69 00 0d 41 02 ff 00 00 ff 00 00 06 00 00 00 47 cc - preset 2 red (foreground), red (background) b0 b1 b2 b3 00 01 01 6a 00 0d 41 02 ff 00 00 00 ff 00 06 00 00 00 47 cd - preset 2 red (foreground), green (background) b0 b1 b2 b3 00 01 01 77 00 0d 41 02 ff 00 00 00 ff 00 06 00 00 00 47 da - preset 2 red (foreground), green (background) - direction RTL b0 b1 b2 b3 00 01 01 7d 00 0d 41 02 ff 00 00 00 ff 00 06 00 00 00 47 e0 - preset 2 red (foreground), green (background) - direction RTL b0 b1 b2 b3 00 01 01 7d 00 0d 41 02 ff 00 00 00 ff 00 06 00 00 00 47 e0 - preset 2 red (foreground), green (background) - direction RTL b0 b1 b2 b3 00 01 01 7c 00 0d 41 02 ff 00 00 00 ff 00 06 01 00 00 48 e1 - preset 2 red (foreground), green (background) - direction LTR b0 b1 b2 b3 00 01 01 89 00 0d 41 02 ff 00 00 00 ff 00 00 00 00 00 41 e0 - preset 2 red (foreground), green (background) - direction LTR - speed 0 b0 b1 b2 b3 00 01 01 8a 00 0d 41 02 ff 00 00 00 ff 00 64 00 00 00 a5 a9 - preset 2 red (foreground), green (background) - direction LTR - speed 64 b0 b1 b2 b3 00 01 01 8b 00 0d 41 02 ff 00 00 00 ff 00 00 00 00 00 41 e2 - preset 2 red (foreground), green (background) - direction LTR - speed 0? b0 b1 b2 b3 00 01 01 8c 00 0d 41 02 ff 00 00 00 ff 00 64 00 00 00 a5 ab - preset 2 red (foreground), green (background) - direction LTR - speed 64? Set Blue b0b1b2b30001010b0034a0000600010000ff0000ff0002ffff000000ff00030000ff0000ff0004ffff000000ff00050000ff0000ff0006ffff000000ffac5f Query b0b1b2b30001010c0004818a8b9604 b0b1b2b30001010c000e811a23280000640f000001000660a2 Set Red b0b1b2b30001010d0034a0000600010000ff0000ff0002ff00000000ff00030000ff0000ff0004ff00000000ff00050000ff0000ff0006ff00000000ffaf67 """ return [ self.construct_wrapped_message(msg, inner_pre_constructed=True) for msg in super().construct_levels_change( persist, red, green, blue, warm_white, cool_white, write_mode ) ] def construct_zone_change( self, points: int, # the number of points on the strip rgb_list: List[Tuple[int, int, int]], speed: int, effect: MultiColorEffects, ) -> bytearray: """The bytes to send for multiple zones. Blue/Green - Static 590063ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff00000000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff001e04640024 Red/Blue - Jump 5900630000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff00ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff00001e01640021 White/Green - Static 590063ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff00001e01640003 11111 22222 33333 44444 55555 66666 77777 88888 99999 00000 11111 22222 33333 44444 55555 White - Static 590063ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff001e016400e5 White - Running Water - Full speed 590063ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff001e026400e6 White - Running Water - 50% speed 590063ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff001e023200b4 Red - Blue - Gradient 590063ff0000f60008ed0011e4001adb0023d3002bca0034c1003db80046af004fa700579e00609500698c007283007b7b008372008c69009560009e5700a74f00af4600b83d00c13400ca2b00d32300db1a00e41100ed0800f60000ff001e01640005 Red - Brething 590063ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000001e05640025 """ sent_zones = len(rgb_list) if sent_zones > points: raise ValueError(f"Device supports a maximum of {points} zones") pixel_bits = 9 + (points * 3) pixels = bytearray([pixel_bits >> 8, pixel_bits & 0xFF]) msg = bytearray([0x59]) msg.extend(pixels) zone_size = points // sent_zones remaining = points for rgb in rgb_list: for _ in range(zone_size): remaining -= 1 msg.extend(bytearray([*rgb])) while remaining: remaining -= 1 msg.extend(bytearray([*rgb])) msg.extend(bytearray([0x00, 0x1E])) msg.extend(bytearray([effect.value, speed])) msg.append(0x00) return self.construct_wrapped_message(msg) def construct_device_config( self, operating_mode: Optional[int], wiring: Optional[int], ic_type: Optional[int], # ic type pixels_per_segment: Optional[int], # pixels per segment segments: Optional[int], # number of segments music_pixels_per_segment: Optional[int], # music pixels per segment music_segments: Optional[int], # number of music segments ) -> bytearray: """The bytes to send to change device config.""" return self.construct_wrapped_message( super().construct_device_config( operating_mode, wiring, ic_type, pixels_per_segment, segments, music_pixels_per_segment, segments, ), inner_pre_constructed=True, ) class ProtocolLEDENETSocket(ProtocolLEDENET8Byte): @property def power_push_updates(self) -> bool: """If True the protocol pushes power state updates when controlled via ir/rf/app.""" return True @property def state_push_updates(self) -> bool: """If True the protocol pushes state updates when controlled via ir/rf/app.""" return True @property def name(self) -> str: """The name of the protocol.""" return PROTOCOL_LEDENET_SOCKET @property def timer_response_len(self) -> int: """Return the time response len.""" return LEDENET_TIMERS_SOCKET_RESPONSE_LEN @property def timer_len(self) -> int: """Return a single timer len.""" return 12 @property def timer_count(self) -> int: """Return the number of timers.""" return 8 class ProtocolLEDENETCCT(ProtocolLEDENET9Byte): MIN_BRIGHTNESS = 2 @property def timer_response_len(self) -> int: """Return the time response len.""" return LEDENET_TIMERS_8BYTE_RESPONSE_LEN @property def timer_len(self) -> int: """Return a single timer len.""" return 14 @property def dimmable_effects(self) -> bool: """Protocol supports dimmable effects.""" return False @property def name(self) -> str: """The name of the protocol.""" return PROTOCOL_LEDENET_CCT @property def power_push_updates(self) -> bool: """If True the protocol pushes power state updates when controlled via ir/rf/app.""" return True def construct_levels_change( self, persist: int, red: Optional[int], green: Optional[int], blue: Optional[int], warm_white: Optional[int], cool_white: Optional[int], write_mode: LevelWriteMode, ) -> List[bytearray]: """The bytes to send for a level change request. b0 b1 b2 b3 00 01 01 52 00 09 35 b1 00 64 00 00 00 03 4d bd - 100% warm b0 b1 b2 b3 00 01 01 72 00 09 35 b1 64 64 00 00 00 03 b1 a5 - 100% cool b0 b1 b2 b3 00 01 01 9f 00 09 35 b1 64 32 00 00 00 03 7f 6e - 100% cool - dim 50% """ assert warm_white is not None, "CCT devices must set a warm white value" assert cool_white is not None, "CCT devices must set a cool white value" scaled_temp, brightness = white_levels_to_scaled_color_temp( warm_white, cool_white ) return [ self.construct_message( bytearray( [ 0x35, 0xB1, scaled_temp, # If the brightness goes below the precision the device # will flip from cold to warm max(self.MIN_BRIGHTNESS, brightness), 0x00, 0x00, 0x00, 0x03, ] ) ) ] class ProtocolLEDENETCCTWrapped(ProtocolLEDENETCCT): @property def name(self) -> str: """The name of the protocol.""" return PROTOCOL_LEDENET_CCT_WRAPPED @property def state_push_updates(self) -> bool: """If True the protocol pushes state updates when controlled via ir/rf/app.""" return True @property def requires_turn_on(self) -> bool: """If True the device must be turned on before setting level/patterns/modes.""" return False def construct_state_query(self) -> bytearray: """The bytes to send for a query request.""" return self.construct_wrapped_message( super().construct_state_query(), inner_pre_constructed=True, ) def construct_state_change(self, turn_on: int) -> bytearray: """The bytes to send for a state change request.""" return self.construct_wrapped_message( super().construct_state_change(turn_on), inner_pre_constructed=True, ) def construct_levels_change( self, persist: int, red: Optional[int], green: Optional[int], blue: Optional[int], warm_white: Optional[int], cool_white: Optional[int], write_mode: LevelWriteMode, ) -> List[bytearray]: """The bytes to send for a level change request. b0 b1 b2 b3 00 01 01 52 00 09 35 b1 00 64 00 00 00 03 4d bd - 100% warm b0 b1 b2 b3 00 01 01 72 00 09 35 b1 64 64 00 00 00 03 b1 a5 - 100% cool b0 b1 b2 b3 00 01 01 9f 00 09 35 b1 64 32 00 00 00 03 7f 6e - 100% cool - dim 50% """ return [ self.construct_wrapped_message( super().construct_levels_change( persist, red, green, blue, warm_white, cool_white, write_mode )[0], inner_pre_constructed=True, ) ] class ProtocolLEDENETAddressableChristmas(ProtocolLEDENETAddressableBase): def construct_state_query(self) -> bytearray: """The bytes to send for a query request.""" return self.construct_wrapped_message( super().construct_state_query(), inner_pre_constructed=True, ) def construct_state_change(self, turn_on: int) -> bytearray: """The bytes to send for a state change request.""" return self.construct_wrapped_message( super().construct_state_change(turn_on), inner_pre_constructed=True, ) @property def name(self) -> str: """The name of the protocol.""" return PROTOCOL_LEDENET_ADDRESSABLE_CHRISTMAS @property def zones(self) -> bool: """If the protocol supports zones.""" return True @property def power_push_updates(self) -> bool: """If True the protocol pushes power state updates when controlled via ir/rf/app.""" return True @property def state_push_updates(self) -> bool: """If True the protocol pushes state updates when controlled via ir/rf/app.""" return True @property def dimmable_effects(self) -> bool: """Protocol supports dimmable effects.""" return False @property def requires_turn_on(self) -> bool: """If True the device must be turned on before setting level/patterns/modes.""" return False def construct_preset_pattern( self, pattern: int, speed: int, brightness: int ) -> bytearray: """The bytes to send for a preset pattern. 0xB0 0xB1 0xB2 0xB3 0x00 0x01 0x01 0x2A 0x00 0x04 0x38 0x01 0x10 0x00 0x3F (15) 0xB0 0xB1 0xB2 0xB3 0x00 0x01 0x01 0x2B 0x00 0x04 0x38 0x02 0x10 0x00 0x41 (15) 0xB0 0xB1 0xB2 0xB3 0x00 0x01 0x01 0x2C 0x00 0x04 0x38 0x03 0x10 0x00 0x43 (15) 0xB0 0xB1 0xB2 0xB3 0x00 0x01 0x01 0x2D 0x00 0x04 0x38 0x04 0x10 0x00 0x45 (15) 0xB0 0xB1 0xB2 0xB3 0x00 0x01 0x01 0x2E 0x00 0x04 0x38 0x05 0x10 0x00 0x47 (15) 0xB0 0xB1 0xB2 0xB3 0x00 0x01 0x01 0x2F 0x00 0x04 0x38 0x06 0x10 0x00 0x49 (15) """ return self.construct_wrapped_message( bytearray( [ 0x38, pattern, utils.speedToDelay(speed), ] ) ) def construct_levels_change( self, persist: int, red: Optional[int], green: Optional[int], blue: Optional[int], warm_white: Optional[int], cool_white: Optional[int], write_mode: LevelWriteMode, ) -> List[bytearray]: """The bytes to send for a level change request. Green 100%: b0b1b2b300010180000d3ba100646400000000000000a49d Blue 50% b0b1b2b300010110000d3ba176e4320000000000000068b5 Red & green 255 and 25% bright b0b1b2b300010133000d3ba11e64190000000000000077f6 Red & Blue 255 and 40% b0b1b2b30001014e000d3ba196642800000000000000fe1f Inner messages Single - Green - Brightness 100% 3b a1 3c 64 64 00 00 00 00 00 00 00 e0 Single - Green - Brightness 50% 3b a1 3c 64 32 00 00 00 00 00 00 00 ae Single - Blue - Brightness 100% 3b a1 78 64 64 00 00 00 00 00 00 00 1c Single - Red - Brightness 100% 3b a1 00 64 64 00 00 00 00 00 00 00 a4 Single - Pink (100% Red, 100% Blue) - Brightness 100% 3b a1 96 64 64 00 00 00 00 00 00 00 3a Single - White (100% Red, 100% Green, 100% Blue) - Brightness 100% 3b a1 00 00 64 00 00 00 00 00 00 00 40 Single - Yellow (100% Red, 100% Green) - Brightness 100% 3b a1 1e 64 64 00 00 00 00 00 00 00 c2 Single - Light Blue (100% Blue, 100% Green) - Brightness 100% 3b a1 5a 64 64 00 00 00 00 00 00 00 fe Single - Red - Brightness 0% 3b a1 00 64 00 00 00 00 00 00 00 00 40 Single - Red - Brightness 50% 3b a1 00 64 32 00 00 00 00 00 00 00 72 Single - Blue - Brightness 50% 3b a1 78 64 32 00 00 00 00 00 00 00 ea """ assert red is not None assert green is not None assert blue is not None h, s, v = colorsys.rgb_to_hsv(red / 255, green / 255, blue / 255) return [ self.construct_wrapped_message( bytearray( [ 0x3B, 0xA1, int(h * 180), int(s * 100), int(v * 100), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ] ) ) ] def construct_zone_change( self, points: int, # the number of points on the strip rgb_list: List[Tuple[int, int, int]], speed: int, effect: MultiColorEffects, ) -> bytearray: """The bytes to send for multiple zones. 6 Zone All red a000060001ff00000000ff0002ff00000000ff0003ff00000000ff0004ff00000000ff0005ff00000000ff0006ff00000000ffaf 6 Zone All Yellow a000060001ffff000000ff0002ffff000000ff0003ffff000000ff0004ffff000000ff0005ffff000000ff0006ffff000000ffa9 6 Zone All Green a00006000100ff000000ff000200ff000000ff000300ff000000ff000400ff000000ff000500ff000000ff000600ff000000ffaf 6 Zone All Green a00006000100ff000000ff000200ff000000ff000300ff000000ff000400ff000000ff000500ff000000ff000600ff000000ffaf 6 Zone All Cyan a00006000100ffff0000ff000200ffff0000ff000300ffff0000ff000400ffff0000ff000500ffff0000ff000600ffff0000ffa9 6 Zone All White a000060001ffffff0000ff0002ffffff0000ff0003ffffff0000ff0004ffffff0000ff0005ffffff0000ff0006ffffff0000ffa3 """ sent_zones = len(rgb_list) if sent_zones > points: raise ValueError(f"Device supports a maximum of {points} zones") msg = bytearray([0xA0, 0x00, 0x06]) zone_size = points // sent_zones remaining = points for rgb in rgb_list: for _ in range(zone_size): remaining -= 1 msg.extend( bytearray([0x00, points - remaining, *rgb, 0x00, 0x00, 0xFF]) ) while remaining: remaining -= 1 msg.extend( bytearray([0x00, points - remaining, *rgb_list[-1], 0x00, 0x00, 0xFF]) ) return self.construct_wrapped_message(msg) def parse_strip_setting(self, msg: bytes) -> LEDENETAddressableDeviceConfiguration: """Parse a strip settings message.""" return LEDENETAddressableDeviceConfiguration( pixels_per_segment=6, segments=None, music_pixels_per_segment=None, music_segments=None, wirings=[], wiring_num=None, wiring=None, num_to_wiring={}, wiring_to_num={}, ic_type=None, ic_type_num=None, operating_mode=COLOR_MODE_RGB, ) Danielhiversen-flux_led-bfd1bbe/flux_led/py.typed000066400000000000000000000000001447734565100224100ustar00rootroot00000000000000Danielhiversen-flux_led-bfd1bbe/flux_led/scanner.py000066400000000000000000000265651447734565100227440ustar00rootroot00000000000000import asyncio import contextlib import logging import select import socket import sys import time from datetime import date from typing import Dict, List, Optional, Tuple, Union from .const import ( ATTR_FIRMWARE_DATE, ATTR_ID, ATTR_IPADDR, ATTR_MODEL, ATTR_MODEL_DESCRIPTION, ATTR_MODEL_INFO, ATTR_MODEL_NUM, ATTR_REMOTE_ACCESS_ENABLED, ATTR_REMOTE_ACCESS_HOST, ATTR_REMOTE_ACCESS_PORT, ATTR_VERSION_NUM, ) if sys.version_info >= (3, 8): from typing import TypedDict # pylint: disable=no-name-in-module else: from typing_extensions import TypedDict from .models_db import get_model_description _LOGGER = logging.getLogger(__name__) MESSAGE_SEND_INTERLEAVE_DELAY = 0.4 LEGACY_OUI = "ACCF23" class FluxLEDDiscovery(TypedDict): """A flux led device.""" ipaddr: str id: Optional[str] # aka mac model: Optional[str] model_num: Optional[int] version_num: Optional[int] firmware_date: Optional[date] model_info: Optional[str] # contains if IR (and maybe BL) if the device supports IR model_description: Optional[str] remote_access_enabled: Optional[bool] remote_access_host: Optional[str] # the remote access host remote_access_port: Optional[int] # the remote access port def is_legacy_device(discovery: Optional[FluxLEDDiscovery]) -> bool: """Check if a discovery is a legacy device.""" if not discovery: return False is_legacy_mac = False if discovery.get(ATTR_ID): mac = discovery[ATTR_ID] assert mac is not None is_legacy_mac = mac.startswith(LEGACY_OUI) return is_legacy_mac or bool( discovery.get(ATTR_VERSION_NUM) and not discovery.get(ATTR_MODEL_NUM) ) def create_udp_socket(discovery_port: int) -> socket.socket: """Create a udp socket used for communicating with the device.""" sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) try: # Legacy devices require source port to be the discovery port sock.bind(("", discovery_port)) except OSError as err: _LOGGER.debug("Port %s is not available: %s", discovery_port, err) sock.bind(("", 0)) sock.setblocking(False) return sock def merge_discoveries(target: FluxLEDDiscovery, source: FluxLEDDiscovery) -> None: """Merge keys from a second discovery that may be missing from the first one.""" for k, v in source.items(): if target.get(k) is None: target[k] = v # type: ignore[literal-required] def _strip_new_lines(msg: str) -> str: return msg.replace("\r", "").replace("\n", "") def _process_discovery_message(data: FluxLEDDiscovery, decoded_data: str) -> None: """Process response from b'HF-A11ASSISTHREAD' b'192.168.214.252,B4E842E10588,AK001-ZJ2145' """ data_split = _strip_new_lines(decoded_data).split(",") if len(data_split) < 3: return ipaddr = data_split[0] data.update( { ATTR_IPADDR: ipaddr, ATTR_ID: data_split[1], ATTR_MODEL: data_split[2], } ) def _process_version_message(data: FluxLEDDiscovery, decoded_data: str) -> None: r"""Process response from b'AT+LVER\r' b'+ok=07_06_20210106_ZG-BL\r' """ version_data = _strip_new_lines(decoded_data[4:]) data_split = version_data.split("_", maxsplit=3) if len(data_split) == 1: with contextlib.suppress(ValueError): data[ATTR_VERSION_NUM] = int(data_split[0], 16) return if len(data_split) >= 2: try: data[ATTR_MODEL_NUM] = int(data_split[0], 16) data[ATTR_VERSION_NUM] = int(data_split[1], 16) except ValueError: return assert data[ATTR_MODEL_NUM] is not None if len(data_split) >= 3: firmware_date = data_split[2] with contextlib.suppress(TypeError, ValueError): data[ATTR_FIRMWARE_DATE] = date( int(firmware_date[:4]), int(firmware_date[4:6]), int(firmware_date[6:8]), ) if len(data_split) == 4: data[ATTR_MODEL_INFO] = data_split[3] data[ATTR_MODEL_DESCRIPTION] = get_model_description( data[ATTR_MODEL_NUM], data[ATTR_MODEL_INFO] ) def _process_remote_access_message(data: FluxLEDDiscovery, decoded_data: str) -> None: """Process response from b'AT+SOCKB\r' b'+ok=TCP,8816,ra8816us02.magichue.net\r' """ data_split = _strip_new_lines(decoded_data).split(",") if len(data_split) < 3: if not data.get(ATTR_REMOTE_ACCESS_ENABLED): data[ATTR_REMOTE_ACCESS_ENABLED] = False return try: data.update( { ATTR_REMOTE_ACCESS_ENABLED: True, ATTR_REMOTE_ACCESS_PORT: int(data_split[1]), ATTR_REMOTE_ACCESS_HOST: data_split[2], } ) except ValueError: return class BulbScanner: DISCOVERY_PORT = 48899 BROADCAST_FREQUENCY = 6 # At least 6 for 0xA1 models RESPONSE_SIZE = 64 DISCOVER_MESSAGE = b"HF-A11ASSISTHREAD" VERSION_MESSAGE = b"AT+LVER\r" REMOTE_ACCESS_MESSAGE = b"AT+SOCKB\r" DISABLE_REMOTE_ACCESS_MESSAGE = b"AT+SOCKB=NONE\r" REBOOT_MESSAGE = b"AT+Z\r" ALL_MESSAGES = {DISCOVER_MESSAGE, VERSION_MESSAGE, REMOTE_ACCESS_MESSAGE} BROADCAST_ADDRESS = "" def __init__(self) -> None: self._discoveries: Dict[str, FluxLEDDiscovery] = {} @property def found_bulbs(self) -> List[FluxLEDDiscovery]: """Return only complete bulb discoveries.""" return [info for info in self._discoveries.values() if info["id"]] def getBulbInfoByID(self, id: str) -> FluxLEDDiscovery: for b in self.found_bulbs: if b["id"] == id: return b return b def getBulbInfo(self) -> List[FluxLEDDiscovery]: return self.found_bulbs def _create_socket(self) -> socket.socket: return create_udp_socket(self.DISCOVERY_PORT) def _destination_from_address(self, address: Optional[str]) -> Tuple[str, int]: if address is None: address = self.BROADCAST_ADDRESS return (address, self.DISCOVERY_PORT) def _process_response( self, data: Optional[bytes], from_address: Tuple[str, int], address: Optional[str], response_list: Dict[str, FluxLEDDiscovery], ) -> bool: """Process a response. Returns True if processing should stop """ if data is None: return False if data in self.ALL_MESSAGES: return False decoded_data = data.decode("ascii") self._process_data(from_address, decoded_data, response_list) if address is None or address not in response_list: return False response = response_list[address] return is_legacy_device(response) or ( response[ATTR_MODEL_NUM] is not None and response[ATTR_REMOTE_ACCESS_ENABLED] is not None ) def _process_data( self, from_address: Tuple[str, int], decoded_data: str, response_list: Dict[str, FluxLEDDiscovery], ) -> None: """Process data.""" from_ipaddr = from_address[0] data = response_list.setdefault( from_ipaddr, FluxLEDDiscovery( ipaddr=from_ipaddr, id=None, model=None, model_num=None, version_num=None, firmware_date=None, model_info=None, model_description=None, remote_access_enabled=None, remote_access_host=None, remote_access_port=None, ), ) if ( decoded_data.startswith("+ok=T") or decoded_data == "+ok=" or decoded_data == "+ok=\r" ): _process_remote_access_message(data, decoded_data) if decoded_data.startswith("+ok="): _process_version_message(data, decoded_data) elif "," in decoded_data: _process_discovery_message(data, decoded_data) def _get_start_messages( self, ) -> List[bytes]: return [self.DISCOVER_MESSAGE] def _get_enable_remote_access_messages( self, remote_access_host: str, remote_access_port: int, ) -> List[bytes]: enable_message = f"AT+SOCKB=TCP,{remote_access_port},{remote_access_host}\r" return [enable_message.encode()] def _get_disable_remote_access_messages( self, ) -> List[bytes]: return [self.DISABLE_REMOTE_ACCESS_MESSAGE] def _get_reboot_messages( self, ) -> List[bytes]: return [self.REBOOT_MESSAGE] def _send_message( self, sender: Union[socket.socket, asyncio.DatagramTransport], destination: Tuple[str, int], message: bytes, ) -> None: _LOGGER.debug("udp: %s => %s", destination, message) sender.sendto(message, destination) def _send_messages( self, messages: List[bytes], sender: Union[socket.socket, asyncio.DatagramTransport], destination: Tuple[str, int], ) -> None: """Send messages with a short delay between them.""" for idx, message in enumerate(messages): self._send_message(sender, destination, message) if idx != len(messages): time.sleep(MESSAGE_SEND_INTERLEAVE_DELAY) def get_discovery_messages( self, ) -> List[bytes]: return [self.DISCOVER_MESSAGE, self.VERSION_MESSAGE, self.REMOTE_ACCESS_MESSAGE] def scan( self, timeout: int = 10, address: Optional[str] = None ) -> List[FluxLEDDiscovery]: """Scan for bulbs. If an address is provided, the scan will return as soon as it gets a response from that address """ discovery_messages = self.get_discovery_messages() sock = self._create_socket() destination = self._destination_from_address(address) # set the time at which we will quit the search quit_time = time.monotonic() + timeout found_all = False # outer loop for query send while not found_all: if time.monotonic() > quit_time: break # send out a broadcast query self._send_messages(discovery_messages, sock, destination) # inner loop waiting for responses while True: sock.settimeout(1) remain_time = quit_time - time.monotonic() time_out = min(remain_time, timeout / self.BROADCAST_FREQUENCY) if time_out <= 0: break read_ready, _, _ = select.select([sock], [], [], time_out) if not read_ready: if time.monotonic() < quit_time: # No response, send broadcast again in cast it got lost self._send_messages(discovery_messages, sock, destination) continue try: data, addr = sock.recvfrom(self.RESPONSE_SIZE) _LOGGER.debug("discover: %s <= %s", addr, data) except socket.timeout: continue if self._process_response(data, addr, address, self._discoveries): found_all = True break return self.found_bulbs Danielhiversen-flux_led-bfd1bbe/flux_led/sock.py000066400000000000000000000031151447734565100222340ustar00rootroot00000000000000import logging from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast from .const import DEFAULT_RETRIES _LOGGER = logging.getLogger(__name__) WrapFuncType = TypeVar("WrapFuncType", bound=Callable[..., Any]) if TYPE_CHECKING: from .device import WifiLedBulb def _socket_retry(attempts: int = DEFAULT_RETRIES) -> WrapFuncType: # type: ignore[type-var, misc] """Define a wrapper to retry on socket failures.""" def decorator_retry(func: WrapFuncType) -> WrapFuncType: def _retry_wrap( self: "WifiLedBulb", *args: Any, retry: int = attempts, **kwargs: Any, ) -> Any: attempts_remaining = retry + 1 while attempts_remaining: attempts_remaining -= 1 try: ret = func(self, *args, **kwargs) self.set_available(f"{func.__name__} was successful") return ret except OSError as ex: _LOGGER.debug( "%s: socket error while calling %s: %s", self.ipaddr, func, ex ) if attempts_remaining: continue self.set_unavailable(f"{func.__name__} failed: {ex}") self.close() # We need to raise or the bulb will # always be seen as available in Home Assistant # when it goes offline raise return cast(WrapFuncType, _retry_wrap) return cast(WrapFuncType, decorator_retry) Danielhiversen-flux_led-bfd1bbe/flux_led/timer.py000066400000000000000000000262161447734565100224240ustar00rootroot00000000000000import datetime from typing import Optional, Union from .pattern import PresetPattern from .utils import utils class BuiltInTimer: sunrise = 0xA1 sunset = 0xA2 @staticmethod def valid(byte_value: int) -> bool: return byte_value == BuiltInTimer.sunrise or byte_value == BuiltInTimer.sunset @staticmethod def valtostr(pattern: int) -> str: for key, value in list(BuiltInTimer.__dict__.items()): if type(value) is int and value == pattern: return key.replace("_", " ").title() raise ValueError(f"{pattern} must be 0xA1 or 0xA2") class LedTimer: Mo = 0x02 Tu = 0x04 We = 0x08 Th = 0x10 Fr = 0x20 Sa = 0x40 Su = 0x80 Everyday = Mo | Tu | We | Th | Fr | Sa | Su Weekdays = Mo | Tu | We | Th | Fr Weekend = Sa | Su @staticmethod def dayMaskToStr(mask: int) -> str: for key, value in LedTimer.__dict__.items(): if type(value) is int and value == mask: return key raise ValueError( f"{mask} must be one of 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80" ) def __init__( self, bytes: Optional[Union[bytes, bytearray]] = None, length: int = 14 ) -> None: self.cold_level = 0 self.pattern_code = 0 self.delay = 0 if bytes is not None: self.length = len(bytes) self.fromBytes(bytes) return self.length = length the_time = datetime.datetime.now() + datetime.timedelta(hours=1) self.setTime(the_time.hour, the_time.minute) self.setDate(the_time.year, the_time.month, the_time.day) self.setModeTurnOff() self.setActive(False) def setActive(self, active: bool = True) -> None: self.active = active def isActive(self) -> bool: return self.active def isExpired(self) -> bool: # if no repeat mask and datetime is in past, return True if self.repeat_mask != 0: return False elif self.year != 0 and self.month != 0 and self.day != 0: dt = datetime.datetime( self.year, self.month, self.day, self.hour, self.minute ) if utils.date_has_passed(dt): return True return False def setTime(self, hour: int, minute: int) -> None: self.hour = hour self.minute = minute def setDate(self, year: int, month: int, day: int) -> None: self.year = year self.month = month self.day = day self.repeat_mask = 0 def setRepeatMask(self, repeat_mask: int) -> None: self.year = 0 self.month = 0 self.day = 0 self.repeat_mask = repeat_mask def setModeDefault(self) -> None: self.mode = "default" self.pattern_code = 0 self.turn_on = True self.red = 0 self.green = 0 self.blue = 0 self.warmth_level = 0 self.cold_level = 0 def setModePresetPattern(self, pattern: int, speed: int) -> None: self.mode = "preset" self.warmth_level = 0 self.cold_level = 0 self.pattern_code = pattern self.delay = utils.speedToDelay(speed) self.turn_on = True def setModeColor(self, r: int, g: int, b: int) -> None: self.mode = "color" self.warmth_level = 0 self.cold_level = 0 self.red = r self.green = g self.blue = b self.pattern_code = 0x61 self.turn_on = True def setModeWarmWhite(self, level: int) -> None: self.mode = "ww" self.warmth_level = utils.percentToByte(level) self.cold_level = 0 self.pattern_code = 0x61 self.red = 0 self.green = 0 self.blue = 0 self.turn_on = True def setModeSunrise( self, startBrightness: int, endBrightness: int, duration: int ) -> None: self.mode = "sunrise" self.turn_on = True self.pattern_code = BuiltInTimer.sunrise self.brightness_start = utils.percentToByte(startBrightness) self.brightness_end = utils.percentToByte(endBrightness) self.warmth_level = utils.percentToByte(endBrightness) self.cold_level = 0 self.duration = int(duration) def setModeSunset( self, startBrightness: int, endBrightness: int, duration: int ) -> None: self.mode = "sunrise" self.turn_on = True self.pattern_code = BuiltInTimer.sunset self.brightness_start = utils.percentToByte(startBrightness) self.brightness_end = utils.percentToByte(endBrightness) self.warmth_level = utils.percentToByte(endBrightness) self.cold_level = 0 self.duration = int(duration) def setModeTurnOff(self) -> None: self.mode = "off" self.turn_on = False self.pattern_code = 0 """ timer are in six 14-byte structs f0 0f 08 10 10 15 00 00 25 1f 00 00 00 f0 0f 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 0: f0 when active entry/ 0f when not active 1: (0f=15) year when no repeat, else 0 2: month when no repeat, else 0 3: dayofmonth when no repeat, else 0 4: hour 5: min 6: 0 7: repeat mask, Mo=0x2,Tu=0x04, We 0x8, Th=0x10 Fr=0x20, Sa=0x40, Su=0x80 8: 61 for solid color or warm, or preset pattern code 9: r (or delay for preset pattern) 10: g 11: b 12: warm white level 13: 0f = turn off, f0 = turn on timer are in six 15-byte structs for 9 byte devices f0 0f 08 10 10 15 00 00 25 1f 00 00 00 f0 0f 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 0: f0 when active entry/ 0f when not active 1: (0f=15) year when no repeat, else 0 2: month when no repeat, else 0 3: dayofmonth when no repeat, else 0 4: hour 5: min 6: 0 7: repeat mask, Mo=0x2,Tu=0x04, We 0x8, Th=0x10 Fr=0x20, Sa=0x40, Su=0x80 8: 61 for solid color or warm, or preset pattern code 9: r (or delay for preset pattern) 10: g 11: b 12: warm white level 13: cold white level 14: 0f = turn off, f0 = turn on """ def fromBytes(self, bytes: Union[bytes, bytearray]) -> None: # noqa: C901 self.red = 0 self.green = 0 self.blue = 0 if bytes[0] == 0xF0: self.active = True else: self.active = False self.year = bytes[1] + 2000 self.month = bytes[2] self.day = bytes[3] self.hour = bytes[4] self.minute = bytes[5] self.repeat_mask = bytes[7] if len(bytes) == 12: # sockets if bytes[8] == 0x23: self.turn_on = True else: self.turn_on = False self.mode = "off" return self.pattern_code = bytes[8] if self.pattern_code == 0x00: self.mode = "default" elif self.pattern_code == 0x61: self.mode = "color" self.red = bytes[9] self.green = bytes[10] self.blue = bytes[11] elif BuiltInTimer.valid(self.pattern_code): self.mode = BuiltInTimer.valtostr(self.pattern_code) self.duration = bytes[9] # same byte as red self.brightness_start = bytes[10] # same byte as green self.brightness_end = bytes[11] # same byte as blue elif PresetPattern.valid(self.pattern_code): self.mode = "preset" self.delay = bytes[9] # same byte as red else: self.mode = "unknown" self.warmth_level = bytes[12] if self.warmth_level != 0: self.mode = "ww" if len(bytes) == 15: # 9 byte protocol self.cold_level = bytes[13] on_byte = bytes[14] else: # 8 byte protocol on_byte = bytes[13] if on_byte == 0xF0: self.turn_on = True else: self.turn_on = False self.mode = "off" def toBytes(self) -> bytearray: # noqa: C901 bytes = bytearray(self.length) if not self.active: bytes[0] = 0x0F # quit since all other zeros is good return bytes bytes[0] = 0xF0 if self.year >= 2000: bytes[1] = self.year - 2000 else: bytes[1] = self.year bytes[2] = self.month bytes[3] = self.day bytes[4] = self.hour bytes[5] = self.minute # what is 6? bytes[7] = self.repeat_mask if self.length == 12: bytes[8] == 0x23 if self.turn_on else 0x24 return bytes on_byte_num = 14 if self.length == 15 else 13 if not self.turn_on: bytes[on_byte_num] = 0x0F return bytes bytes[on_byte_num] = 0xF0 bytes[8] = self.pattern_code if PresetPattern.valid(self.pattern_code): bytes[9] = self.delay bytes[10] = 0 bytes[11] = 0 elif BuiltInTimer.valid(self.pattern_code): bytes[9] = self.duration bytes[10] = self.brightness_start bytes[11] = self.brightness_end else: bytes[9] = self.red bytes[10] = self.green bytes[11] = self.blue bytes[12] = self.warmth_level if self.length == 15: bytes[13] = self.cold_level return bytes def __str__(self) -> str: txt = "" if not self.active: return "Unset" if self.turn_on: txt += "[ON ]" else: txt += "[OFF]" txt += " " txt += f"{self.hour:02}:{self.minute:02} " if self.repeat_mask == 0: txt += f"Once: {self.year:04}-{self.month:02}-{self.day:02}" else: bits = [ LedTimer.Su, LedTimer.Mo, LedTimer.Tu, LedTimer.We, LedTimer.Th, LedTimer.Fr, LedTimer.Sa, ] for b in bits: if self.repeat_mask & b: txt += LedTimer.dayMaskToStr(b) else: txt += "--" txt += " " txt += " " if self.pattern_code == 0x61: if self.warmth_level != 0: txt += f"Warm White: {utils.byteToPercent(self.warmth_level)}%" else: color_str = utils.color_tuple_to_string( (self.red, self.green, self.blue) ) txt += f"Color: {color_str}" elif PresetPattern.valid(self.pattern_code): pat = PresetPattern.valtostr(self.pattern_code) speed = utils.delayToSpeed(self.delay) txt += f"{pat} (Speed:{speed}%)" elif BuiltInTimer.valid(self.pattern_code): type = BuiltInTimer.valtostr(self.pattern_code) txt += "{} (Duration:{} minutes, Brightness: {}% -> {}%)".format( type, self.duration, utils.byteToPercent(self.brightness_start), utils.byteToPercent(self.brightness_end), ) return txt Danielhiversen-flux_led-bfd1bbe/flux_led/utils.py000066400000000000000000000257511447734565100224470ustar00rootroot00000000000000import ast import colorsys import contextlib import datetime from collections import namedtuple from typing import Iterable, List, Optional, Tuple, Union, cast import webcolors # type: ignore from .const import MAX_TEMP, MIN_TEMP MAX_MIN_TEMP_DIFF = MAX_TEMP - MIN_TEMP WhiteLevels = namedtuple( "WhiteLevels", [ "warm_white", "cool_white", ], ) TemperatureBrightness = namedtuple( "TemperatureBrightness", [ "temperature", "brightness", ], ) class utils: @staticmethod def color_object_to_tuple( color: Union[Tuple[int, ...], str] ) -> Optional[Tuple[int, ...]]: # see if it's already a color tuple if isinstance(color, tuple) and len(color) in [3, 4, 5]: return color # can't convert non-string if not isinstance(color, str): return None color = color.strip() # try to convert from an english name with contextlib.suppress(Exception): return cast(Tuple[int, int, int], webcolors.name_to_rgb(color)) # try to convert an web hex code with contextlib.suppress(Exception): return cast( Tuple[int, int, int], webcolors.hex_to_rgb(webcolors.normalize_hex(color)), ) # try to convert a string RGB tuple with contextlib.suppress(Exception): val = ast.literal_eval(color) if type(val) is not tuple or len(val) not in [3, 4, 5]: raise Exception return val return None @staticmethod def color_tuple_to_string(rgb: Tuple[int, int, int]) -> str: # try to convert to an english name with contextlib.suppress(Exception): return cast(str, webcolors.rgb_to_name(rgb)) return str(rgb) @staticmethod def get_color_names_list() -> List[str]: return sorted( { *webcolors.CSS2_HEX_TO_NAMES.values(), *webcolors.CSS21_HEX_TO_NAMES.values(), *webcolors.CSS3_HEX_TO_NAMES.values(), *webcolors.HTML4_HEX_TO_NAMES.values(), } ) @staticmethod def date_has_passed(dt: datetime.datetime) -> bool: return (dt - datetime.datetime.now()).total_seconds() < 0 @staticmethod def raw_state_to_dec(rx: Iterable[int]) -> str: raw_state_str = "" for _r in rx: raw_state_str += str(_r) + "," return raw_state_str max_delay = 0x1F @staticmethod def delayToSpeed(delay: int) -> int: # speed is 0-100, delay is 1-31 # 1st translate delay to 0-30 delay = delay - 1 delay = max(0, min(utils.max_delay - 1, delay)) inv_speed = int((delay * 100) / (utils.max_delay - 1)) speed = 100 - inv_speed return speed @staticmethod def speedToDelay(speed: int) -> int: # speed is 0-100, delay is 1-31 speed = max(0, min(100, speed)) inv_speed = 100 - speed delay = int((inv_speed * (utils.max_delay - 1)) / 100) # translate from 0-30 to 1-31 delay = delay + 1 return delay @staticmethod def byteToPercent(byte: int) -> int: return int((max(0, min(255, byte)) * 100) / 255) @staticmethod def percentToByte(percent: int) -> int: return int((max(0, min(100, percent)) * 255) / 100) @staticmethod def A3WarmWhiteToByte(val: int) -> int: return round(((min(228, max(128, val)) - 128) * 255) / 100) def rgbwc_to_rgbcw( rgbwc_data: Tuple[int, int, int, int, int] ) -> Tuple[int, int, int, int, int]: r, g, b, w, c = rgbwc_data return r, g, b, c, w def rgbcw_to_rgbwc( rgbcw_data: Tuple[int, int, int, int, int] ) -> Tuple[int, int, int, int, int]: r, g, b, c, w = rgbcw_data return r, g, b, w, c def _adjust_brightness( current_brightness: int, new_brightness: int, color_brightness: int, cw_brightness: int, ww_brightness: int, ) -> Tuple[int, int, int]: if new_brightness < current_brightness: change_brightness_pct = ( current_brightness - new_brightness ) / current_brightness ww_brightness = round(ww_brightness * (1 - change_brightness_pct)) color_brightness = round(color_brightness * (1 - change_brightness_pct)) cw_brightness = round(cw_brightness * (1 - change_brightness_pct)) else: change_brightness_pct = (new_brightness - current_brightness) / ( 255 - current_brightness ) ww_brightness = round( (255 - ww_brightness) * change_brightness_pct + ww_brightness ) color_brightness = round( (255 - color_brightness) * change_brightness_pct + color_brightness ) cw_brightness = round( (255 - cw_brightness) * change_brightness_pct + cw_brightness ) return color_brightness, cw_brightness, ww_brightness def rgbw_brightness( rgbw_data: Tuple[int, int, int, int], brightness: Optional[int] = None, ) -> Tuple[int, int, int, int]: """Convert rgbw to brightness.""" original_r, original_g, original_b = rgbw_data[0:3] h, s, v = colorsys.rgb_to_hsv(original_r / 255, original_g / 255, original_b / 255) color_brightness = round(v * 255) ww_brightness = rgbw_data[3] current_brightness = round((color_brightness + ww_brightness) / 2) if not brightness or brightness == current_brightness: return rgbw_data if brightness < current_brightness: change_brightness_pct = (current_brightness - brightness) / current_brightness ww_brightness = round(ww_brightness * (1 - change_brightness_pct)) color_brightness = round(color_brightness * (1 - change_brightness_pct)) else: change_brightness_pct = (brightness - current_brightness) / ( 255 - current_brightness ) ww_brightness = round( (255 - ww_brightness) * change_brightness_pct + ww_brightness ) color_brightness = round( (255 - color_brightness) * change_brightness_pct + color_brightness ) r, g, b = colorsys.hsv_to_rgb(h, s, color_brightness / 255) return (round(r * 255), round(g * 255), round(b * 255), ww_brightness) def rgbww_brightness( rgbww_data: Tuple[int, int, int, int, int], brightness: Optional[int] = None, ) -> Tuple[int, int, int, int, int]: """Convert rgbww to brightness.""" original_r, original_g, original_b = rgbww_data[0:3] h, s, v = colorsys.rgb_to_hsv(original_r / 255, original_g / 255, original_b / 255) color_brightness = round(v * 255) ww_brightness = rgbww_data[3] cw_brightness = rgbww_data[4] current_brightness = round((color_brightness + ww_brightness + cw_brightness) / 3) if not brightness or brightness == current_brightness: return rgbww_data color_brightness, cw_brightness, ww_brightness = _adjust_brightness( current_brightness, brightness, color_brightness, cw_brightness, ww_brightness ) r, g, b = colorsys.hsv_to_rgb(h, s, color_brightness / 255) return ( round(r * 255), round(g * 255), round(b * 255), ww_brightness, cw_brightness, ) def rgbcw_brightness( rgbcw_data: Tuple[int, int, int, int, int], brightness: Optional[int] = None, ) -> Tuple[int, int, int, int, int]: """Convert rgbww to brightness.""" original_r, original_g, original_b = rgbcw_data[0:3] h, s, v = colorsys.rgb_to_hsv(original_r / 255, original_g / 255, original_b / 255) color_brightness = round(v * 255) cw_brightness = rgbcw_data[3] ww_brightness = rgbcw_data[4] current_brightness = round((color_brightness + ww_brightness + cw_brightness) / 3) if not brightness or brightness == current_brightness: return rgbcw_data color_brightness, cw_brightness, ww_brightness = _adjust_brightness( current_brightness, brightness, color_brightness, cw_brightness, ww_brightness ) r, g, b = colorsys.hsv_to_rgb(h, s, color_brightness / 255) return ( round(r * 255), round(g * 255), round(b * 255), cw_brightness, ww_brightness, ) def color_temp_to_white_levels( temperature: int, brightness: float, min_temp: int = MIN_TEMP, max_temp: int = MAX_TEMP, ) -> WhiteLevels: # Assume output temperature of between 2700 and 6500 Kelvin, and scale # the warm and cold LEDs linearly to provide that temperature = min(max(min_temp, temperature), max_temp) if not (0 <= brightness <= 255): raise ValueError( f"Brightness of {brightness} is not valid and must be between 0 and 255" ) brightness = round(brightness / 255, 2) warm = ((max_temp - temperature) / (max_temp - min_temp)) * brightness cold = brightness - warm return WhiteLevels(round(255 * warm), round(255 * cold)) def scaled_color_temp_to_white_levels( temperature: int, brightness: float ) -> WhiteLevels: # Assume output temperature of between 0 and 100, and scale # the warm and cold LEDs linearly to provide that if not (0 <= temperature <= 100): raise ValueError( f"Temperature of {temperature} is not valid and must be between {0} and {100}" ) if not (0 <= brightness <= 100): raise ValueError( f"Brightness of {brightness} is not valid and must be between 0 and 100" ) brightness = round(brightness / 100, 2) warm = ((100 - temperature) / 100) * brightness cold = brightness - warm return WhiteLevels(round(255 * warm), round(255 * cold)) def white_levels_to_color_temp( warm_white: int, cool_white: int, min_temp: int = MIN_TEMP, max_temp: int = MAX_TEMP ) -> TemperatureBrightness: if not (0 <= warm_white <= 255): raise ValueError( f"Warm White of {warm_white} is not valid and must be between 0 and 255" ) if not (0 <= cool_white <= 255): raise ValueError( f"Cool White of {cool_white} is not valid and must be between 0 and 255" ) warm = warm_white / 255 cold = cool_white / 255 brightness = warm + cold if brightness == 0: temperature: float = min_temp else: temperature = ((cold / brightness) * (max_temp - min_temp)) + min_temp return TemperatureBrightness(round(temperature), min(255, round(brightness * 255))) def white_levels_to_scaled_color_temp( warm_white: int, cool_white: int ) -> TemperatureBrightness: if not (0 <= warm_white <= 255): raise ValueError( f"Warm White of {warm_white} is not valid and must be between 0 and 255" ) if not (0 <= cool_white <= 255): raise ValueError( f"Cool White of {cool_white} is not valid and must be between 0 and 255" ) warm = warm_white / 255 cold = cool_white / 255 brightness = warm + cold if brightness == 0: temperature: float = 0 else: temperature = (cold / brightness) * 100 return TemperatureBrightness(round(temperature), min(100, round(brightness * 100))) Danielhiversen-flux_led-bfd1bbe/mypy.ini000066400000000000000000000012701447734565100206200ustar00rootroot00000000000000[mypy] python_version = 3.7 warn_unused_configs = true disable_error_code = no-redef exclude = dist disallow_any_generics = true disallow_subclassing_any = true disallow_untyped_calls = true disallow_untyped_defs = true disallow_incomplete_defs = true check_untyped_defs = true disallow_untyped_decorators = true no_implicit_optional = true warn_redundant_casts = true warn_unused_ignores = true warn_return_any = true no_implicit_reexport = true strict_equality = true implicit_reexport = true [mypy-tests.*] disallow_untyped_calls = false disallow_untyped_defs = false disallow_incomplete_defs = false check_untyped_defs = false [mypy-pytest_benchmark.fixture] ignore_missing_imports = true Danielhiversen-flux_led-bfd1bbe/pytest.ini000066400000000000000000000000331447734565100211460ustar00rootroot00000000000000[pytest] asyncio_mode=auto Danielhiversen-flux_led-bfd1bbe/requirements.txt000066400000000000000000000000211447734565100223760ustar00rootroot00000000000000webcolors==1.11.1Danielhiversen-flux_led-bfd1bbe/requirements_test.txt000066400000000000000000000001341447734565100234420ustar00rootroot00000000000000webcolors pylint==2.17.4 pytest-asyncio==0.21.1 pytest-cov==4.1.0 mypy==1.4.1 flake8==6.0.0 Danielhiversen-flux_led-bfd1bbe/setup.cfg000066400000000000000000000011261447734565100207420ustar00rootroot00000000000000[metadata] description-file = README.md [coverage:run] omit = exampeles/* */dist-packages/* tests.py [flake8] exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build max-complexity = 25 doctests = True # To work with Black # E501: line too long # W503: Line break occurred before a binary operator # E203: Whitespace before ':' # D202 No blank lines allowed after function docstring # W504 line break after binary operator ignore = E501, W503, E203, D202, W504, D100 D101, D102, D103, D105, D107, D400, D401 noqa-require-code = True Danielhiversen-flux_led-bfd1bbe/setup.py000066400000000000000000000044271447734565100206420ustar00rootroot00000000000000# coding=utf-8 from setuptools import setup setup_requirements = [ "pytest-runner>=5.2", ] test_requirements = [ "pytest-asyncio", "black>=19.10b0", "codecov>=2.1.4", "flake8>=3.8.3", "flake8-debugger>=3.2.1", "pytest>=5.4.3", "pytest-cov>=2.9.0", "pytest-raises>=0.11", ] dev_requirements = [ *setup_requirements, *test_requirements, "bump2version>=1.0.1", "coverage>=5.1", "ipython>=7.15.0", "m2r2>=0.2.7", "pytest-runner>=5.2", "Sphinx>=3.4.3", "sphinx_rtd_theme>=0.5.1", "tox>=3.15.2", "twine>=3.1.1", "wheel>=0.34.2", ] requirements = ["webcolors", 'typing_extensions;python_version<"3.8"', "async_timeout>=3.0.0"] extra_requirements = { "setup": setup_requirements, "test": test_requirements, "dev": dev_requirements, "all": [ *requirements, *dev_requirements, ], } setup( name="flux_led", packages=["flux_led"], version="1.0.4", description="A Python library to communicate with the flux_led smart bulbs", author="Daniel Hjelseth Høyer", author_email="mail@dahoiv.net", url="https://github.com/Danielhiversen/flux_led", license="LGPLv3+", include_package_data=True, package_data={"flux_led": ["py.typed"]}, keywords=[ "flux_led", "smart bulbs", "light", ], classifiers=[ "Development Status :: 3 - Alpha", "Environment :: Other Environment", "Intended Audience :: Developers", "License :: OSI Approved :: " + "GNU Lesser General Public License v3 or later (LGPLv3+)", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Topic :: Home Automation", "Topic :: Software Development :: Libraries :: Python Modules", ], python_requires=">=3.7", setup_requires=setup_requirements, tests_require=test_requirements, extras_require=extra_requirements, entry_points={"console_scripts": ["flux_led = flux_led.fluxled:main"]}, install_requires=requirements, ) Danielhiversen-flux_led-bfd1bbe/tests.py000066400000000000000000003067701447734565100206520ustar00rootroot00000000000000import datetime import unittest import unittest.mock as mock from unittest.mock import patch import pytest import flux_led from flux_led.const import ( COLOR_MODE_CCT, COLOR_MODE_DIM, COLOR_MODE_RGB, COLOR_MODE_RGBW, COLOR_MODE_RGBWW, STATE_BLUE, STATE_GREEN, STATE_RED, STATE_WARM_WHITE, TRANSITION_GRADUAL, MultiColorEffects, ) from flux_led.pattern import PresetPattern from flux_led.protocol import ( PROTOCOL_LEDENET_8BYTE, PROTOCOL_LEDENET_8BYTE_AUTO_ON, PROTOCOL_LEDENET_8BYTE_DIMMABLE_EFFECTS, PROTOCOL_LEDENET_9BYTE, PROTOCOL_LEDENET_9BYTE_AUTO_ON, PROTOCOL_LEDENET_9BYTE_DIMMABLE_EFFECTS, PROTOCOL_LEDENET_ADDRESSABLE_A1, PROTOCOL_LEDENET_ADDRESSABLE_A2, PROTOCOL_LEDENET_ADDRESSABLE_A3, PROTOCOL_LEDENET_ORIGINAL, PROTOCOL_LEDENET_ORIGINAL_CCT, PROTOCOL_LEDENET_SOCKET, ) from flux_led.utils import ( color_temp_to_white_levels, rgbcw_brightness, rgbcw_to_rgbwc, rgbw_brightness, rgbwc_to_rgbcw, rgbww_brightness, scaled_color_temp_to_white_levels, utils, white_levels_to_color_temp, white_levels_to_scaled_color_temp, ) LEDENET_STATE_QUERY = b"\x81\x8a\x8b\x96" class TestLight(unittest.TestCase): @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_connect(self, mock_connect, mock_read, mock_send): """Test setup with minimum configuration.""" calls = 0 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray(b"\x81E") if calls == 2: self.assertEqual(expected, 12) return bytearray(b"#a!\x10g\xffh\x00\x04\x00\xf0\x3d") raise Exception mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.166") assert light.color_modes == {COLOR_MODE_RGB, COLOR_MODE_DIM} self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual( light.__str__(), "ON [Color: (103, 255, 104) Brightness: 100% raw state: 129,69,35,97,33,16,103,255,104,0,4,0,240,61,]", ) self.assertEqual(light.protocol, PROTOCOL_LEDENET_8BYTE) self.assertEqual(light.model_num, 0x45) self.assertEqual(light.model, "Unknown Model (0x45)") self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "color") self.assertEqual(light.warm_white, 0) self.assertEqual(light.brightness, 255) self.assertEqual(light.getRgb(), (103, 255, 104)) self.assertEqual(light.rgb, (103, 255, 104)) self.assertEqual(light.rgb_unscaled, (103, 255, 104)) self.assertEqual(light.rgbwcapable, False) self.assertEqual(light.device_type, flux_led.DeviceType.Bulb) @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_rgb(self, mock_connect, mock_read, mock_send): calls = 0 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray(b"\x81E") if calls == 2: self.assertEqual(expected, 12) return bytearray(b"#a!\x10g\xffh\x00\x04\x00\xf0\x3d") if calls == 3: self.assertEqual(expected, 14) return bytearray(b"\x81E#a!\x10\x01\x19P\x00\x04\x00\xf0\xd9") mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") self.assertEqual(light.model_num, 0x45) self.assertEqual(light.model, "Unknown Model (0x45)") assert light.color_modes == {COLOR_MODE_RGB, COLOR_MODE_DIM} self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) light.setRgb(1, 25, 80) self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 2) self.assertEqual( mock_send.call_args, mock.call(bytearray(b"1\x01\x19P\x00\xf0\x0f\x9a")) ) self.assertEqual(light.getRgb(), (1, 25, 80)) self.assertEqual(light.rgb, (1, 25, 80)) self.assertEqual(light.rgb_unscaled, (3, 80, 255)) light._transition_complete_time = 0 light.update_state() self.assertEqual(mock_read.call_count, 3) self.assertEqual(mock_send.call_count, 3) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual( light.__str__(), "ON [Color: (1, 25, 80) Brightness: 31% raw state: 129,69,35,97,33,16,1,25,80,0,4,0,240,217,]", ) self.assertEqual(light.protocol, PROTOCOL_LEDENET_8BYTE) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "color") self.assertEqual(light.warm_white, 0) self.assertEqual(light.brightness, 80) self.assertEqual(light.getRgb(), (1, 25, 80)) self.assertEqual(light.device_type, flux_led.DeviceType.Bulb) self.assertEqual(light.version_num, 4) @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_off_on(self, mock_connect, mock_read, mock_send): calls = 0 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray(b"\x81E") if calls == 2: self.assertEqual(expected, 12) return bytearray(b"#a!\x10\x00\x00\x00\xa6\x04\x00\x0f\x34") if calls == 3: # turn off response self.assertEqual(expected, 4) return bytearray(b"\x0fq#\xa3") if calls == 4: self.assertEqual(expected, 14) return bytearray(b"\x81E$a!\x10\x00\x00\x00\xa6\x04\x00\x0f4") mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") assert light.color_modes == {COLOR_MODE_RGB, COLOR_MODE_DIM} self.assertEqual( light.__str__(), "ON [Warm White: 65% raw state: 129,69,35,97,33,16,0,0,0,166,4,0,15,52,]", ) self.assertEqual(light.protocol, PROTOCOL_LEDENET_8BYTE) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "ww") self.assertEqual(light.warm_white, 0) self.assertEqual(light.brightness, 166) self.assertEqual(light.getRgb(), (255, 255, 255)) self.assertEqual(light.rgbwcapable, False) self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) light.turnOff() self.assertEqual(mock_read.call_count, 3) self.assertEqual(mock_send.call_count, 2) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"q$\x0f\xa4"))) light._transition_complete_time = 0 light.update_state() self.assertEqual(mock_read.call_count, 4) self.assertEqual(mock_send.call_count, 3) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"\x81\x8a\x8b\x96"))) self.assertEqual( light.__str__(), "OFF [Warm White: 65% raw state: 129,69,36,97,33,16,0,0,0,166,4,0,15,52,]", ) self.assertEqual(light.protocol, PROTOCOL_LEDENET_8BYTE) self.assertEqual(light.is_on, False) self.assertEqual(light.mode, "ww") self.assertEqual(light.warm_white, 0) self.assertEqual(light.brightness, 166) self.assertEqual(light.getRgb(), (255, 255, 255)) self.assertEqual(light.rgbwcapable, False) self.assertEqual(light.device_type, flux_led.DeviceType.Bulb) @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_ww(self, mock_connect, mock_read, mock_send): calls = 0 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray(b"\x81E") if calls == 2: self.assertEqual(expected, 12) return bytearray(b"#a!\x10\xb6\x00\x98\x00\x04\x00\xf0\xbd") if calls == 3: self.assertEqual(expected, 14) return bytearray(b"\x81E#a!\x10\x00\x00\x00\x19\x04\x00\x0f\xa7") mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") assert light.color_modes == {COLOR_MODE_RGB, COLOR_MODE_DIM} self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual( light.__str__(), "ON [Color: (182, 0, 152) Brightness: 71% raw state: 129,69,35,97,33,16,182,0,152,0,4,0,240,189,]", ) self.assertEqual(light.protocol, PROTOCOL_LEDENET_8BYTE) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "color") self.assertEqual(light.warm_white, 0) self.assertEqual(light.brightness, 182) self.assertEqual(light.getRgb(), (182, 0, 152)) self.assertEqual(light.rgbwcapable, False) self.assertEqual(light.device_type, flux_led.DeviceType.Bulb) light.setWarmWhite255(25) self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 2) self.assertEqual( mock_send.call_args, mock.call(bytearray(b"1\x00\x00\x00\x19\x0f\x0fh")) ) light._transition_complete_time = 0 light.update_state() self.assertEqual(mock_read.call_count, 3) self.assertEqual(mock_send.call_count, 3) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"\x81\x8a\x8b\x96"))) self.assertEqual( light.__str__(), "ON [Warm White: 9% raw state: 129,69,35,97,33,16,0,0,0,25,4,0,15,167,]", ) self.assertEqual(light.protocol, PROTOCOL_LEDENET_8BYTE) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "ww") self.assertEqual(light.warm_white, 0) self.assertEqual(light.brightness, 25) self.assertEqual(light.getRgb(), (255, 255, 255)) self.assertEqual(light.rgbwcapable, False) self.assertEqual(light.device_type, flux_led.DeviceType.Bulb) @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_switch(self, mock_connect, mock_read, mock_send): calls = 0 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray(b"\x81\x97") if calls == 2: self.assertEqual(expected, 12) return bytearray(b"$$\x00\x00\x00\x00\x00\x00\x02\x00\x00b") if calls == 3: # turn on response self.assertEqual(expected, 4) return bytearray(b"\x0fq#\xa3") if calls == 4: self.assertEqual(expected, 14) return bytearray(b"\x81\x97##\x00\x00\x00\x00\x00\x00\x02\x00\x00`") mock_read.side_effect = read_data switch = flux_led.WifiLedBulb("192.168.1.164") assert switch.color_modes == set() self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"\x81\x8a\x8b\x96"))) self.assertEqual( switch.__str__(), "OFF [Switch raw state: 129,151,36,36,0,0,0,0,0,0,2,0,0,98,]", ) self.assertEqual(switch.protocol, PROTOCOL_LEDENET_SOCKET) self.assertEqual(switch.is_on, False) self.assertEqual(switch.mode, "switch") self.assertEqual(switch.device_type, flux_led.DeviceType.Switch) switch.turnOn() self.assertEqual(mock_send.call_args, mock.call(bytearray(b"q#\x0f\xa3"))) self.assertEqual(mock_read.call_count, 3) self.assertEqual(mock_send.call_count, 2) switch._transition_complete_time = 0 switch.update_state() self.assertEqual(mock_read.call_count, 4) self.assertEqual(mock_send.call_count, 3) self.assertEqual( switch.__str__(), "ON [Switch raw state: 129,151,35,35,0,0,0,0,0,0,2,0,0,96,]", ) self.assertEqual(switch.protocol, PROTOCOL_LEDENET_SOCKET) self.assertEqual(switch.is_on, True) self.assertEqual(switch.device_type, flux_led.DeviceType.Switch) @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_rgb_brightness(self, mock_connect, mock_read, mock_send): calls = 0 def read_data(expected): nonlocal calls calls += 1 if calls == 1: # first part of state response self.assertEqual(expected, 2) return bytearray(b"\x81E") if calls == 2: # second part of state response self.assertEqual(expected, 12) return bytearray(b"$a!\x10\xff[\xd4\x00\x04\x00\xf0\x9e") if calls == 3: # turn on response self.assertEqual(expected, 4) return bytearray(b"\x0fq#\xa3") if calls == 4: self.assertEqual(expected, 14) return bytearray(b"\x81E#a!\x10\x03M\xf7\x00\x04\x00\xf0\xb6") mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") assert light.color_modes == {COLOR_MODE_RGB, COLOR_MODE_DIM} self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual( light.__str__(), "OFF [Color: (255, 91, 212) Brightness: 100% raw state: 129,69,36,97,33,16,255,91,212,0,4,0,240,158,]", ) self.assertEqual(light.protocol, PROTOCOL_LEDENET_8BYTE) self.assertEqual(light.getWarmWhite255(), 255) self.assertEqual(light.getCCT(), (255, 255)) self.assertEqual(light.is_on, False) self.assertEqual(light.mode, "color") self.assertEqual(light.warm_white, 0) self.assertEqual(light.brightness, 255) self.assertEqual(light.getRgb(), (255, 91, 212)) self.assertEqual(light.device_type, flux_led.DeviceType.Bulb) light.turnOn() self.assertEqual(mock_read.call_count, 3) self.assertEqual(mock_send.call_count, 2) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"q#\x0f\xa3"))) self.assertEqual( light.__str__(), "ON [Color: (255, 91, 212) Brightness: 100% raw state: 129,69,35,97,33,16,255,91,212,0,4,0,240,158,]", ) self.assertEqual(light.protocol, PROTOCOL_LEDENET_8BYTE) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "color") self.assertEqual(light.warm_white, 0) self.assertEqual(light.brightness, 255) self.assertEqual(light.getRgb(), (255, 91, 212)) light.setRgb(1, 25, 80, brightness=247) self.assertEqual(mock_read.call_count, 3) self.assertEqual(mock_send.call_count, 3) self.assertEqual( mock_send.call_args, mock.call(bytearray(b"1\x03M\xf7\x00\xf0\x0fw")) ) self.assertEqual( light.__str__(), "ON [Color: (3, 77, 247) Brightness: 97% raw state: 129,69,35,97,33,16,3,77,247,0,4,0,240,158,]", ) self.assertEqual(light.protocol, PROTOCOL_LEDENET_8BYTE) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "color") self.assertEqual(light.warm_white, 0) self.assertEqual(light.brightness, 247) self.assertEqual(light.getRgb(), (3, 77, 247)) light._transition_complete_time = 0 light.update_state() self.assertEqual(mock_read.call_count, 4) self.assertEqual(mock_send.call_count, 4) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual( light.__str__(), "ON [Color: (3, 77, 247) Brightness: 97% raw state: 129,69,35,97,33,16,3,77,247,0,4,0,240,182,]", ) self.assertEqual(light.protocol, PROTOCOL_LEDENET_8BYTE) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "color") self.assertEqual(light.warm_white, 0) self.assertEqual(light.brightness, 247) self.assertEqual(light.getRgb(), (3, 77, 247)) @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_rgbww_controller_version_4(self, mock_connect, mock_read, mock_send): calls = 0 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray(b"\x81\x25") if calls == 2: self.assertEqual(expected, 12) return bytearray(b"\x23\x61\x05\x10\xb6\x00\x98\x00\x04\x00\xf0\x81") if calls == 3: self.assertEqual(expected, 14) return bytearray( b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde" ) if calls == 4: self.assertEqual(expected, 14) return bytearray( b"\x81\x25\x23\x38\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xb5" ) if calls == 5: self.assertEqual(expected, 12) return bytearray(b"\x0f\x11\x14\x16\x01\x02\x106\x02\x07\x00\x9c") mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") assert light.color_modes == {COLOR_MODE_RGBWW, COLOR_MODE_CCT} self.assertEqual(light.protocol, PROTOCOL_LEDENET_9BYTE) self.assertEqual(light.model_num, 0x25) self.assertEqual(light.version_num, 4) self.assertEqual(light.microphone, False) self.assertEqual(light.model, "Controller RGB/WW/CW (0x25)") self.assertEqual( light.effect_list, [ "blue_fade", "blue_strobe", "colorjump", "colorloop", "colorstrobe", "cyan_fade", "cyan_strobe", "gb_cross_fade", "green_fade", "green_strobe", "purple_fade", "purple_strobe", "rb_cross_fade", "red_fade", "red_strobe", "rg_cross_fade", "white_fade", "white_strobe", "yellow_fade", "yellow_strobe", "random", ], ) assert light.pixels_per_segment is None assert light.segments is None assert light.music_pixels_per_segment is None assert light.music_segments is None assert light.ic_types is None assert light.ic_type is None assert light.operating_mode == "RGBWW" assert light.operating_modes == ["DIM", "CCT", "RGB", "RGBW", "RGBWW"] assert light.wiring is None assert light.wirings is None self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual(light.protocol, PROTOCOL_LEDENET_9BYTE) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "color") self.assertEqual(light.min_temp, 2700) self.assertEqual(light.max_temp, 6500) self.assertEqual(light.warm_white, 0) self.assertEqual(light.brightness, 61) # RGBWW brightness self.assertEqual(light.getRgb(), (182, 0, 152)) self.assertEqual(light.getRgbw(), (182, 0, 152, 0)) self.assertEqual(light.getRgbww(), (182, 0, 152, 0, 0)) self.assertEqual(light.rgbwcapable, True) self.assertEqual( light.__str__(), "ON [Color: (182, 0, 152) White: 0 raw state: 129,37,35,97,5,16,182,0,152,0,4,0,240,129,]", ) light.setWarmWhite255(25) self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 2) self.assertEqual( mock_send.call_args, mock.call(bytearray(b"1\x00\x00\x00\x19\x00\x00\x0fY")), ) light._transition_complete_time = 0 light.update_state() self.assertEqual(mock_read.call_count, 3) self.assertEqual(mock_send.call_count, 3) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual(light.protocol, PROTOCOL_LEDENET_9BYTE) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "color") self.assertEqual(light.warm_white, 25) self.assertEqual(light.cold_white, 37) self.assertEqual(light.brightness, 81) # RGBWW brighness self.assertEqual(light.rgbw, (182, 0, 152, 25)) self.assertEqual(light.getRgbw(), (182, 0, 152, 25)) self.assertEqual(light.rgbww, (182, 0, 152, 25, 37)) self.assertEqual(light.getRgbww(), (182, 0, 152, 25, 37)) self.assertEqual(light.rgbcw, (182, 0, 152, 37, 25)) self.assertEqual(light.getRgbcw(), (182, 0, 152, 37, 25)) self.assertEqual(light.rgbwcapable, True) self.assertEqual(light.dimmable_effects, False) self.assertEqual(light.requires_turn_on, True) self.assertEqual( light.__str__(), "ON [Color: (182, 0, 152) White: 25 raw state: 129,37,35,97,5,16,182,0,152,25,4,37,15,222,]", ) # Home Assistant legacy names light.set_effect("colorjump", 50, 100) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"a8\x10\x0f\xb8"))) # Library names light.set_effect("seven_color_jumping", 50, 60) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"a8\x10\x0f\xb8"))) with pytest.raises(ValueError): light.set_effect("unknown", 50) light._transition_complete_time = 0 light.update_state() self.assertEqual(mock_read.call_count, 4) self.assertEqual(mock_send.call_count, 6) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual(light.mode, "preset") self.assertEqual(light.effect, "colorjump") self.assertEqual(light.brightness, 255) self.assertEqual(light.preset_pattern_num, 0x38) self.assertEqual( light.__str__(), "ON [Pattern: colorjump (Speed 50%) raw state: 129,37,35,56,5,16,182,0,152,25,4,37,15,181,]", ) assert light.getClock() == datetime.datetime(2022, 1, 2, 16, 54, 2) self.assertEqual(mock_read.call_count, 5) self.assertEqual(mock_send.call_count, 7) light.setClock() self.assertEqual(mock_read.call_count, 5) self.assertEqual(mock_send.call_count, 8) light.setWarmWhite(50) self.assertEqual( mock_send.call_args, mock.call(bytearray(b"1\x00\x00\x00\x7f%\x00\x0f\xe4")), ) light.setWarmWhite255(utils.percentToByte(50)) self.assertEqual( mock_send.call_args, mock.call(bytearray(b"1\x00\x00\x00\x7f%\x00\x0f\xe4")), ) light.setColdWhite(50) self.assertEqual( mock_send.call_args, mock.call(bytearray(b"1\x00\x00\x00\x00\x7f\x00\x0f\xbf")), ) light.setColdWhite255(utils.percentToByte(50)) self.assertEqual( mock_send.call_args, mock.call(bytearray(b"1\x00\x00\x00\x00\x7f\x00\x0f\xbf")), ) light.setCustomPattern([[255, 0, 0]], 50, TRANSITION_GRADUAL) self.assertEqual( mock_send.call_args, mock.call( bytearray( b"Q\xff\x00\x00\x00\x01\x02\x03\x00\x01\x02\x03\x00\x01\x02\x03\x00\x01\x02\x03\x00\x01\x02\x03\x00\x01\x02\x03\x00\x01\x02\x03\x00\x01\x02\x03\x00\x01\x02\x03\x00\x01\x02\x03\x00\x01\x02\x03\x00\x01\x02\x03\x00\x01\x02\x03\x00\x01\x02\x03\x00\x01\x02\x03\x00\x10:\xff\x0f\x02" ) ), ) light.close() @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_rgbww_controller_version_2_after_factory_reset( self, mock_connect, mock_read, mock_send ): calls = 0 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray(b"\x81\x25") if calls == 2: self.assertEqual(expected, 12) return bytearray(b"\x23\x61\x00\x03\x00\xFF\x00\x00\x02\x00\x5A\x88") if calls == 3: self.assertEqual(expected, 14) return bytearray( b"\x81\x25\x23\x61\x00\x03\x00\xFF\x00\x00\x02\x00\x5A\x88" ) mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") assert light.color_modes == {COLOR_MODE_CCT, COLOR_MODE_RGBWW} self.assertEqual(light.protocol, PROTOCOL_LEDENET_9BYTE) self.assertEqual(light.model_num, 0x25) self.assertEqual(light.version_num, 2) self.assertEqual(light.mode, "color") self.assertEqual(light.raw_state.mode, 0) self.assertEqual(light.microphone, False) self.assertEqual(light.model, "Controller RGB/WW/CW (0x25)") self.assertEqual(light.operating_mode, COLOR_MODE_RGBWW) @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_rgbww_controller_version_9(self, mock_connect, mock_read, mock_send): calls = 0 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray(b"\x81\x25") if calls == 2: self.assertEqual(expected, 12) return bytearray(b"\x23\x61\x05\x10\xb6\x00\x98\x00\x09\x00\xf0\x86") if calls == 3: self.assertEqual(expected, 14) return bytearray( b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x09\x25\x0f\xe3" ) if calls == 4: self.assertEqual(expected, 14) return bytearray( b"\x81\x25\x23\x38\x05\x10\xb6\x00\x98\x19\x09\x25\x0f\xba" ) if calls == 5: self.assertEqual(expected, 94) return bytearray( b"\x0F\x22\xF0\x16\x01\x04\x00\x2B\x00\x00\x61\x19\x47\xFF\x00\x00\xF0\xF0\x16\x01\x04\x04\x2C\x00\x00\x61\x7F\xFF\x00\x00\x00\xF0\xF0\x16\x01\x03\x16\x1F\x00\x00\x61\xFF\x00\x00\x00\x00\xF0\xF0\x16\x01\x03\x17\x13\x00\x00\x61\x81\x81\x81\x00\x00\xF0\xF0\x16\x01\x03\x17\x28\x00\x00\x61\x00\xFF\x00\x00\x00\xF0\xF0\x16\x01\x04\x07\x2C\x00\x00\x61\x21\x00\xFF\x00\x00\xF0\x00\x00" ) if calls == 5: self.assertEqual(expected, 4) return bytearray( b"\x0F\x22\xF0\x16\x01\x04\x00\x2B\x00\x00\x61\x19\x47\xFF\x00\x00\xF0\xF0\x16\x01\x04\x04\x2C\x00\x00\x61\x7F\xFF\x00\x00\x00\xF0\xF0\x16\x01\x03\x16\x1F\x00\x00\x61\xFF\x00\x00\x00\x00\xF0\xF0\x16\x01\x03\x17\x13\x00\x00\x61\x81\x81\x81\x00\x00\xF0\xF0\x16\x01\x03\x17\x28\x00\x00\x61\x00\xFF\x00\x00\x00\xF0\xF0\x16\x01\x04\x07\x2C\x00\x00\x61\x21\x00\xFF\x00\x00\xF0\x00\x00" ) mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") assert light.color_modes == {COLOR_MODE_RGBWW, COLOR_MODE_CCT} self.assertEqual(light.protocol, PROTOCOL_LEDENET_9BYTE) self.assertEqual(light.model_num, 0x25) self.assertEqual(light.version_num, 9) self.assertEqual(light.microphone, False) self.assertEqual(light.model, "Controller RGB/WW/CW (0x25)") self.assertEqual( light.effect_list, [ "blue_fade", "blue_strobe", "colorjump", "colorloop", "colorstrobe", "cyan_fade", "cyan_strobe", "gb_cross_fade", "green_fade", "green_strobe", "purple_fade", "purple_strobe", "rb_cross_fade", "red_fade", "red_strobe", "rg_cross_fade", "white_fade", "white_strobe", "yellow_fade", "yellow_strobe", "random", ], ) self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual(light.protocol, PROTOCOL_LEDENET_9BYTE) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "color") self.assertEqual(light.min_temp, 2700) self.assertEqual(light.max_temp, 6500) self.assertEqual(light.warm_white, 0) self.assertEqual(light.brightness, 61) # RGBWW brightness self.assertEqual(light.getRgb(), (182, 0, 152)) self.assertEqual(light.getRgbw(), (182, 0, 152, 0)) self.assertEqual(light.getRgbww(), (182, 0, 152, 0, 0)) self.assertEqual(light.rgbwcapable, True) self.assertEqual( light.__str__(), "ON [Color: (182, 0, 152) White: 0 raw state: 129,37,35,97,5,16,182,0,152,0,9,0,240,134,]", ) light.setWarmWhite255(25) self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 2) self.assertEqual( mock_send.call_args, mock.call(bytearray(b"1\x00\x00\x00\x19\x00\x00\x0fY")), ) light._transition_complete_time = 0 light.update_state() self.assertEqual(mock_read.call_count, 3) self.assertEqual(mock_send.call_count, 3) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual(light.protocol, PROTOCOL_LEDENET_9BYTE) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "color") self.assertEqual(light.warm_white, 25) self.assertEqual(light.cold_white, 37) self.assertEqual(light.brightness, 81) # RGBWW brighness self.assertEqual(light.rgbw, (182, 0, 152, 25)) self.assertEqual(light.getRgbw(), (182, 0, 152, 25)) self.assertEqual(light.rgbww, (182, 0, 152, 25, 37)) self.assertEqual(light.getRgbww(), (182, 0, 152, 25, 37)) self.assertEqual(light.rgbcw, (182, 0, 152, 37, 25)) self.assertEqual(light.getRgbcw(), (182, 0, 152, 37, 25)) self.assertEqual(light.rgbwcapable, True) self.assertEqual(light.dimmable_effects, False) self.assertEqual(light.requires_turn_on, True) self.assertEqual( light.__str__(), "ON [Color: (182, 0, 152) White: 25 raw state: 129,37,35,97,5,16,182,0,152,25,9,37,15,227,]", ) # Home Assistant legacy names light.set_effect("colorjump", 50, 100) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"a8\x10\x0f\xb8"))) # Library names light.set_effect("seven_color_jumping", 50, 60) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"a8\x10\x0f\xb8"))) with pytest.raises(ValueError): light.set_effect("unknown", 50) light._transition_complete_time = 0 light.update_state() self.assertEqual(mock_read.call_count, 4) self.assertEqual(mock_send.call_count, 6) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual(light.mode, "preset") self.assertEqual(light.effect, "colorjump") self.assertEqual(light.brightness, 255) self.assertEqual(light.preset_pattern_num, 0x38) self.assertEqual( light.__str__(), "ON [Pattern: colorjump (Speed 50%) raw state: 129,37,35,56,5,16,182,0,152,25,9,37,15,186,]", ) timers = light.getTimers() assert len(timers) == 6 self.assertEqual(mock_read.call_count, 5) self.assertEqual(mock_send.call_count, 7) light.sendTimers(timers) self.assertEqual(mock_read.call_count, 6) self.assertEqual(mock_send.call_count, 8) @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_rgbcw_bulb_v4(self, mock_connect, mock_read, mock_send): calls = 0 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray(b"\x81\x35") if calls == 2: self.assertEqual(expected, 12) return bytearray(b"\x23\x61\x05\x10\xb6\x00\x98\x00\x04\x00\xf0\x91") if calls == 3: self.assertEqual(expected, 14) return bytearray( b"\x81\x35\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xee" ) if calls == 4: self.assertEqual(expected, 14) return bytearray( b"\x81\x35\x23\x38\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xc5" ) mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") assert light.color_modes == {COLOR_MODE_RGB, COLOR_MODE_CCT} self.assertEqual(light.version_num, 0x04) self.assertEqual(light.protocol, PROTOCOL_LEDENET_9BYTE) self.assertEqual(light.model_num, 0x35) self.assertEqual(light.microphone, False) self.assertEqual(light.model, "Bulb RGBCW (0x35)") self.assertEqual( light.effect_list, [ "blue_fade", "blue_strobe", "colorjump", "colorloop", "colorstrobe", "cyan_fade", "cyan_strobe", "gb_cross_fade", "green_fade", "green_strobe", "purple_fade", "purple_strobe", "rb_cross_fade", "red_fade", "red_strobe", "rg_cross_fade", "white_fade", "white_strobe", "yellow_fade", "yellow_strobe", "random", ], ) assert light.pixels_per_segment is None assert light.segments is None assert light.music_pixels_per_segment is None assert light.music_segments is None assert light.ic_types is None assert light.ic_type is None assert light.operating_mode is None assert light.operating_modes is None assert light.wiring is None assert light.wirings is None self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual(light.protocol, PROTOCOL_LEDENET_9BYTE) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "color") self.assertEqual(light.min_temp, 2700) self.assertEqual(light.max_temp, 6500) self.assertEqual(light.warm_white, 0) self.assertEqual(light.brightness, 182) self.assertEqual(light.getRgb(), (182, 0, 152)) self.assertEqual(light.getRgbw(), (182, 0, 152, 0)) self.assertEqual(light.getRgbww(), (182, 0, 152, 0, 0)) self.assertEqual(light.rgbwcapable, False) self.assertEqual( light.__str__(), ( "ON [Color: (182, 0, 152) Brightness: 71% raw state: " "129,53,35,97,5,16,182,0,152,0,4,0,240,145,]" ), ) light.setWarmWhite255(25) self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 2) self.assertEqual( mock_send.call_args, mock.call(bytearray(b"1\x00\x00\x00\x19\x19\x0f\x0f\x81")), ) light._transition_complete_time = 0 light.update_state() self.assertEqual(mock_read.call_count, 3) self.assertEqual(mock_send.call_count, 3) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual(light.protocol, PROTOCOL_LEDENET_9BYTE) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "ww") self.assertEqual(light.warm_white, 25) self.assertEqual(light.cold_white, 37) self.assertEqual(light.brightness, 62) self.assertEqual(light.rgbw, (182, 0, 152, 25)) self.assertEqual(light.getRgbw(), (255, 255, 255, 255)) self.assertEqual(light.rgbww, (182, 0, 152, 25, 37)) self.assertEqual(light.getRgbww(), (255, 255, 255, 255, 255)) self.assertEqual(light.rgbcw, (182, 0, 152, 37, 25)) self.assertEqual(light.getRgbcw(), (255, 255, 255, 255, 255)) self.assertEqual(light.rgbwcapable, False) self.assertEqual(light.dimmable_effects, False) self.assertEqual(light.requires_turn_on, True) self.assertEqual( light.__str__(), ( "ON [CCT: 4968K Brightness: 24% raw state: " "129,53,35,97,5,16,182,0,152,25,4,37,15,238,]" ), ) # Home Assistant legacy names light.set_effect("colorjump", 50, 100) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"a8\x10\x0f\xb8"))) # Library names light.set_effect("seven_color_jumping", 50, 60) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"a8\x10\x0f\xb8"))) with pytest.raises(ValueError): light.set_effect("unknown", 50) light._transition_complete_time = 0 light.update_state() self.assertEqual(mock_read.call_count, 4) self.assertEqual(mock_send.call_count, 6) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual(light.mode, "preset") self.assertEqual(light.effect, "colorjump") self.assertEqual(light.brightness, 255) self.assertEqual(light.preset_pattern_num, 0x38) self.assertEqual( light.__str__(), ( "ON [Pattern: colorjump (Speed 50%) raw state: " "129,53,35,56,5,16,182,0,152,25,4,37,15,197,]" ), ) light.setWhiteTemperature(2700, 255) self.assertEqual(mock_read.call_count, 4) self.assertEqual(mock_send.call_count, 7) self.assertEqual( mock_send.call_args, mock.call(bytearray(b"1\x00\x00\x00\xff\x00\x0f\x0fN")) ) @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_rgbcw_floor_lamp_v7(self, mock_connect, mock_read, mock_send): calls = 0 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray(b"\x81\x0E") if calls == 2: self.assertEqual(expected, 12) return bytearray(b"\x23\x61\x07\x10\xb6\x00\x98\x00\x07\x00\xf0\x6f") raise ValueError mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") assert light.color_modes == {COLOR_MODE_RGB, COLOR_MODE_CCT} self.assertEqual(light.version_num, 0x07) self.assertEqual(light.protocol, PROTOCOL_LEDENET_9BYTE_AUTO_ON) self.assertEqual(light.model_num, 0x0E) self.assertEqual(light.microphone, False) self.assertEqual(light.dimmable_effects, False) self.assertEqual(light.requires_turn_on, False) self.assertEqual(light.model, "Floor Lamp RGBCW (0x0E)") self.assertEqual( light.effect_list, [ "blue_fade", "blue_strobe", "colorjump", "colorloop", "colorstrobe", "cyan_fade", "cyan_strobe", "gb_cross_fade", "green_fade", "green_strobe", "purple_fade", "purple_strobe", "rb_cross_fade", "red_fade", "red_strobe", "rg_cross_fade", "white_fade", "white_strobe", "yellow_fade", "yellow_strobe", "random", ], ) self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "color") self.assertEqual(light.min_temp, 2700) self.assertEqual(light.max_temp, 6500) @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_rgbcw_floor_lamp_v9(self, mock_connect, mock_read, mock_send): calls = 0 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray(b"\x81\x0E") if calls == 2: self.assertEqual(expected, 12) return bytearray(b"\x23\x61\x07\x10\xb6\x00\x98\x00\x09\x00\xf0\x71") raise ValueError mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") assert light.color_modes == {COLOR_MODE_RGB, COLOR_MODE_CCT} self.assertEqual(light.version_num, 0x09) self.assertEqual(light.protocol, PROTOCOL_LEDENET_9BYTE_DIMMABLE_EFFECTS) self.assertEqual(light.model_num, 0x0E) self.assertEqual(light.microphone, False) self.assertEqual(light.dimmable_effects, True) self.assertEqual(light.requires_turn_on, False) self.assertEqual(light.model, "Floor Lamp RGBCW (0x0E)") self.assertEqual( light.effect_list, [ "blue_fade", "blue_strobe", "colorjump", "colorloop", "colorstrobe", "cyan_fade", "cyan_strobe", "cycle_rgb", "cycle_seven_colors", "gb_cross_fade", "green_fade", "green_strobe", "purple_fade", "purple_strobe", "rb_cross_fade", "red_fade", "red_strobe", "rg_cross_fade", "rgb_cross_fade", "white_fade", "white_strobe", "yellow_fade", "yellow_strobe", "random", ], ) self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "color") self.assertEqual(light.min_temp, 2700) self.assertEqual(light.max_temp, 6500) @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_rgb_controller_33_v3(self, mock_connect, mock_read, mock_send): calls = 0 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray(b"\x81\x33") if calls == 2: self.assertEqual(expected, 12) return bytearray(b"\x23\x61\x07\x10\xb6\x00\x98\x00\x03\x00\xf0\x90") raise ValueError mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") assert light.color_modes == {COLOR_MODE_RGB} self.assertEqual(light.version_num, 0x03) self.assertEqual(light.protocol, PROTOCOL_LEDENET_8BYTE) self.assertEqual(light.model_num, 0x33) self.assertEqual(light.microphone, False) self.assertEqual(light.dimmable_effects, False) self.assertEqual(light.requires_turn_on, True) self.assertEqual(light._protocol.power_push_updates, False) self.assertEqual(light._protocol.state_push_updates, False) self.assertEqual(light.model, "Controller RGB (0x33)") self.assertEqual( light.effect_list, [ "blue_fade", "blue_strobe", "colorjump", "colorloop", "colorstrobe", "cyan_fade", "cyan_strobe", "gb_cross_fade", "green_fade", "green_strobe", "purple_fade", "purple_strobe", "rb_cross_fade", "red_fade", "red_strobe", "rg_cross_fade", "white_fade", "white_strobe", "yellow_fade", "yellow_strobe", "random", ], ) self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "color") self.assertEqual(light.min_temp, 2700) self.assertEqual(light.max_temp, 6500) @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_rgb_controller_33_v7(self, mock_connect, mock_read, mock_send): calls = 0 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray(b"\x81\x33") if calls == 2: self.assertEqual(expected, 12) return bytearray(b"\x23\x61\x07\x10\xb6\x00\x98\x00\x07\x00\xf0\x94") raise ValueError mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") assert light.color_modes == {COLOR_MODE_RGB} self.assertEqual(light.mode, "color") self.assertEqual(light.version_num, 0x07) self.assertEqual(light.protocol, PROTOCOL_LEDENET_8BYTE_AUTO_ON) self.assertEqual(light.model_num, 0x33) self.assertEqual(light.microphone, False) self.assertEqual(light.dimmable_effects, False) self.assertEqual(light.requires_turn_on, False) self.assertEqual(light._protocol.power_push_updates, False) self.assertEqual(light._protocol.state_push_updates, False) self.assertEqual(light.model, "Controller RGB (0x33)") self.assertEqual( light.effect_list, [ "blue_fade", "blue_strobe", "colorjump", "colorloop", "colorstrobe", "cyan_fade", "cyan_strobe", "gb_cross_fade", "green_fade", "green_strobe", "purple_fade", "purple_strobe", "rb_cross_fade", "red_fade", "red_strobe", "rg_cross_fade", "white_fade", "white_strobe", "yellow_fade", "yellow_strobe", "random", ], ) assert light.pixels_per_segment is None assert light.segments is None assert light.music_pixels_per_segment is None assert light.music_segments is None assert light.ic_types is None assert light.ic_type is None assert light.operating_mode is None assert light.operating_modes is None assert light.wiring is None assert light.wirings == ["RGB", "GRB", "BRG"] self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "color") self.assertEqual(light.min_temp, 2700) self.assertEqual(light.max_temp, 6500) @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_rgb_controller_33_v9(self, mock_connect, mock_read, mock_send): calls = 0 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray(b"\x81\x33") if calls == 2: self.assertEqual(expected, 12) return bytearray(b"\x23\x61\x07\x10\xb6\x00\x98\x00\x09\x00\xf0\x96") raise ValueError mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") assert light.color_modes == {COLOR_MODE_RGB} self.assertEqual(light.version_num, 0x09) self.assertEqual(light.protocol, PROTOCOL_LEDENET_8BYTE_DIMMABLE_EFFECTS) self.assertEqual(light.model_num, 0x33) self.assertEqual(light.microphone, False) self.assertEqual(light.dimmable_effects, True) self.assertEqual(light.requires_turn_on, False) self.assertEqual(light._protocol.power_push_updates, True) self.assertEqual(light._protocol.state_push_updates, True) self.assertEqual(light.model, "Controller RGB (0x33)") self.assertEqual( light.effect_list, [ "blue_fade", "blue_strobe", "colorjump", "colorloop", "colorstrobe", "cyan_fade", "cyan_strobe", "cycle_rgb", "cycle_seven_colors", "gb_cross_fade", "green_fade", "green_strobe", "purple_fade", "purple_strobe", "rb_cross_fade", "red_fade", "red_strobe", "rg_cross_fade", "rgb_cross_fade", "white_fade", "white_strobe", "yellow_fade", "yellow_strobe", "random", ], ) assert light.pixels_per_segment is None assert light.segments is None assert light.music_pixels_per_segment is None assert light.music_segments is None assert light.ic_types is None assert light.ic_type is None assert light.operating_mode is None assert light.operating_modes is None assert light.wiring is None assert light.wirings == ["RGB", "GRB", "BRG"] self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "color") self.assertEqual(light.min_temp, 2700) self.assertEqual(light.max_temp, 6500) light.set_effect("blue_fade", 50, 50) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"8(\x102\xa2"))) assert PresetPattern.valtostr(0x25) == "Seven Color Cross Fade" assert PresetPattern.str_to_val("Seven Color Cross Fade") == 0x25 assert PresetPattern.str_to_val("colorloop") == 0x25 light.set_effect("colorloop", 50, 50) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"8%\x102\x9f"))) @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_rgbcw_bulb_v9(self, mock_connect, mock_read, mock_send): calls = 0 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray(b"\x81\x35") if calls == 2: self.assertEqual(expected, 12) return bytearray(b"\x23\x61\x05\x10\xb6\x00\x98\x00\x09\x00\xf0\x96") if calls == 3: self.assertEqual(expected, 14) return bytearray( b"\x81\x35\x23\x61\x05\x10\xb6\x00\x98\x19\x09\x25\x0f\xf3" ) if calls == 4: self.assertEqual(expected, 14) return bytearray( b"\x81\x35\x23\x38\x05\x10\xb6\x00\x98\x19\x09\x25\x0f\xca" ) mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") assert light.color_modes == {COLOR_MODE_RGB, COLOR_MODE_CCT} self.assertEqual(light.protocol, PROTOCOL_LEDENET_9BYTE_DIMMABLE_EFFECTS) self.assertEqual(light.model_num, 0x35) self.assertEqual(light.microphone, False) self.assertEqual(light.model, "Bulb RGBCW (0x35)") self.assertEqual( light.effect_list, [ "blue_fade", "blue_strobe", "colorjump", "colorloop", "colorstrobe", "cyan_fade", "cyan_strobe", "cycle_rgb", "cycle_seven_colors", "gb_cross_fade", "green_fade", "green_strobe", "purple_fade", "purple_strobe", "rb_cross_fade", "red_fade", "red_strobe", "rg_cross_fade", "rgb_cross_fade", "white_fade", "white_strobe", "yellow_fade", "yellow_strobe", "random", ], ) self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual(light.protocol, PROTOCOL_LEDENET_9BYTE_DIMMABLE_EFFECTS) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "color") self.assertEqual(light.min_temp, 2700) self.assertEqual(light.max_temp, 6500) self.assertEqual(light.warm_white, 0) self.assertEqual(light.brightness, 182) self.assertEqual(light.getRgb(), (182, 0, 152)) self.assertEqual(light.getRgbw(), (182, 0, 152, 0)) self.assertEqual(light.getRgbww(), (182, 0, 152, 0, 0)) self.assertEqual(light.rgbwcapable, False) self.assertEqual( light.__str__(), ( "ON [Color: (182, 0, 152) Brightness: 71% raw state: " "129,53,35,97,5,16,182,0,152,0,9,0,240,150,]" ), ) light.setWarmWhite255(25) self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 2) self.assertEqual( mock_send.call_args, mock.call(bytearray(b"1\x00\x00\x00\x19\x19\x0f\x0f\x81")), ) light._transition_complete_time = 0 light.update_state() self.assertEqual(mock_read.call_count, 3) self.assertEqual(mock_send.call_count, 3) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual(light.protocol, PROTOCOL_LEDENET_9BYTE_DIMMABLE_EFFECTS) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "ww") self.assertEqual(light.warm_white, 25) self.assertEqual(light.cold_white, 37) self.assertEqual(light.brightness, 62) self.assertEqual(light.rgbw, (182, 0, 152, 25)) self.assertEqual(light.getRgbw(), (255, 255, 255, 255)) self.assertEqual(light.rgbww, (182, 0, 152, 25, 37)) self.assertEqual(light.getRgbww(), (255, 255, 255, 255, 255)) self.assertEqual(light.rgbcw, (182, 0, 152, 37, 25)) self.assertEqual(light.getRgbcw(), (255, 255, 255, 255, 255)) self.assertEqual(light.rgbwcapable, False) self.assertEqual(light.dimmable_effects, True) self.assertEqual(light._protocol.power_push_updates, True) self.assertEqual(light._protocol.state_push_updates, True) self.assertEqual(light.requires_turn_on, False) self.assertEqual( light.__str__(), ( "ON [CCT: 4968K Brightness: 24% raw state: " "129,53,35,97,5,16,182,0,152,25,9,37,15,243,]" ), ) # Home Assistant legacy names light.set_effect("colorjump", 50, 100) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"88\x10d\xe4"))) # Library names light.set_effect("seven_color_jumping", 50, 50) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"88\x102\xb2"))) light.set_effect("rgb_cross_fade", 50, 60) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"8$\x10<\xa8"))) with pytest.raises(ValueError): light.set_effect("unknown", 50) with pytest.raises(ValueError): light.setPresetPattern(0x38, 50, 200) with pytest.raises(ValueError): light.setPresetPattern(0x99, 50, 100) light._transition_complete_time = 0 light.update_state() self.assertEqual(mock_read.call_count, 4) self.assertEqual(mock_send.call_count, 7) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual(light.mode, "preset") self.assertEqual(light.effect, "colorjump") self.assertEqual(light.brightness, 153) self.assertEqual(light.preset_pattern_num, 0x38) self.assertEqual( light.__str__(), ( "ON [Pattern: colorjump (Speed 50%) raw state: " "129,53,35,56,5,16,182,0,152,25,9,37,15,202,]" ), ) @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_original_ledenet(self, mock_connect, mock_read, mock_send): calls = 0 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray(b"") if calls == 2: self.assertEqual(expected, 2) return bytearray(b"f\x01") if calls == 3: self.assertEqual(expected, 9) return bytearray(b"#A!\x08\xff\x80*\x01\x99") if calls == 4: self.assertEqual(expected, 11) return bytearray(b"f\x01#A!\x08\x01\x19P\x01\x99") if calls == 5: # ready turn off response self.assertEqual(expected, 4) return bytearray(b"\x0fq#\xa3") if calls == 6: self.assertEqual(expected, 11) return bytearray(b"f\x01$A!\x08\x01\x19P\x01\x99") if calls == 7: # ready turn on response self.assertEqual(expected, 4) return bytearray(b"\x0fq#\xa3") if calls == 8: self.assertEqual(expected, 11) return bytearray(b"f\x01#A!\x08\x01\x19P\x01\x99") mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") assert light.color_modes == {COLOR_MODE_RGB} self.assertEqual(light.model_num, 0x01) self.assertEqual(light.model, "Legacy Controller RGB (0x01)") self.assertEqual(light.dimmable_effects, False) self.assertEqual(light.requires_turn_on, True) self.assertEqual(light.white_active, True) self.assertEqual(light._protocol.power_push_updates, False) self.assertEqual(light._protocol.state_push_updates, False) self.assertEqual(mock_read.call_count, 3) self.assertEqual(mock_send.call_count, 2) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"\xef\x01w"))) light.setRgb(1, 25, 80) self.assertEqual(mock_read.call_count, 3) self.assertEqual(mock_send.call_count, 3) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"V\x01\x19P\xaa"))) light._transition_complete_time = 0 light.update_state() self.assertEqual(mock_read.call_count, 4) self.assertEqual(mock_send.call_count, 4) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"\xef\x01w"))) self.assertEqual( light.__str__(), "ON [Color: (1, 25, 80) Brightness: 31% raw state: 102,1,35,65,33,8,1,25,80,1,153,0,]", ) self.assertEqual(light.protocol, PROTOCOL_LEDENET_ORIGINAL) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "color") self.assertEqual(light.warm_white, 0) self.assertEqual(light.brightness, 80) self.assertEqual(light.getRgb(), (1, 25, 80)) light.turnOff() self.assertEqual(mock_read.call_count, 5) self.assertEqual(mock_send.call_count, 5) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"\xcc$3"))) light._transition_complete_time = 0 light.update_state() self.assertEqual(mock_read.call_count, 6) self.assertEqual(mock_send.call_count, 6) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"\xef\x01w"))) self.assertEqual( light.__str__(), "OFF [Color: (1, 25, 80) Brightness: 31% raw state: 102,1,36,65,33,8,1,25,80,1,153,0,]", ) self.assertEqual(light.protocol, PROTOCOL_LEDENET_ORIGINAL) self.assertEqual(light.is_on, False) self.assertEqual(light.mode, "color") self.assertEqual(light.warm_white, 0) self.assertEqual(light.brightness, 80) self.assertEqual(light.getRgb(), (1, 25, 80)) light.turnOn() self.assertEqual(mock_read.call_count, 7) self.assertEqual(mock_send.call_count, 7) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"\xcc#3"))) light._transition_complete_time = 0 light.update_state() self.assertEqual(mock_read.call_count, 8) self.assertEqual(mock_send.call_count, 8) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"\xef\x01w"))) self.assertEqual( light.__str__(), "ON [Color: (1, 25, 80) Brightness: 31% raw state: 102,1,35,65,33,8,1,25,80,1,153,0,]", ) self.assertEqual(light.protocol, PROTOCOL_LEDENET_ORIGINAL) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "color") self.assertEqual(light.warm_white, 0) self.assertEqual(light.cool_white, 0) self.assertEqual(light.brightness, 80) self.assertEqual(light.getRgb(), (1, 25, 80)) self.assertEqual(light.version_num, 0) light.set_effect("colorjump", 50, 100) self.assertEqual(mock_read.call_count, 8) self.assertEqual(mock_send.call_count, 9) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"\xbb8\x10D"))) @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_original_ledenet_cct(self, mock_connect, mock_read, mock_send): calls = 0 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray(b"") if calls == 2: self.assertEqual(expected, 2) return bytearray(b"f\x03") if calls == 3: self.assertEqual(expected, 9) return bytearray(b"#A!\x08\xff\x80*\x01\x99") if calls == 4: self.assertEqual(expected, 11) return bytearray(b"f\x03#A!\x08\x01\x19P\x01\x99") if calls == 5: # ready turn off response self.assertEqual(expected, 4) return bytearray(b"\x0fq#\xa3") if calls == 6: self.assertEqual(expected, 11) return bytearray(b"f\x03$A!\x08\x01\x19P\x01\x99") if calls == 7: # ready turn on response self.assertEqual(expected, 4) return bytearray(b"\x0fq#\xa3") if calls == 8: self.assertEqual(expected, 11) return bytearray(b"f\x03#A!\x08\x01\x19P\x01\x99") mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") assert light.color_modes == {COLOR_MODE_CCT} assert light.effect is None assert light.effect_list == [ "Cool Flash", "Cool Gradual", "Warm Flash", "Warm Gradual", "random", ] self.assertEqual(light.model_num, 0x03) self.assertEqual(light.model, "Legacy Controller CCT (0x03)") self.assertEqual(light.dimmable_effects, False) self.assertEqual(light.requires_turn_on, True) self.assertEqual(light.white_active, True) self.assertEqual(light._protocol.power_push_updates, False) self.assertEqual(light._protocol.state_push_updates, False) self.assertEqual(mock_read.call_count, 3) self.assertEqual(mock_send.call_count, 2) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"\xef\x01w"))) light.setWhiteTemperature(2700, 255) self.assertEqual(mock_read.call_count, 3) self.assertEqual(mock_send.call_count, 3) self.assertEqual(mock_send.call_args, mock.call(bytearray((b"V\xff\x00\xaa")))) light._transition_complete_time = 0 light.update_state() self.assertEqual(mock_read.call_count, 4) self.assertEqual(mock_send.call_count, 4) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"\xef\x01w"))) self.assertEqual( light.__str__(), "ON [CCT: 6354K Brightness: 10% raw state: 102,3,35,65,33,8,1,0,80,1,153,25,]", ) self.assertEqual(light.protocol, PROTOCOL_LEDENET_ORIGINAL_CCT) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "ww") self.assertEqual(light.warm_white, 0) self.assertEqual(light.brightness, 26) light.turnOff() self.assertEqual(mock_read.call_count, 5) self.assertEqual(mock_send.call_count, 5) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"\xcc$3"))) light._transition_complete_time = 0 light.update_state() self.assertEqual(mock_read.call_count, 6) self.assertEqual(mock_send.call_count, 6) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"\xef\x01w"))) self.assertEqual( light.__str__(), "OFF [CCT: 6354K Brightness: 10% raw state: 102,3,36,65,33,8,1,0,80,1,153,25,]", ) self.assertEqual(light.protocol, PROTOCOL_LEDENET_ORIGINAL_CCT) self.assertEqual(light.is_on, False) self.assertEqual(light.mode, "ww") self.assertEqual(light.cool_white, 0) self.assertEqual(light.warm_white, 0) self.assertEqual(light.brightness, 26) light.turnOn() self.assertEqual(mock_read.call_count, 7) self.assertEqual(mock_send.call_count, 7) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"\xcc#3"))) light._transition_complete_time = 0 light.update_state() self.assertEqual(mock_read.call_count, 8) self.assertEqual(mock_send.call_count, 8) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"\xef\x01w"))) self.assertEqual( light.__str__(), "ON [CCT: 6354K Brightness: 10% raw state: 102,3,35,65,33,8,1,0,80,1,153,25,]", ) self.assertEqual(light.protocol, PROTOCOL_LEDENET_ORIGINAL_CCT) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "ww") self.assertEqual(light.warm_white, 0) self.assertEqual(light.cool_white, 0) self.assertEqual(light.brightness, 26) self.assertEqual(light.version_num, 0) light.set_effect("Warm Flash", 50, 100) self.assertEqual(mock_read.call_count, 8) self.assertEqual(mock_send.call_count, 9) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"\xbb<\x10D"))) light.set_effect("Cool Gradual", 50, 100) self.assertEqual(mock_read.call_count, 8) self.assertEqual(mock_send.call_count, 10) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"\xbbJ\x10D"))) @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_state_transition(self, mock_connect, mock_read, mock_send): calls = 0 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray(b"\x81E") if calls == 2: self.assertEqual(expected, 12) return bytearray(b"#a!\x10g\xffh\x00\x04\x00\xf0=") if calls == 3: self.assertEqual(expected, 14) return bytearray(b"\x81E#a!\x10\x01\x19P\x00\x04\x00\xf0\xd9") if calls == 4: self.assertEqual(expected, 14) return bytearray(b"\x81E#a!\x10\x01\x19P\x00\x04\x00\xf0\xd9") mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") assert light.color_modes == {COLOR_MODE_RGB, COLOR_MODE_DIM} self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) light.setRgb(50, 100, 50) self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 2) self.assertEqual( mock_send.call_args, mock.call(bytearray(b"12d2\x00\xf0\x0f\xf8")) ) self.assertEqual(light.getRgb(), (50, 100, 50)) # While a transition is in progress we do not update # internal state light.update_state() self.assertEqual(light.getRgb(), (50, 100, 50)) # Now that the transition has completed state should # be updated, we mock the bulb to replay with an # RGB state of (1, 25, 80) light._transition_complete_time = 0 light.update_state() self.assertEqual(mock_read.call_count, 4) self.assertEqual(mock_send.call_count, 4) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual( light.__str__(), "ON [Color: (1, 25, 80) Brightness: 31% raw state: 129,69,35,97,33,16,1,25,80,0,4,0,240,217,]", ) self.assertEqual(light.protocol, PROTOCOL_LEDENET_8BYTE) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "color") self.assertEqual(light.warm_white, 0) self.assertEqual(light.brightness, 80) self.assertEqual(light.getRgb(), (1, 25, 80)) self.assertEqual(light.device_type, flux_led.DeviceType.Bulb) def test_rgbww_brightness(self): assert rgbww_brightness((128, 128, 128, 128, 128), 255) == ( 255, 255, 255, 255, 255, ) assert rgbww_brightness((128, 128, 128, 128, 128), 128) == ( 128, 128, 128, 128, 128, ) assert rgbww_brightness((255, 255, 255, 255, 255), 128) == ( 128, 128, 128, 128, 128, ) assert rgbww_brightness((0, 255, 0, 0, 0), 255) == (0, 255, 0, 255, 255) assert rgbww_brightness((0, 255, 0, 0, 0), 128) == (0, 255, 0, 64, 64) def test_rgbcw_brightness(self): assert rgbcw_brightness((128, 128, 128, 128, 128), 255) == ( 255, 255, 255, 255, 255, ) assert rgbcw_brightness((128, 128, 128, 128, 128), 128) == ( 128, 128, 128, 128, 128, ) assert rgbcw_brightness((255, 255, 255, 255, 255), 128) == ( 128, 128, 128, 128, 128, ) assert rgbcw_brightness((0, 255, 0, 0, 0), 255) == (0, 255, 0, 255, 255) assert rgbcw_brightness((0, 255, 0, 0, 0), 128) == (0, 255, 0, 64, 64) def test_rgbw_brightness(self): assert rgbw_brightness((128, 128, 128, 128), 255) == (255, 255, 255, 255) assert rgbw_brightness((128, 128, 128, 128), 128) == (128, 128, 128, 128) assert rgbw_brightness((255, 255, 255, 255), 128) == (128, 128, 128, 128) assert rgbw_brightness((0, 255, 0, 0), 255) == (0, 255, 0, 255) assert rgbw_brightness((0, 255, 0, 0), 128) == (0, 255, 0, 0) def test_rgbwc_to_rgbcw_rgbcw_to_rgbwc_round_trip(self): rgbwc = (1, 2, 3, 4, 5) rgbcw = rgbwc_to_rgbcw(rgbwc) assert rgbcw == (1, 2, 3, 5, 4) assert rgbcw_to_rgbwc(rgbcw) == rgbwc def test_color_object_to_tuple(self): assert utils.color_object_to_tuple("red") == (255, 0, 0) assert utils.color_object_to_tuple("green") == (0, 128, 0) assert utils.color_object_to_tuple("blue") == (0, 0, 255) green = (0, 255, 0) assert utils.color_object_to_tuple(green) == green assert utils.color_object_to_tuple(set()) is None assert utils.color_object_to_tuple("#ff00ff") == (255, 0, 255) assert utils.color_object_to_tuple("(255,0,255)") == (255, 0, 255) def test_get_color_names_list(self): names = utils.get_color_names_list() assert len(names) > 120 assert "springgreen" in names assert "yellow" in names def test_color_tuple_to_string(self): assert utils.color_tuple_to_string((255, 0, 0)) == "red" assert utils.color_tuple_to_string((0, 128, 0)) == "green" assert utils.color_tuple_to_string((0, 0, 255)) == "blue" assert utils.color_tuple_to_string((3, 2, 1)) == "(3, 2, 1)" def test_color_temp_to_white_levels(self): assert color_temp_to_white_levels(2700, 255) == (255, 0) assert color_temp_to_white_levels(4600, 255) == (128, 128) assert color_temp_to_white_levels(5000, 255) == (101, 154) assert color_temp_to_white_levels(6500, 255) == (0, 255) assert color_temp_to_white_levels(2700, 128) == (128, 0) assert color_temp_to_white_levels(4600, 128) == (64, 64) assert color_temp_to_white_levels(5000, 128) == (50, 77) assert color_temp_to_white_levels(6500, 128) == (0, 128) assert color_temp_to_white_levels(6500, 255) == (0, 255) with pytest.raises(ValueError): color_temp_to_white_levels(6500, -1) def test_white_levels_to_color_temp(self): assert white_levels_to_color_temp(0, 255) == (6500, 255) assert white_levels_to_color_temp(255, 255) == (4600, 255) assert white_levels_to_color_temp(128, 128) == (4600, 255) assert white_levels_to_color_temp(255, 0) == (2700, 255) assert white_levels_to_color_temp(0, 128) == (6500, 128) assert white_levels_to_color_temp(64, 64) == (4600, 128) assert white_levels_to_color_temp(77, 50) == (4196, 127) assert white_levels_to_color_temp(128, 0) == (2700, 128) assert white_levels_to_color_temp(0, 0) == (2700, 0) with pytest.raises(ValueError): white_levels_to_color_temp(-1, 0) with pytest.raises(ValueError): white_levels_to_color_temp(0, 500) def test_scaled_color_temp_to_white_levels(self): assert scaled_color_temp_to_white_levels(0, 100) == (255, 0) assert scaled_color_temp_to_white_levels(50, 100) == (128, 128) assert scaled_color_temp_to_white_levels(76, 100) == (61, 194) assert scaled_color_temp_to_white_levels(100, 100) == (0, 255) assert scaled_color_temp_to_white_levels(42, 50) == (74, 54) assert scaled_color_temp_to_white_levels(71, 50) == (37, 91) assert scaled_color_temp_to_white_levels(77, 50) == (29, 98) assert scaled_color_temp_to_white_levels(100, 50) == (0, 128) assert scaled_color_temp_to_white_levels(100, 100) == (0, 255) with pytest.raises(ValueError): scaled_color_temp_to_white_levels(100, -1) with pytest.raises(ValueError): scaled_color_temp_to_white_levels(-1, 100) def test_white_levels_to_scaled_color_temp(self): assert white_levels_to_scaled_color_temp(0, 255) == (100, 100) assert white_levels_to_scaled_color_temp(255, 255) == (50, 100) assert white_levels_to_scaled_color_temp(128, 128) == (50, 100) assert white_levels_to_scaled_color_temp(255, 0) == (0, 100) assert white_levels_to_scaled_color_temp(0, 128) == (100, 50) assert white_levels_to_scaled_color_temp(64, 64) == (50, 50) assert white_levels_to_scaled_color_temp(77, 50) == (39, 50) assert white_levels_to_scaled_color_temp(128, 0) == (0, 50) assert white_levels_to_scaled_color_temp(0, 0) == (0, 0) with pytest.raises(ValueError): white_levels_to_scaled_color_temp(-1, 0) with pytest.raises(ValueError): white_levels_to_scaled_color_temp(0, 500) @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_unknown_model_detection_rgbw_cct(self, mock_connect, mock_read, mock_send): calls = 0 model_not_in_db = 222 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray([129, model_not_in_db]) if calls == 2: self.assertEqual(expected, 12) return bytearray(b"$$\x47\x00\x00\x00\x00\x00\x02\x00\x00\xf0") if calls == 3: self.assertEqual(expected, 14) return bytearray( b"\x81\xde\x23\x41\x47\x00\x00\x00\x00\x00\x02\xFF\x00\x0b" ) mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") assert light.color_modes == {COLOR_MODE_RGB, COLOR_MODE_CCT} self.assertEqual(light.model_num, 0xDE) self.assertEqual(light.model, "Unknown Model (0xDE)") assert light.color_mode == COLOR_MODE_RGB light.update_state() assert light.color_mode == COLOR_MODE_CCT self.assertEqual(light.color_temp, 6500) self.assertEqual(light.isOn(), True) self.assertEqual(light.getCCT(), (0, 255)) self.assertEqual(light.getWarmWhite255(), 255) self.assertEqual(light.getWhiteTemperature(), (6500, 255)) self.assertEqual( light.__str__(), "ON [CCT: 6500K Brightness: 100% raw state: 129,222,35,65,71,0,0,0,0,0,2,255,0,11,]", ) @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_unknown_model_detection_rgb_dim(self, mock_connect, mock_read, mock_send): calls = 0 model_not_in_db = 222 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray([129, model_not_in_db]) if calls == 2: self.assertEqual(expected, 12) return bytearray(b"$$\x46\x00\x00\x00\x00\x00\x02\x00\x00\xef") mock_read.side_effect = read_data switch = flux_led.WifiLedBulb("192.168.1.164") assert switch.color_modes == {COLOR_MODE_RGB, COLOR_MODE_DIM} @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_unknown_model_detection_rgbww(self, mock_connect, mock_read, mock_send): calls = 0 model_not_in_db = 222 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray([129, model_not_in_db]) if calls == 2: self.assertEqual(expected, 12) return bytearray(b"$$\x45\x00\x00\x00\x00\x00\x02\x00\x00\xee") mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") assert light.color_modes == {COLOR_MODE_RGBWW, COLOR_MODE_CCT} @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_unknown_model_detection_rgbw(self, mock_connect, mock_read, mock_send): calls = 0 model_not_in_db = 222 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray([129, model_not_in_db]) if calls == 2: self.assertEqual(expected, 12) return bytearray(b"$$\x44\x00\x00\x00\x00\x00\x02\x00\x00\xed") mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") assert light.color_modes == {COLOR_MODE_RGBW, COLOR_MODE_CCT} self.assertEqual(light.color_mode, COLOR_MODE_RGBW) light.setWhiteTemperature(light.max_temp, 255) self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 2) self.assertEqual( mock_send.call_args, mock.call(bytearray(b"1\xff\xff\xff\x00\x00\x0f=")), ) self.assertEqual(light.color_mode, COLOR_MODE_CCT) light.setWhiteTemperature(light.min_temp, 255) self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 3) self.assertEqual( mock_send.call_args, mock.call(bytearray(b"1\x00\x00\x00\xff\x00\x0f?")), ) self.assertEqual(light.color_mode, COLOR_MODE_CCT) light.setWhiteTemperature( light.max_temp - ((light.max_temp - light.min_temp) / 2), 255 ) self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 4) self.assertEqual( mock_send.call_args, mock.call(bytearray(b"1\x80\x80\x80\x80\x00\x0f@")), ) self.assertEqual(light.color_mode, COLOR_MODE_CCT) @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_single_channel_remapping(self, mock_connect, mock_read, mock_send): calls = 0 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray(b"\x81\x41") if calls == 2: self.assertEqual(expected, 12) return bytearray(b"#a\x41\x10\xff\x00\x00\x00\x04\x00\xf0\x8a") if calls == 3: self.assertEqual(expected, 14) return bytearray(b"\x81\x41#a\x41\x10\x64\x00\x00\x00\x04\x00\xf0\xef") raise ValueError("Too many calls") mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") self.assertEqual(light.model_num, 0x41) self.assertEqual(light.model, "Controller Dimmable (0x41)") assert light.color_modes == {COLOR_MODE_DIM} self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual( light.__str__(), "ON [Warm White: 100% raw state: 129,65,35,97,65,16,0,0,0,255,4,0,240,138,]", ) self.assertEqual(light.protocol, PROTOCOL_LEDENET_8BYTE_AUTO_ON) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "ww") self.assertEqual(light.warm_white, 0) self.assertEqual(light.brightness, 255) self.assertEqual(light.rgbwcapable, False) self.assertEqual(light.device_type, flux_led.DeviceType.Bulb) light.setRgbw(0, 0, 0, w=0x80) self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 2) self.assertEqual( mock_send.call_args, mock.call(bytearray(b"1\x80\x00\x00\x00\x00\x0f\xc0")) ) assert light.raw_state.warm_white == 0x80 self.assertEqual( light.__str__(), "ON [Warm White: 50% raw state: 129,65,35,97,65,16,0,0,0,128,4,0,240,138,]", ) # Update state now assumes its externally set to 100 light._transition_complete_time = 0 light.update_state() self.assertEqual(mock_read.call_count, 3) assert light.raw_state.warm_white == 100 self.assertEqual(light.getWarmWhite255(), 100) self.assertEqual(light.brightness, 100) self.assertEqual( light.__str__(), "ON [Warm White: 39% raw state: 129,65,35,97,65,16,0,0,0,100,4,0,240,239,]", ) light._set_power_state(light._protocol.off_byte) self.assertEqual( light.__str__(), "OFF [Warm White: 39% raw state: 129,65,36,97,65,16,0,0,0,100,4,0,240,239,]", ) light._set_power_state(light._protocol.on_byte) self.assertEqual( light.__str__(), "ON [Warm White: 39% raw state: 129,65,35,97,65,16,0,0,0,100,4,0,240,239,]", ) light._replace_raw_state( {STATE_RED: 255, STATE_GREEN: 0, STATE_BLUE: 0, STATE_WARM_WHITE: 0} ) self.assertEqual( light.__str__(), "ON [Warm White: 100% raw state: 129,65,35,97,65,16,0,0,0,255,4,0,240,239,]", ) # Verify we do not remap states that have not changed light._replace_raw_state({STATE_BLUE: 0}) self.assertEqual( light.__str__(), "ON [Warm White: 100% raw state: 129,65,35,97,65,16,0,0,0,255,4,0,240,239,]", ) # Verify we do not remap states that have not changed light._replace_raw_state({STATE_GREEN: 255, STATE_BLUE: 255}) self.assertEqual( light.__str__(), "ON [Warm White: 100% raw state: 129,65,35,97,65,16,0,255,255,255,4,0,240,239,]", ) self.assertEqual(light.dimmable_effects, False) self.assertEqual(light.requires_turn_on, False) self.assertEqual(light._protocol.power_push_updates, False) self.assertEqual(light._protocol.state_push_updates, False) @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_addressable_strip_effects_a2(self, mock_connect, mock_read, mock_send): calls = 0 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray(b"\x81\xA2") if calls == 2: self.assertEqual(expected, 12) return bytearray(b"#a\x41\x10\xff\x00\x00\x00\x04\x00\xf0\xeb") if calls == 3: self.assertEqual(expected, 14) return bytearray( b"\x81\xA2#\x25\x01\x10\x64\x00\x00\x00\x04\x00\xf0\xd4" ) if calls == 4: self.assertEqual(expected, 14) return bytearray( b"\x81\xA2#\x24\x01\x10\x64\x00\x00\x00\x04\x00\xf0\xd3" ) raise ValueError("Too many calls") mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") self.assertEqual(light.speed_adjust_off, False) self.assertEqual(light.model_num, 0xA2) self.assertEqual(light.microphone, True) self.assertEqual(light.model, "Addressable v2 (0xA2)") assert len(light.effect_list) == 105 assert light.color_modes == {COLOR_MODE_RGB} self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual( light.__str__(), "ON [Color: (255, 0, 0) Brightness: 100% raw state: 129,162,35,97,65,16,255,0,0,0,4,0,240,235,]", ) self.assertEqual(light.protocol, PROTOCOL_LEDENET_ADDRESSABLE_A2) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "color") self.assertEqual(light.warm_white, 0) self.assertEqual(light.brightness, 255) self.assertEqual(light.rgbwcapable, False) self.assertEqual(light.device_type, flux_led.DeviceType.Bulb) self.assertEqual(light.dimmable_effects, True) self.assertEqual(light.requires_turn_on, False) self.assertEqual(light._protocol.power_push_updates, False) self.assertEqual(light._protocol.state_push_updates, False) light.setRgbw(0, 255, 0) self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 2) self.assertEqual( mock_send.call_args, mock.call(bytearray(b"A\x01\x00\xff\x00\x00\x00\x00`\xff\x00\x00\xa0")), ) light.set_effect("RBM 1", 50) self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 3) self.assertEqual( mock_send.call_args, mock.call(bytearray(b"B\x012d\xd9")), ) light._transition_complete_time = 0 light.update_state() self.assertEqual( light.__str__(), "ON [Pattern: RBM 1 (Speed 16%) raw state: 129,162,35,37,1,16,100,0,0,0,4,0,240,212,]", ) assert light.effect == "RBM 1" assert light.brightness == 255 assert light.getSpeed() == 16 light.update_state() self.assertEqual( light.__str__(), "ON [Pattern: Multi Color Static (Speed 16%) raw state: 129,162,35,36,1,16,100,0,0,0,4,0,240,211,]", ) assert light.effect == "Multi Color Static" assert light.brightness == 255 assert light.getSpeed() == 16 with pytest.raises(ValueError): light.setPresetPattern(1, 50, 200) with pytest.raises(ValueError): light.setPresetPattern(105, 50, 100) @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_addressable_strip_effects_a3(self, mock_connect, mock_read, mock_send): calls = 0 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray(b"\x81\xA3") if calls == 2: self.assertEqual(expected, 12) return bytearray(b"#a\x41\x10\xff\x00\x00\x00\x04\x00\xf0\xec") if calls == 3: self.assertEqual(expected, 14) return bytearray( b"\x81\xA3#\x25\x01\x10\x64\x00\x00\x00\x04\x00\xf0\xd5" ) raise ValueError("Too many calls") mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") self.assertEqual(light.speed_adjust_off, True) self.assertEqual(light.model_num, 0xA3) self.assertEqual(light.microphone, True) self.assertEqual(light.model, "Addressable v3 (0xA3)") assert len(light.effect_list) == 105 assert light.color_modes == {COLOR_MODE_RGB} self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual( light.__str__(), "ON [Color: (255, 0, 0) Brightness: 100% raw state: 129,163,35,97,65,16,255,0,0,0,4,0,240,236,]", ) self.assertEqual(light.protocol, PROTOCOL_LEDENET_ADDRESSABLE_A3) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "color") self.assertEqual(light.warm_white, 0) self.assertEqual(light.brightness, 255) self.assertEqual(light.rgbwcapable, False) self.assertEqual(light.device_type, flux_led.DeviceType.Bulb) self.assertEqual(light.dimmable_effects, True) self.assertEqual(light.requires_turn_on, False) self.assertEqual(light._protocol.power_push_updates, True) self.assertEqual(light._protocol.state_push_updates, True) light.setRgbw(0, 255, 0) self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 2) self.assertEqual( mock_send.call_args, mock.call( bytearray( b"\xb0\xb1\xb2\xb3\x00\x01\x01\x00\x00\rA\x01\x00\xff\x00\x00\x00\x00`\xff\x00\x00\xa0\x15" ) ), ) light.set_effect("RBM 1", 50) self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 3) self.assertEqual( mock_send.call_args, mock.call( bytearray(b"\xb0\xb1\xb2\xb3\x00\x01\x01\x01\x00\x05B\x012d\xd9\x80") ), ) light._transition_complete_time = 0 light.update_state() self.assertEqual( light.__str__(), "ON [Pattern: RBM 1 (Speed 16%) raw state: 129,163,35,37,1,16,100,0,0,0,4,0,240,213,]", ) assert light.effect == "RBM 1" assert light.brightness == 255 assert light.getSpeed() == 16 data = light._protocol.construct_zone_change( 2, [(255, 255, 255), (0, 255, 0)], 100, MultiColorEffects.STATIC ) assert data == ( b"\xb0\xb1\xb2\xb3\x00\x01\x01\x03\x00\x0fY\x00\x0f\xff\xff\xff\x00\xff\x00\x00\x1e\x01d\x00\xe7\xa8" ) data = light._protocol.construct_zone_change( 4, [(255, 255, 255), (0, 255, 0)], 100, MultiColorEffects.STATIC ) assert data == ( b"\xb0\xb1\xb2\xb3\x00\x01\x01\x04\x00\x15Y\x00\x15\xff\xff\xff\xff\xff\xff\x00\xff\x00\x00\xff\x00\x00\x1e\x01d\x00\xe9\xb3" ) @patch("flux_led.WifiLedBulb._send_msg") @patch("flux_led.WifiLedBulb._read_msg") @patch("flux_led.WifiLedBulb.connect") def test_original_addressable_strip_effects( self, mock_connect, mock_read, mock_send ): calls = 0 def read_data(expected): nonlocal calls calls += 1 if calls == 1: self.assertEqual(expected, 2) return bytearray(b"\x81\xA1") if calls == 2: self.assertEqual(expected, 12) return bytearray(b"#a\x41\x10\xff\x00\x00\x00\x04\x00\xf0\xea") if calls == 3: self.assertEqual(expected, 14) return bytearray( b"\x81\xA1#\x00\xa1\x01\x64\x00\x00\x00\x04\x00\xf0\x3f" ) if calls == 4: self.assertEqual(expected, 14) return bytearray( b"\x81\xA1\x23\x00\x61\x64\x07\x00\x21\x03\x03\x01\x2C\x65" ) raise ValueError("Too many calls") mock_read.side_effect = read_data light = flux_led.WifiLedBulb("192.168.1.164") self.assertEqual(light.speed_adjust_off, False) self.assertEqual(light.dimmable_effects, False) self.assertEqual(light._protocol.power_push_updates, True) self.assertEqual(light._protocol.state_push_updates, False) self.assertEqual(light.requires_turn_on, False) self.assertEqual(light.model_num, 0xA1) self.assertEqual(light.model, "Addressable v1 (0xA1)") assert len(light.effect_list) == 301 assert light.color_modes == {COLOR_MODE_RGB} self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 1) self.assertEqual(mock_send.call_args, mock.call(bytearray(LEDENET_STATE_QUERY))) self.assertEqual( light.__str__(), "ON [Color: (255, 0, 0) Brightness: 100% raw state: 129,161,35,97,65,16,255,0,0,0,4,0,240,234,]", ) self.assertEqual(light.protocol, PROTOCOL_LEDENET_ADDRESSABLE_A1) self.assertEqual(light.is_on, True) self.assertEqual(light.mode, "color") self.assertEqual(light.warm_white, 0) self.assertEqual(light.brightness, 255) self.assertEqual(light.rgbwcapable, False) self.assertEqual(light.device_type, flux_led.DeviceType.Bulb) light.setRgbw(0, 255, 0) self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 2) self.assertEqual( mock_send.call_args, mock.call(bytearray(b"1\x00\xff\x00\x00\x00\xf0\x0f/")), ) light.set_effect( "Overlay circularly, 7 colors with black background from start to end", 50 ) self.assertEqual(mock_read.call_count, 2) self.assertEqual(mock_send.call_count, 3) self.assertEqual(mock_send.call_args, mock.call(bytearray(b"a\x00\xa12\x0fC"))) assert light.brightness == 255 light._transition_complete_time = 0 light.update_state() self.assertEqual( light.__str__(), "ON [Pattern: Overlay circularly, 7 colors with black background from start to end (Speed 1%) raw state: 129,161,35,0,161,1,100,0,0,0,4,0,240,63,]", ) assert ( light.effect == "Overlay circularly, 7 colors with black background from start to end" ) assert light.getSpeed() == 1 light.set_effect("random", 50) self.assertEqual(mock_send.call_count, 5) light.set_levels(128, 0, 0) self.assertEqual(mock_read.call_count, 3) self.assertEqual(mock_send.call_count, 6) self.assertEqual( mock_send.call_args, mock.call(bytearray(b"1\x80\x00\x00\x00\x00\xf0\x0f\xb0")), ) light.update_state() assert light.effect is None assert light.brightness == 128 with pytest.raises(ValueError): light.setPresetPattern(1, 50, 200) with pytest.raises(ValueError): light.setPresetPattern(305, 50, 100) Danielhiversen-flux_led-bfd1bbe/tests_aio.py000066400000000000000000004022241447734565100214710ustar00rootroot00000000000000import asyncio import contextlib import datetime import json import logging import time import sys from unittest.mock import MagicMock, call, patch try: from unittest.mock import AsyncMock except ImportError: from unittest.mock import MagicMock as AsyncMock import pytest from flux_led import aiodevice, aioscanner, DeviceUnavailableException from flux_led.aio import AIOWifiLedBulb from flux_led.aioprotocol import AIOLEDENETProtocol from flux_led.aioscanner import AIOBulbScanner, LEDENETDiscovery from flux_led.const import ( COLOR_MODE_CCT, COLOR_MODE_RGB, COLOR_MODE_RGBW, COLOR_MODE_RGBWW, EFFECT_MUSIC, MAX_TEMP, MIN_TEMP, PUSH_UPDATE_INTERVAL, MultiColorEffects, WhiteChannelType, ) from flux_led.protocol import ( ProtocolLEDENETCCT, ProtocolLEDENETCCTWrapped, PROTOCOL_LEDENET_8BYTE_AUTO_ON, PROTOCOL_LEDENET_8BYTE_DIMMABLE_EFFECTS, PROTOCOL_LEDENET_9BYTE, PROTOCOL_LEDENET_ADDRESSABLE_CHRISTMAS, PROTOCOL_LEDENET_ORIGINAL, PowerRestoreState, PowerRestoreStates, RemoteConfig, ) from flux_led.scanner import ( FluxLEDDiscovery, create_udp_socket, is_legacy_device, merge_discoveries, ) from flux_led.timer import LedTimer IP_ADDRESS = "127.0.0.1" MODEL_NUM_HEX = "0x35" MODEL = "AZ120444" MODEL_DESCRIPTION = "Bulb RGBCW" FLUX_MAC_ADDRESS = "aabbccddeeff" FLUX_DISCOVERY_PARTIAL = FluxLEDDiscovery( ipaddr=IP_ADDRESS, model=MODEL, id=FLUX_MAC_ADDRESS, model_num=None, version_num=None, firmware_date=None, model_info=None, model_description=None, ) FLUX_DISCOVERY = FluxLEDDiscovery( ipaddr=IP_ADDRESS, model=MODEL, id=FLUX_MAC_ADDRESS, model_num=0x25, version_num=0x04, firmware_date=datetime.date(2021, 5, 5), model_info=MODEL, model_description=MODEL_DESCRIPTION, ) FLUX_DISCOVERY_24G_REMOTE = FluxLEDDiscovery( ipaddr=IP_ADDRESS, model="AK001-ZJ2148", id=FLUX_MAC_ADDRESS, model_num=0x25, version_num=0x04, firmware_date=datetime.date(2021, 5, 5), model_info=MODEL, model_description=MODEL_DESCRIPTION, ) FLUX_DISCOVERY_LEGACY = FluxLEDDiscovery( ipaddr=IP_ADDRESS, model=MODEL, id="ACCF23123456", model_num=0x23, version_num=0x04, firmware_date=datetime.date(2021, 5, 5), model_info=MODEL, model_description=MODEL_DESCRIPTION, ) FLUX_DISCOVERY_MISSING_HARDWARE = FluxLEDDiscovery( ipaddr=IP_ADDRESS, model=None, id=FLUX_MAC_ADDRESS, model_num=0x25, version_num=0x04, firmware_date=datetime.date(2021, 5, 5), model_info=MODEL, model_description=MODEL_DESCRIPTION, ) class MinJSONEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, set): return list(o) return json.JSONEncoder.default(self, o) def mock_coro(return_value=None, exception=None): """Return a coro that returns a value or raise an exception.""" fut = asyncio.Future() if exception is not None: fut.set_exception(exception) else: fut.set_result(return_value) return fut @pytest.fixture async def mock_discovery_aio_protocol(): """Fixture to mock an asyncio connection.""" loop = asyncio.get_running_loop() future = asyncio.Future() async def _wait_for_connection(): transport, protocol = await future await asyncio.sleep(0) await asyncio.sleep(0) return transport, protocol async def _mock_create_datagram_endpoint(func, sock=None): protocol: LEDENETDiscovery = func() transport = MagicMock() protocol.connection_made(transport) with contextlib.suppress(asyncio.InvalidStateError): future.set_result((transport, protocol)) return transport, protocol with patch.object( loop, "create_datagram_endpoint", _mock_create_datagram_endpoint ), patch.object(aioscanner, "MESSAGE_SEND_INTERLEAVE_DELAY", 0): yield _wait_for_connection @pytest.fixture async def mock_aio_protocol(): """Fixture to mock an asyncio connection.""" loop = asyncio.get_running_loop() future = asyncio.Future() async def _wait_for_connection(): transport, protocol = await future await asyncio.sleep(0) await asyncio.sleep(0) await asyncio.sleep(0) return transport, protocol async def _mock_create_connection(func, ip, port): protocol: AIOLEDENETProtocol = func() transport = MagicMock() protocol.connection_made(transport) with contextlib.suppress(asyncio.InvalidStateError): future.set_result((transport, protocol)) return transport, protocol with patch.object(loop, "create_connection", _mock_create_connection): yield _wait_for_connection @pytest.mark.asyncio async def test_no_initial_response(mock_aio_protocol): """Test we try switching protocol if we get no initial response.""" light = AIOWifiLedBulb("192.168.1.166", timeout=0.01) assert light.protocol is None def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() with pytest.raises(RuntimeError): await task assert transport.mock_calls == [ call.get_extra_info("peername"), call.write(bytearray(b"\x81\x8a\x8b\x96")), call.write_eof(), call.close(), ] assert not light.available assert light.protocol is PROTOCOL_LEDENET_ORIGINAL @pytest.mark.asyncio async def test_invalid_initial_response(mock_aio_protocol): """Test we try switching protocol if we an unexpected response.""" light = AIOWifiLedBulb("192.168.1.166", timeout=0.01) def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received(b"\x31\x25") with pytest.raises(RuntimeError): await task assert transport.mock_calls == [ call.get_extra_info("peername"), call.write(bytearray(b"\x81\x8a\x8b\x96")), call.write_eof(), call.close(), ] assert not light.available @pytest.mark.asyncio async def test_cannot_determine_strip_type(mock_aio_protocol): """Test we raise RuntimeError when we cannot determine the strip type.""" light = AIOWifiLedBulb("192.168.1.166", timeout=0.01) def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) await mock_aio_protocol() # protocol state light._aio_protocol.data_received( b"\x81\xA3#\x25\x01\x10\x64\x00\x00\x00\x04\x00\xf0\xd5" ) with pytest.raises(RuntimeError): await task assert not light.available @pytest.mark.asyncio async def test_setting_discovery(mock_aio_protocol): """Test we can pass discovery to AIOWifiLedBulb.""" light = AIOWifiLedBulb("192.168.1.166", timeout=0.01) def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) await mock_aio_protocol() # protocol state light._aio_protocol.data_received( b"\x81\x35\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xee" ) discovery = FluxLEDDiscovery( { "firmware_date": datetime.date(2021, 1, 9), "id": "B4E842E10586", "ipaddr": "192.168.213.259", "model": "AK001-ZJ2145", "model_description": "Bulb RGBCW", "model_info": "ZG-BL-PWM", "model_num": 53, "remote_access_enabled": False, "remote_access_host": None, "remote_access_port": None, "version_num": 98, } ) await task assert light.available assert light.model == "Bulb RGBCW (0x35)" light.discovery = discovery assert light.model == "Bulb RGBCW (0x35)" assert light.discovery == discovery @pytest.mark.asyncio async def test_reassemble(mock_aio_protocol): """Test we can reassemble.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde" ) await task assert light.color_modes == {COLOR_MODE_RGBWW, COLOR_MODE_CCT} assert light.protocol == PROTOCOL_LEDENET_9BYTE assert light.model_num == 0x25 assert light.model == "Controller RGB/WW/CW (0x25)" assert light.is_on is True assert len(light.effect_list) == 21 light._aio_protocol.data_received( b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde" b"\x81\x25\x24\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xdf" ) await asyncio.sleep(0) assert light.is_on is False light._aio_protocol.data_received(b"\x81") light._aio_protocol.data_received( b"\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f" ) light._aio_protocol.data_received(b"\xde") await asyncio.sleep(0) assert light.is_on is True transport.reset_mock() await light.async_set_device_config() assert len(transport.mock_calls) == 1 assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"b\x05\x0fv" transport.reset_mock() await light.async_set_device_config(operating_mode="CCT") assert len(transport.mock_calls) == 1 assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"b\x02\x0fs" @pytest.mark.asyncio async def test_extract_from_outer_message(mock_aio_protocol): """Test we can can extract a message wrapped with an outer message.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) await mock_aio_protocol() light._aio_protocol.data_received( b"\xb0\xb1\xb2\xb3\x00\x01\x01\x81\x00\x0e\x81\x1a\x23\x61\x07\x00\xff\x00\x00\x00\x01\x00\x06\x2c\xaf" b"\xb0\xb1\xb2\xb3\x00\x01\x01\x81\x00\x0e\x81\x1a\x23\x61\x07\x00\xff\x00\x00\x00\x01\x00\x06\x2c\xaf" ) await task assert light.color_modes == {COLOR_MODE_RGB} assert light.protocol == PROTOCOL_LEDENET_ADDRESSABLE_CHRISTMAS assert light.model_num == 0x1A assert light.model == "String Lights (0x1A)" assert light.is_on is True assert len(light.effect_list) == 101 assert light.rgb == (255, 0, 0) @pytest.mark.asyncio async def test_extract_from_outer_message_and_reassemble(mock_aio_protocol): """Test we can can extract a message wrapped with an outer message.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) await mock_aio_protocol() for ( byte ) in b"\xb0\xb1\xb2\xb3\x00\x01\x01\x81\x00\x0e\x81\x1a\x23\x61\x07\x00\xff\x00\x00\x00\x01\x00\x06\x2c\xaf": light._aio_protocol.data_received(bytearray([byte])) await task assert light.color_modes == {COLOR_MODE_RGB} assert light.protocol == PROTOCOL_LEDENET_ADDRESSABLE_CHRISTMAS assert light.model_num == 0x1A assert light.model == "String Lights (0x1A)" assert light.is_on is True assert len(light.effect_list) == 101 assert light.rgb == (255, 0, 0) @pytest.mark.asyncio async def test_turn_on_off(mock_aio_protocol, caplog: pytest.LogCaptureFixture): """Test we can turn on and off.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde" ) await task data = [] def _send_data(*args, **kwargs): light._aio_protocol.data_received(data.pop(0)) with patch.object(aiodevice, "POWER_STATE_TIMEOUT", 0.010), patch.object( light._aio_protocol, "write", _send_data ): data = [ b"\xF0\x71\x24\x85", b"\x81\x25\x24\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xdf", ] await light.async_turn_off() await asyncio.sleep(0) assert light.is_on is False assert len(data) == 1 data = [ b"\xF0\x71\x24\x85", b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde", ] await light.async_turn_on() await asyncio.sleep(0) assert light.is_on is True assert len(data) == 0 data = [b"\xF0\x71\x24\x85"] await light.async_turn_off() await asyncio.sleep(0) assert light.is_on is False assert len(data) == 0 data = [ b"\x81\x25\x24\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xdf", b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde", ] await light.async_turn_on() await asyncio.sleep(0) assert light.is_on is True assert len(data) == 0 data = [ b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde", b"\x81\x25\x24\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xdf", ] await light.async_turn_off() await asyncio.sleep(0) assert light.is_on is False assert len(data) == 0 data = [ *( b"\xF0\x71\x24\x85", b"\x81\x25\x24\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xdf", ) * 5 ] await light.async_turn_on() await asyncio.sleep(0) assert light.is_on is True assert len(data) == 3 light._aio_protocol.data_received( b"\x81\x25\x24\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xdf" ) assert ( light.is_on is True ) # transition time should now be in effect since we forced state data = [*(b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde",) * 14] await light.async_turn_off() await asyncio.sleep(0) # If all we get is on 0x81 responses, the bulb failed to turn off assert light.is_on is True assert len(data) == 2 await asyncio.sleep(0) caplog.clear() caplog.set_level(logging.DEBUG) # Handle the failure case with patch.object(aiodevice, "POWER_STATE_TIMEOUT", 0.010): await asyncio.create_task(light.async_turn_off()) assert light.is_on is True assert "Failed to set power state to False (1/6)" in caplog.text assert "Failed to set power state to False (2/6)" in caplog.text assert "Failed to set power state to False (3/6)" in caplog.text assert "Failed to set power state to False (4/6)" in caplog.text assert "Failed to set power state to False (5/6)" in caplog.text assert "Failed to set power state to False (6/6)" in caplog.text with patch.object(light._aio_protocol, "write", _send_data), patch.object( aiodevice, "POWER_STATE_TIMEOUT", 0.010 ): data = [ *( b"\x0F\x71\x24\xA4", b"\x81\x25\x24\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xdf", ) * 5 ] await light.async_turn_off() assert light.is_on is False assert len(data) == 9 data = [ *( b"\xF0\x71\x23\xA3", b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde", ) * 5 ] await light.async_turn_on() assert light.is_on is True assert len(data) == 9 data = [ *( b"\x0F\x71\x24\xA4", b"\x81\x25\x24\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xdf", ) * 5 ] await light.async_turn_off() assert light.is_on is False assert len(data) == 9 await asyncio.sleep(0) caplog.clear() caplog.set_level(logging.DEBUG) # Handle the failure case with patch.object(aiodevice, "POWER_STATE_TIMEOUT", 0.010): await asyncio.create_task(light.async_turn_on()) assert light.is_on is False assert "Failed to set power state to True (1/6)" in caplog.text assert "Failed to set power state to True (2/6)" in caplog.text assert "Failed to set power state to True (3/6)" in caplog.text assert "Failed to set power state to True (4/6)" in caplog.text assert "Failed to set power state to True (5/6)" in caplog.text assert "Failed to set power state to True (6/6)" in caplog.text @pytest.mark.asyncio async def test_turn_on_off_via_power_state_message( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test we can turn on and off via power state message.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass with patch.object(aiodevice, "POWER_STATE_TIMEOUT", 0.010): task = asyncio.create_task(light.async_setup(_updated_callback)) await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde" ) await task task = asyncio.create_task(light.async_turn_off()) # Wait for the future to get added await asyncio.sleep(0) light._ignore_next_power_state_update = False light._aio_protocol.data_received(b"\x0F\x71\x24\xA4") await asyncio.sleep(0) assert light.is_on is False await task task = asyncio.create_task(light.async_turn_on()) await asyncio.sleep(0) light._ignore_next_power_state_update = False light._aio_protocol.data_received(b"\x0F\x71\x23\xA3") await asyncio.sleep(0) assert light.is_on is True await task @pytest.mark.asyncio async def test_turn_on_off_via_assessable_state_message( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test we can turn on and off via addressable state message.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass with patch.object(aiodevice, "POWER_STATE_TIMEOUT", 0.025): task = asyncio.create_task(light.async_setup(_updated_callback)) await mock_aio_protocol() # protocol state light._aio_protocol.data_received( b"\x81\xA3#\x25\x01\x10\x64\x00\x00\x00\x04\x00\xf0\xd5" ) # ic sorting light._aio_protocol.data_received( b"\x00\x63\x00\x19\x00\x02\x04\x03\x19\x02\xA0" ) await task data = None def _send_data(*args, **kwargs): light._aio_protocol.data_received(data) with patch.object(light._aio_protocol, "write", _send_data): data = b"\xB0\xB1\xB2\xB3\x00\x01\x01\x23\x00\x0E\x81\xA3\x24\x25\xFF\x47\x64\xFF\xFF\x00\x01\x00\x1E\x34\x61" await light.async_turn_off() assert light.is_on is False data = b"\xB0\xB1\xB2\xB3\x00\x01\x01\x24\x00\x0E\x81\xA3\x23\x25\x5F\x21\x64\xFF\xFF\x00\x01\x00\x1E\x6D\xD4" await light.async_turn_on() assert light.is_on is True @pytest.mark.asyncio async def test_shutdown(mock_aio_protocol): """Test we can shutdown.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde" ) await task await light.async_stop() await asyncio.sleep(0) # make sure nothing throws @pytest.mark.asyncio async def test_handling_connection_lost(mock_aio_protocol): """Test we can reconnect.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass with patch.object(aiodevice, "POWER_STATE_TIMEOUT", 0.025): task = asyncio.create_task(light.async_setup(_updated_callback)) await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde" ) await task light._aio_protocol.connection_lost(None) await asyncio.sleep(0) # make sure nothing throws # Test we reconnect and can turn off task = asyncio.create_task(light.async_turn_off()) # Wait for the future to get added await asyncio.sleep(0.1) # wait for reconnect light._aio_protocol.data_received( b"\x81\x25\x24\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xdf" ) await asyncio.sleep(0) assert light.is_on is False await task @pytest.mark.asyncio async def test_handling_unavailable_after_no_response(mock_aio_protocol): """Test we handle the bulb not responding.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde" ) await task await light.async_update() await light.async_update() await light.async_update() await light.async_update() with pytest.raises(RuntimeError): await light.async_update() assert light.available is False @pytest.mark.asyncio async def test_handling_unavailable_after_no_response_force(mock_aio_protocol): """Test we handle the bulb not responding.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, original_aio_protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\xA3#\x25\x01\x10\x64\x00\x00\x00\x04\x00\xf0\xd5" ) # ic state light._aio_protocol.data_received( b"\xB0\xB1\xB2\xB3\x00\x01\x01\x00\x00\x0B\x00\x63\x00\x90\x00\x01\x07\x08\x90\x01\x94\xFB" ) await task assert light._protocol.power_push_updates is True transport.reset_mock() await light.async_update() assert len(transport.mock_calls) == 1 assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"\xb0\xb1\xb2\xb3\x00\x01\x01\x01\x00\x04\x81\x8a\x8b\x96\xf9" ) light._last_update_time = time.monotonic() - (PUSH_UPDATE_INTERVAL + 1) await light.async_update() light._last_update_time = time.monotonic() - (PUSH_UPDATE_INTERVAL + 1) await light.async_update() light._last_update_time = time.monotonic() - (PUSH_UPDATE_INTERVAL + 1) await light.async_update() light._last_update_time = time.monotonic() - (PUSH_UPDATE_INTERVAL + 1) with pytest.raises(RuntimeError): await light.async_update() assert light.available is False # simulate reconnect await light.async_update() assert light._aio_protocol != original_aio_protocol light._aio_protocol = original_aio_protocol transport.reset_mock() light._aio_protocol.data_received( b"\x81\xA3#\x25\x01\x10\x64\x00\x00\x00\x04\x00\xf0\xd5" ) await light.async_update(force=True) assert len(transport.mock_calls) == 1 assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"\xb0\xb1\xb2\xb3\x00\x01\x01\x06\x00\x04\x81\x8a\x8b\x96\xfe" ) assert light.available is True light._aio_protocol.data_received( b"\x81\xA3#\x25\x01\x10\x64\x00\x00\x00\x04\x00\xf0\xd5" ) assert light.available is True @pytest.mark.asyncio async def test_async_set_levels(mock_aio_protocol, caplog: pytest.LogCaptureFixture): """Test we can set levels.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x33\x24\x61\x23\x01\x00\xFF\x00\x00\x04\x00\x0F\x6F" ) await task assert light.model_num == 0x33 assert light.version_num == 4 assert light.wiring == "GRB" assert light.wiring_num == 2 assert light.wirings == ["RGB", "GRB", "BRG"] assert light.operating_mode is None assert light.dimmable_effects is False assert light.requires_turn_on is True assert light._protocol.power_push_updates is False assert light._protocol.state_push_updates is False transport.reset_mock() await light.async_set_device_config() assert len(transport.mock_calls) == 1 assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"b\x00\x02\x0fs" transport.reset_mock() await light.async_set_device_config(wiring="BRG") assert len(transport.mock_calls) == 1 assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"b\x00\x03\x0ft" transport.reset_mock() with pytest.raises(ValueError): # ValueError: RGBW command sent to non-RGBW devic await light.async_set_levels(255, 255, 255, 255, 255) await light.async_set_levels(255, 0, 0) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\xff\x00\x00\x00\x00\x0f?" # light is on light._aio_protocol.data_received( b"\x81\x33\x23\x25\x01\x10\x64\x00\x00\x00\x04\x00\xf0\x65" ) transport.reset_mock() await light.async_update() await light.async_update() await light.async_update() await light.async_update() await asyncio.sleep(0) assert len(transport.mock_calls) == 4 # light is off light._aio_protocol.data_received( b"\x81\x33\x24\x25\x01\x10\x64\x00\x00\x00\x04\x00\xf0\x66" ) transport.reset_mock() await light.async_update() await light.async_update() await light.async_update() await light.async_update() await asyncio.sleep(0) assert len(transport.mock_calls) == 4 with pytest.raises(ValueError): await light.async_set_preset_pattern(101, 50, 100) @pytest.mark.asyncio async def test_async_set_levels_0x52( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test we can set levels.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x52\x23\x61\x00\x00\xFF\x00\x00\x00\x01\x00\x00\x57" ) await task assert light.model_num == 0x52 assert light.version_num == 1 assert light.wiring is None assert light.wiring_num is None assert light.wirings is None assert light.operating_mode is None assert light.dimmable_effects is False assert light.requires_turn_on is True assert light._protocol.power_push_updates is False assert light._protocol.state_push_updates is False transport.reset_mock() with pytest.raises(ValueError): # ValueError: RGBW command sent to non-RGBW devic await light.async_set_levels(255, 255, 255, 255, 255) transport.reset_mock() await light.async_set_levels(0, 0, 0, 255, 255) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\xff\xff\x00\x00\x00\x0f>" transport.reset_mock() await light.async_set_levels(0, 0, 0, 128, 255) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\x80\xff\x00\x00\x00\x0f\xbf" transport.reset_mock() await light.async_set_levels(0, 0, 0, 0, 128) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\x00\x80\x00\x00\x00\x0f\xc0" @pytest.mark.asyncio async def test_async_set_effect(mock_aio_protocol, caplog: pytest.LogCaptureFixture): """Test we can set an effect.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\xA3#\x25\x01\x10\x64\x00\x00\x00\x04\x00\xf0\xd5" ) # ic state light._aio_protocol.data_received(b"\x00\x63\x00\x19\x00\x02\x04\x03\x19\x02\xA0") await task assert light.model_num == 0xA3 assert light.dimmable_effects is True assert light.requires_turn_on is False assert light._protocol.power_push_updates is True assert light._protocol.state_push_updates is True transport.reset_mock() await light.async_set_effect("random", 50) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0].startswith(b"\xb0\xb1\xb2\xb3") transport.reset_mock() await light.async_set_effect("RBM 1", 50) assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"\xb0\xb1\xb2\xb3\x00\x01\x01\x02\x00\x05B\x012d\xd9\x81" ) assert light.effect == "RBM 1" transport.reset_mock() await light.async_set_brightness(255) assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"\xb0\xb1\xb2\xb3\x00\x01\x01\x03\x00\x05B\x01\x10d\xb7>" ) transport.reset_mock() await light.async_set_brightness(128) assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"\xb0\xb1\xb2\xb3\x00\x01\x01\x04\x00\x05B\x01\x102\x85\xdb" ) for i in range(5, 255): transport.reset_mock() await light.async_set_brightness(128) assert transport.mock_calls[0][0] == "write" counter_byte = transport.mock_calls[0][1][0][7] assert counter_byte == i transport.reset_mock() await light.async_set_brightness(128) assert transport.mock_calls[0][0] == "write" counter_byte = transport.mock_calls[0][1][0][7] assert counter_byte == 0 @pytest.mark.asyncio async def test_SK6812RGBW(mock_aio_protocol, caplog: pytest.LogCaptureFixture): """Test we can set set zone colors.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\xA3#\x25\x01\x10\x64\x00\x00\x00\x04\x00\xf0\xd5" ) # ic state light._aio_protocol.data_received( b"\xB0\xB1\xB2\xB3\x00\x01\x01\x00\x00\x0B\x00\x63\x00\x90\x00\x01\x07\x08\x90\x01\x94\xFB" ) await task assert light.pixels_per_segment == 144 assert light.segments == 1 assert light.music_pixels_per_segment == 144 assert light.music_segments == 1 assert light.ic_types == [ "WS2812B", "SM16703", "SM16704", "WS2811", "UCS1903", "SK6812", "SK6812RGBW", "INK1003", "UCS2904B", ] assert light.ic_type == "SK6812RGBW" assert light.ic_type_num == 7 assert light.operating_mode is None assert light.operating_modes is None assert light.wiring == "WGRB" assert light.wiring_num == 8 assert light.wirings == [ "RGBW", "RBGW", "GRBW", "GBRW", "BRGW", "BGRW", "WRGB", "WRBG", "WGRB", "WGBR", "WBRG", "WBGR", ] assert light.model_num == 0xA3 assert light.dimmable_effects is True assert light.requires_turn_on is False assert light.color_mode == COLOR_MODE_RGBW assert light.color_modes == {COLOR_MODE_RGBW, COLOR_MODE_CCT} diag = light.diagnostics assert isinstance(json.dumps(diag, cls=MinJSONEncoder), str) assert diag["device_state"]["wiring_num"] == 8 assert ( diag["last_messages"]["state"] == "0x81 0xA3 0x23 0x25 0x01 0x10 0x64 0x00 0x00 0x00 0x04 0x00 0xF0 0xD5" ) transport.reset_mock() with patch.object(light, "_async_device_config_resync", mock_coro): await light.async_set_device_config(ic_type="SK6812RGBW", wiring="WRGB") assert len(transport.mock_calls) == 1 assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"\xb0\xb1\xb2\xb3\x00\x01\x01\x01\x00\x0bb\x00\x90\x00\x01\x07\x06\x90\x01\xf0\x81\xd6" ) transport.reset_mock() with patch.object(aiodevice, "COMMAND_SPACING_DELAY", 0): await light.async_set_levels(r=255, g=255, b=255, w=255) assert transport.mock_calls == [ call.write( bytearray( b"\xb0\xb1\xb2\xb3\x00\x01\x01\x02\x00\rA\x01\xff\xff\xff\x00\x00\x00`\xff\x00\x00\x9e\x13" ) ), call.write(bytearray(b"\xb0\xb1\xb2\xb3\x00\x01\x01\x03\x00\x03G\xffFZ")), ] transport.reset_mock() await light.async_set_levels(w=255) assert transport.mock_calls == [ call.write(bytearray(b"\xb0\xb1\xb2\xb3\x00\x01\x01\x04\x00\x03G\xffF[")) ] light._transition_complete_time = 0 light._aio_protocol.data_received( b"\x81\xA3\x23\x61\x01\x32\x40\x40\x40\x80\x01\x00\x90\xAC" ) assert light.raw_state.warm_white == 0 light._aio_protocol.data_received( b"\x81\xA3\x23\x61\x01\x32\x40\x40\x40\xE4\x01\x00\x90\x10" ) assert light.raw_state.warm_white == 255 light._aio_protocol.data_received( b"\x81\xA3\x23\x61\x01\x32\x40\x40\x40\xB1\x01\x00\x90\xDD" ) assert light.raw_state.warm_white == 125 transport.reset_mock() with patch.object(aiodevice, "COMMAND_SPACING_DELAY", 0): await light.async_set_white_temp(6500, 255) assert transport.mock_calls == [ call.write( bytearray( bytearray( b"\xb0\xb1\xb2\xb3\x00\x01\x01\x05\x00\rA\x01\xff\xff\xff\x00\x00\x00`\xff\x00\x00\x9e\x16" ) ) ), call.write(bytearray(b"\xb0\xb1\xb2\xb3\x00\x01\x01\x06\x00\x03G\x00G_")), ] @pytest.mark.asyncio async def test_ws2812b_a1(mock_aio_protocol, caplog: pytest.LogCaptureFixture): """Test we can determine ws2812b configuration.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\xA1#\x25\x01\x10\x64\x00\x00\x00\x04\x00\xf0\xd3" ) # ic state light._aio_protocol.data_received( b"\x63\x00\x32\x04\x00\x00\x00\x00\x00\x00\x02\x9B" ) await task assert light._protocol.timer_count == 6 assert light._protocol.timer_len == 14 assert light._protocol.timer_response_len == 88 assert light.pixels_per_segment == 50 assert light.segments is None assert light.music_pixels_per_segment is None assert light.music_segments is None assert light.ic_types == [ "UCS1903", "SM16703", "WS2811", "WS2812B", "SK6812", "INK1003", "WS2801", "LB1914", ] assert light.ic_type == "WS2812B" assert light.ic_type_num == 4 assert light.operating_mode is None assert light.operating_modes is None assert light.wiring == "GRB" assert light.wiring_num == 2 assert light.wirings == ["RGB", "RBG", "GRB", "GBR", "BRG", "BGR"] assert light.model_num == 0xA1 assert light.dimmable_effects is False assert light.requires_turn_on is False transport.reset_mock() with patch.object(light, "_async_device_config_resync", mock_coro): await light.async_set_device_config() assert len(transport.mock_calls) == 1 assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"b\x002\x04\x00\x00\x00\x00\x00\x00\x02\xf0\x8a" ) transport.reset_mock() with patch.object(light, "_async_device_config_resync", mock_coro): await light.async_set_device_config( ic_type="SK6812", wiring="GRB", pixels_per_segment=300 ) assert len(transport.mock_calls) == 1 assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"b\x01,\x05\x00\x00\x00\x00\x00\x00\x02\xf0\x86" ) @pytest.mark.asyncio async def test_ws2811_a2(mock_aio_protocol, caplog: pytest.LogCaptureFixture): """Test we can determine ws2811 configuration.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\xA2#\x25\x01\x10\x64\x00\x00\x00\x04\x00\xf0\xd4" ) # ic state light._aio_protocol.data_received(b"\x00\x63\x00\x19\x00\x02\x04\x03\x19\x02\xA0") await task assert light.pixels_per_segment == 25 assert light.segments == 2 assert light.music_pixels_per_segment == 25 assert light.music_segments == 2 assert light.ic_type == "WS2811B" assert light.ic_type_num == 4 assert light.operating_mode is None assert light.operating_modes is None assert light.wiring == "GBR" assert light.wiring_num == 3 assert light.wirings == ["RGB", "RBG", "GRB", "GBR", "BRG", "BGR"] assert light.model_num == 0xA2 assert light.dimmable_effects is True assert light.requires_turn_on is False transport.reset_mock() with patch.object(light, "_async_device_config_resync", mock_coro): await light.async_set_device_config() assert len(transport.mock_calls) == 1 assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"b\x00\x19\x00\x02\x04\x03\x19\x02\xf0\x8f" transport.reset_mock() with patch.object(light, "_async_device_config_resync", mock_coro): await light.async_set_device_config( ic_type="SK6812", wiring="GRB", pixels_per_segment=300 ) assert len(transport.mock_calls) == 1 assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"b\x01,\x00\x02\x05\x02\x19\x02\xf0\xa3" transport.reset_mock() with patch.object(light, "_async_device_config_resync", mock_coro): await light.async_set_device_config( pixels_per_segment=1000, segments=1000, music_pixels_per_segment=1000, music_segments=1000, ) assert len(transport.mock_calls) == 1 assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"b\x01,\x00\x06\x04\x03\x96\x06\xf0(" @pytest.mark.asyncio async def test_ws2812b_older_a3(mock_aio_protocol, caplog: pytest.LogCaptureFixture): """Test we can determine ws2812b configuration on an older a3.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\xA3\x23\x61\x01\x32\x00\x64\x00\x00\x01\x00\x1E\x5E" ) # ic state light._aio_protocol.data_received( b"\xB0\xB1\xB2\xB3\x00\x01\x01\x00\x00\x0B\x01\x63\x00\x1E\x00\x0A\x01\x00\x1E\x0A\xB5\x3D" ) await task assert light.pixels_per_segment == 30 assert light.segments == 10 assert light.music_pixels_per_segment == 30 assert light.music_segments == 10 assert light.ic_type == "WS2812B" assert light.ic_type_num == 1 assert light.operating_mode is None assert light.operating_modes is None assert light.wiring == "RGB" assert light.wiring_num == 0 assert light.wirings == ["RGB", "RBG", "GRB", "GBR", "BRG", "BGR"] assert light.model_num == 0xA3 assert light.dimmable_effects is True assert light.requires_turn_on is False transport.reset_mock() with patch.object(light, "_async_device_config_resync", mock_coro): await light.async_set_device_config() assert len(transport.mock_calls) == 1 assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"\xb0\xb1\xb2\xb3\x00\x01\x01\x01\x00\x0bb\x00\x1e\x00\n\x01\x00\x1e\n\xf0\xa3\x1a" ) transport.reset_mock() with patch.object(light, "_async_device_config_resync", mock_coro): await light.async_set_device_config( ic_type="SK6812", wiring="GRB", pixels_per_segment=300 ) assert len(transport.mock_calls) == 1 assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"\xb0\xb1\xb2\xb3\x00\x01\x01\x02\x00\x0bb\x01,\x00\x06\x06\x02\x1e\n\xf0\xb5?" ) transport.reset_mock() with patch.object(light, "_async_device_config_resync", mock_coro): await light.async_set_device_config( pixels_per_segment=1000, segments=1000, music_pixels_per_segment=1000, music_segments=1000, ) assert len(transport.mock_calls) == 1 assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b'\xb0\xb1\xb2\xb3\x00\x01\x01\x03\x00\x0bb\x01,\x00\x06\x01\x00\x96\x06\xf0"\x1a' ) @pytest.mark.asyncio async def test_async_set_zones(mock_aio_protocol, caplog: pytest.LogCaptureFixture): """Test we can set set zone colors.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\xA3#\x25\x01\x10\x64\x00\x00\x00\x04\x00\xf0\xd5" ) # ic state light._aio_protocol.data_received(b"\x00\x63\x00\x19\x00\x02\x04\x03\x19\x02\xA0") # sometimes the devices responds 2x light._aio_protocol.data_received(b"\x00\x63\x00\x19\x00\x02\x04\x03\x19\x02\xA0") await task assert light.pixels_per_segment == 25 assert light.segments == 2 assert light.music_pixels_per_segment == 25 assert light.music_segments == 2 assert light.ic_types == [ "WS2812B", "SM16703", "SM16704", "WS2811", "UCS1903", "SK6812", "SK6812RGBW", "INK1003", "UCS2904B", ] assert light.ic_type == "WS2811" assert light.ic_type_num == 4 assert light.operating_mode is None assert light.operating_modes is None assert light.wiring == "GBR" assert light.wiring_num == 3 assert light.wirings == ["RGB", "RBG", "GRB", "GBR", "BRG", "BGR"] assert light.model_num == 0xA3 assert light.dimmable_effects is True assert light.requires_turn_on is False transport.reset_mock() with patch.object(light, "_async_device_config_resync", mock_coro): await light.async_set_device_config() assert len(transport.mock_calls) == 1 assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"\xb0\xb1\xb2\xb3\x00\x01\x01\x01\x00\x0bb\x00\x19\x00\x02\x04\x03\x19\x02\xf0\x8f\xf2" ) transport.reset_mock() with patch.object(light, "_async_device_config_resync", mock_coro): await light.async_set_device_config( ic_type="SK6812", wiring="GRB", pixels_per_segment=300, segments=2, music_pixels_per_segment=150, music_segments=2, ) assert len(transport.mock_calls) == 1 assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"\xb0\xb1\xb2\xb3\x00\x01\x01\x02\x00\x0bb\x01,\x00\x02\x06\x02\x96\x02\xf0!\x17" ) transport.reset_mock() with patch.object(light, "_async_device_config_resync", mock_coro): await light.async_set_device_config( ic_type="SK6812", wiring="GRB", pixels_per_segment=300, segments=2, music_pixels_per_segment=300, music_segments=2, ) assert len(transport.mock_calls) == 1 assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"\xb0\xb1\xb2\xb3\x00\x01\x01\x03\x00\x0bb\x01,\x00\x02\x06\x02\x96\x02\xf0!\x18" ) transport.reset_mock() await light.async_set_zones( [(255, 0, 0), (0, 0, 255)], 100, MultiColorEffects.STROBE ) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == bytearray( b"\xb0\xb1\xb2\xb3\x00\x01\x01\x04\x00TY\x00T\xff\x00\x00" b"\xff\x00\x00\xff\x00\x00\xff\x00\x00\xff\x00\x00\xff\x00\x00\xff" b"\x00\x00\xff\x00\x00\xff\x00\x00\xff\x00\x00\xff\x00\x00\xff\x00" b"\x00\x00\x00\xff\x00\x00\xff\x00\x00\xff\x00\x00\xff\x00\x00\xff" b"\x00\x00\xff\x00\x00\xff\x00\x00\xff\x00\x00\xff\x00\x00\xff\x00" b"\x00\xff\x00\x00\xff\x00\x00\xff\x00\x1e\x03d\x00\x19R" ) with pytest.raises(ValueError): await light.async_set_zones( [(255, 0, 0) for _ in range(30)], ) @pytest.mark.asyncio async def test_async_set_zones_unsupported_device( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test we can set set zone colors raises valueerror on unsupported.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25#\x25\x01\x10\x64\x00\x00\x00\x04\x00\xf0\x57" ) await task assert light.model_num == 0x25 transport.reset_mock() with pytest.raises(ValueError): await light.async_set_zones( [(255, 0, 0), (0, 0, 255)], 100, MultiColorEffects.STROBE ) @pytest.mark.asyncio async def test_0x06_device_wiring(mock_aio_protocol, caplog: pytest.LogCaptureFixture): """Test we can get wiring for an 0x06.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x06\x24\x61\x24\x01\x00\xFF\x00\x00\x03\x00\xF0\x23" ) await task assert light.model_num == 0x06 assert light.pixels_per_segment is None assert light.segments is None assert light.music_pixels_per_segment is None assert light.music_segments is None assert light.ic_types is None assert light.ic_type is None assert light.operating_mode == "RGB&W" assert light.operating_modes == ["RGB&W", "RGB/W"] assert light.wiring == "GRBW" assert light.wiring_num == 2 assert light.wirings == ["RGBW", "GRBW", "BRGW"] @pytest.mark.asyncio async def test_0x07_device_wiring(mock_aio_protocol, caplog: pytest.LogCaptureFixture): """Test we can get wiring for an 0x07.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x07\x24\x61\xC7\x01\x00\x00\x00\x00\x02\xFF\x0F\xE5" ) await task assert light.model_num == 0x07 assert light.pixels_per_segment is None assert light.segments is None assert light.music_pixels_per_segment is None assert light.music_segments is None assert light.ic_types is None assert light.ic_type is None assert light.operating_mode == "RGB/CCT" assert light.operating_modes == ["RGB&CCT", "RGB/CCT"] assert light.wiring == "CBRGW" assert light.wiring_num == 12 assert light.wirings == [ "RGBCW", "GRBCW", "BRGCW", "RGBWC", "GRBWC", "BRGWC", "WRGBC", "WGRBC", "WBRGC", "CRGBW", "CBRBW", "CBRGW", "WCRGB", "WCGRB", "WCBRG", ] @pytest.mark.asyncio async def test_async_set_music_mode_0x08( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test we can set music mode on an 0x08.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass with patch.object(aiodevice, "COMMAND_SPACING_DELAY", 0): task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x08#\x5d\x01\x10\x64\x00\x00\x00\x04\x00\xf0\x72" ) await task assert light.model_num == 0x08 assert light.version_num == 4 assert light.effect == EFFECT_MUSIC assert light.microphone is True assert light.protocol == PROTOCOL_LEDENET_8BYTE_DIMMABLE_EFFECTS assert light.pixels_per_segment is None assert light.segments is None assert light.music_pixels_per_segment is None assert light.music_segments is None assert light.ic_types is None assert light.ic_type is None assert light.operating_mode is None assert light.operating_modes is None assert light.wiring is None # How can we get this in music mode? assert light.wirings == ["RGB", "GRB", "BRG"] transport.reset_mock() await light.async_set_music_mode() assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"s\x01d\x0f\xe7" assert transport.mock_calls[1][0] == "write" assert transport.mock_calls[1][1][0] == b"7\x00\x007" transport.reset_mock() await light.async_set_music_mode(effect=2) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"s\x01d\x0f\xe7" assert transport.mock_calls[1][0] == "write" assert transport.mock_calls[1][1][0] == b"7\x02\x009" with pytest.raises(ValueError): await light.async_set_music_mode(effect=0x08) @pytest.mark.asyncio async def test_async_set_music_mode_0x08_v1_firmware( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test we can set music mode on an 0x08 with v1 firmware.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass with patch.object(aiodevice, "COMMAND_SPACING_DELAY", 0): task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x08\x23\x62\x23\x01\x80\x00\x80\x00\x01\x00\x00\x33" ) await task assert light.model_num == 0x08 assert light.version_num == 1 assert light.effect == EFFECT_MUSIC assert light.microphone is True assert light.raw_state.red == 128 assert light.raw_state.green == 0 assert light.raw_state.blue == 128 assert light.protocol == PROTOCOL_LEDENET_8BYTE_AUTO_ON # In music mode, we always report 255 otherwise it will likely be 0 assert light.brightness == 255 transport.reset_mock() await light.async_set_music_mode() assert len(transport.mock_calls) == 1 assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"s\x01d\x0f\xe7" @pytest.mark.asyncio async def test_async_set_music_mode_0x08_v2_firmware( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test we can set music mode on an 0x08 with v2 firmware.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass with patch.object(aiodevice, "COMMAND_SPACING_DELAY", 0): task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x08\x23\x62\x23\x01\x80\x00\xFF\x00\x02\x00\x00\xB3" ) await task assert light.model_num == 0x08 assert light.version_num == 2 assert light.effect == EFFECT_MUSIC assert light.microphone is True assert light.protocol == PROTOCOL_LEDENET_8BYTE_DIMMABLE_EFFECTS transport.reset_mock() await light.async_set_music_mode() assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"s\x01d\x0f\xe7" assert transport.mock_calls[1][0] == "write" assert transport.mock_calls[1][1][0] == b"7\x00\x007" @pytest.mark.asyncio async def test_async_set_music_mode_a2( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test we can set music mode on an 0xA2.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\xA2#\x62\x01\x10\x64\x00\x00\x00\x04\x00\xf0\x11" ) # ic state light._aio_protocol.data_received(b"\x00\x63\x00\x19\x00\x02\x04\x03\x19\x02\xA0") await task assert light.model_num == 0xA2 assert light.effect == EFFECT_MUSIC assert light.microphone is True assert light._protocol.state_push_updates is False assert light._protocol.power_push_updates is False transport.reset_mock() await light.async_set_music_mode() assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"s\x01&\x01d\x00\x00\x00\x00\x00dd\xc7" transport.reset_mock() await light.async_set_effect(EFFECT_MUSIC, 100, 100) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"s\x01&\x01d\x00\x00\x00\x00\x00dd\xc7" # light is on light._aio_protocol.data_received( b"\x81\xA2\x23\x62\x01\x10\x64\x00\x00\x00\x04\x00\xf0\x11" ) transport.reset_mock() await light.async_update() await light.async_update() await light.async_update() await light.async_update() await asyncio.sleep(0) assert len(transport.mock_calls) == 4 # light is off light._aio_protocol.data_received( b"\x81\xA2\x24\x62\x01\x10\x64\x00\x00\x00\x04\x00\xf0\x12" ) transport.reset_mock() await light.async_update() await light.async_update() await light.async_update() await light.async_update() await asyncio.sleep(0) assert len(transport.mock_calls) == 4 @pytest.mark.asyncio async def test_async_set_music_mode_a3( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test we can set music mode on an 0xA3.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\xA3#\x62\x01\x10\x64\x00\x00\x00\x04\x00\xf0\x12" ) # ic state light._aio_protocol.data_received(b"\x00\x63\x00\x19\x00\x02\x04\x03\x19\x02\xA0") await task assert light.model_num == 0xA3 assert light.effect == EFFECT_MUSIC assert light.microphone is True transport.reset_mock() await light.async_set_music_mode() assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0].startswith(b"\xb0\xb1\xb2\xb3") with pytest.raises(ValueError): await light.async_set_music_mode(mode=0x08) with pytest.raises(ValueError): await light.async_set_music_mode(effect=0x99) @pytest.mark.asyncio async def test_async_set_music_mode_device_without_mic_0x07( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test we can set music mode on an 0x08.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x07#\x25\x01\x10\x64\x00\x00\x00\x04\x00\xf0\x39" ) await task assert light.model_num == 0x07 assert light.microphone is False transport.reset_mock() with pytest.raises(ValueError): await light.async_set_music_mode() @pytest.mark.asyncio async def test_async_set_white_temp_0x35( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test we can set white temp on a 0x35.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x35\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xee" ) await task assert light.model_num == 0x35 transport.reset_mock() await light.async_set_white_temp(6500, 255) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\x00\x00\x00\x00\xff\x0f\x0fN" @pytest.mark.asyncio async def test_setup_0x35_with_ZJ21410( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test we can setup a 0x35 with the ZJ21410 module.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\xB0\xB1\xB2\xB3\x00\x02\x01\x70\x00\x0E\x81\x35\x23\x61\x17\x04\xD3\xFF\x49\x00\x09\x00\xF0\x69\x19" ) await task assert light.model_num == 0x35 @pytest.mark.asyncio async def test_setup_0x44_with_version_num_10( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test we use the right protocol for 044 with v10.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x44\x24\x61\x01\x01\xFF\x00\xFF\x00\x0A\x00\xF0\x44" ) await task assert light.model_num == 0x44 assert light.protocol == PROTOCOL_LEDENET_8BYTE_AUTO_ON @pytest.mark.asyncio async def test_async_failed_callback( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test we log on failed callback.""" light = AIOWifiLedBulb("192.168.1.166") caplog.set_level(logging.DEBUG) def _updated_callback(*args, **kwargs): raise ValueError("something went wrong") task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\xA3#\x25\x01\x10\x64\x00\x00\x00\x04\x00\xf0\xd5" ) # ic state light._aio_protocol.data_received(b"\x00\x63\x00\x19\x00\x02\x04\x03\x19\x02\xA0") await task assert light.model_num == 0xA3 assert light.dimmable_effects is True assert light.requires_turn_on is False assert "something went wrong" in caplog.text @pytest.mark.asyncio async def test_async_set_custom_effect( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test we can set a custom effect.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde" ) await task assert light.model_num == 0x25 transport.reset_mock() # no values with pytest.raises(ValueError): await light.async_set_custom_pattern([], 50, "jump") await light.async_set_custom_pattern( [ (255, 0, 0), (255, 0, 0), (255, 0, 0), (255, 0, 0), (255, 0, 0), (255, 0, 0), (255, 0, 0), (255, 0, 0), (255, 0, 0), (255, 0, 0), (255, 0, 0), (255, 0, 0), (255, 0, 0), (255, 0, 0), (255, 0, 255), (255, 0, 0), (255, 0, 0), ], 50, "jump", ) assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"Q\xff\x00\x00\x00\xff\x00\x00\x00\xff\x00\x00\x00\xff\x00\x00\x00\xff\x00\x00\x00\xff\x00\x00\x00\xff\x00\x00\x00\xff\x00\x00\x00\xff\x00\x00\x00\xff\x00\x00\x00\xff\x00\x00\x00\xff\x00\x00\x00\xff\x00\x00\x00\xff\x00\x00\x00\xff\x00\xff\x00\xff\x00\x00\x00\x10;\xff\x0f\x99" ) @pytest.mark.asyncio async def test_async_stop(mock_aio_protocol): """Test we can stop without throwing.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde" ) await task await light.async_stop() await asyncio.sleep(0) # make sure nothing throws @pytest.mark.asyncio async def test_async_set_brightness_rgbww(mock_aio_protocol): """Test we can set brightness rgbww.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde" ) await task transport.reset_mock() await light.async_set_brightness(255) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\xff\x00\xd5\xff\xff\x00\x0f\x12" transport.reset_mock() await light.async_set_brightness(128) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\x80\x00k\x80\x80\x00\x0f+" @pytest.mark.asyncio async def test_async_set_brightness_cct_0x25(mock_aio_protocol): """Test we can set brightness with a 0x25 cct device.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x02\x10\xb6\x00\x98\x19\x04\x25\x0f\xdb" ) await task transport.reset_mock() await light.async_set_brightness(255) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\x00\x00\x00g\x98\x00\x0f?" assert light.brightness == 255 transport.reset_mock() await light.async_set_brightness(128) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\x00\x00\x004L\x00\x0f\xc0" assert light.brightness == 128 @pytest.mark.asyncio async def test_async_set_brightness_cct_0x07(mock_aio_protocol): """Test we can set brightness with a 0x07 cct device.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x07\x24\x61\xC7\x01\x00\x00\x00\x00\x02\xFF\x0F\xE5" ) await task transport.reset_mock() await light.async_set_brightness(255) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\x00\x00\x00\x00\xff\x0f\x0fN" assert light.brightness == 255 transport.reset_mock() await light.async_set_brightness(128) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\x00\x00\x00\x00\x80\x0f\x0f\xcf" assert light.brightness == 128 @pytest.mark.asyncio async def test_async_set_brightness_dim(mock_aio_protocol): """Test we can set brightness with a dim only device.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x01\x10\xb6\x00\x98\x19\x04\x25\x0f\xda" ) await task transport.reset_mock() await light.async_set_brightness(255) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\x00\x00\x00\xff\xff\x00\x0f>" assert light.brightness == 255 transport.reset_mock() await light.async_set_brightness(128) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\x00\x00\x00\x80\x80\x00\x0f@" assert light.brightness == 128 @pytest.mark.asyncio async def test_async_set_brightness_rgb_0x33(mock_aio_protocol): """Test we can set brightness with a rgb only device.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x33\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xec" ) await task transport.reset_mock() await light.async_set_brightness(255) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\xff\x00\xd4\x00\x00\x0f\x13" assert light.brightness == 255 transport.reset_mock() await light.async_set_brightness(128) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\x80\x00j\x00\x00\x0f*" assert light.brightness == 128 @pytest.mark.asyncio async def test_async_set_brightness_rgb_0x25(mock_aio_protocol): """Test we can set brightness with a 0x25 device.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x03\x10\xb6\x00\x98\x19\x04\x25\x0f\xdc" ) await task transport.reset_mock() await light.async_set_brightness(255) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\xff\x00\xd4\x00\x00\x00\x0f\x13" assert light.brightness == 255 transport.reset_mock() await light.async_set_brightness(128) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\x80\x00j\x00\x00\x00\x0f*" assert light.brightness == 128 @pytest.mark.asyncio async def test_async_set_brightness_rgbw(mock_aio_protocol): """Test we can set brightness with a rgbw only device.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x04\x10\xb6\x00\x98\x19\x04\x25\x0f\xdd" ) await task transport.reset_mock() await light.async_set_brightness(255) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\xff\x00\xd5\xff\xff\x00\x0f\x12" assert light.brightness == 255 transport.reset_mock() await light.async_set_brightness(128) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\x80\x00k\x80\x80\x00\x0f+" assert light.brightness == 128 @pytest.mark.asyncio async def test_0x06_rgbw_cct_warm(mock_aio_protocol, caplog: pytest.LogCaptureFixture): """Test we can set CCT on RGBW with a warm strip.""" light = AIOWifiLedBulb("192.168.1.166") assert light.white_channel_channel_type == WhiteChannelType.WARM light.white_channel_channel_type = WhiteChannelType.WARM assert light.white_channel_channel_type == WhiteChannelType.WARM def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x06\x24\x61\x24\x01\x00\xFF\x00\x00\x03\x00\xF0\x23" ) await task assert light.model_num == 0x06 assert light.operating_mode == "RGB&W" assert light.min_temp == MIN_TEMP assert light.max_temp == MAX_TEMP assert light.color_modes == {COLOR_MODE_RGBW, COLOR_MODE_CCT} transport.reset_mock() await light.async_set_white_temp(light.max_temp, 255) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\xff\xff\xff\x00\x00\x0f=" assert light.brightness == 255 assert light.raw_state.red == 255 assert light.raw_state.green == 255 assert light.raw_state.blue == 255 assert light.raw_state.warm_white == 0 transport.reset_mock() await light.async_set_white_temp(light.min_temp, 255) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\x00\x00\x00\xff\x00\x0f?" assert light.brightness == 255 assert light.raw_state.red == 0 assert light.raw_state.green == 0 assert light.raw_state.blue == 0 assert light.raw_state.warm_white == 255 @pytest.mark.asyncio async def test_0x06_rgbw_cct_natural( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test we can set CCT on RGBW with a natural strip.""" light = AIOWifiLedBulb("192.168.1.166") light.white_channel_channel_type = WhiteChannelType.NATURAL assert light.white_channel_channel_type == WhiteChannelType.NATURAL def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x06\x24\x61\x24\x01\x00\xFF\x00\x00\x03\x00\xF0\x23" ) await task assert light.model_num == 0x06 assert light.operating_mode == "RGB&W" assert light.color_modes == {COLOR_MODE_RGBW, COLOR_MODE_CCT} assert light.min_temp == MAX_TEMP - ((MAX_TEMP - MIN_TEMP) / 2) assert light.max_temp == MAX_TEMP transport.reset_mock() await light.async_set_white_temp(light.max_temp, 255) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\xff\xff\xff\x00\x00\x0f=" assert light.brightness == 255 assert light.raw_state.red == 255 assert light.raw_state.blue == 255 assert light.raw_state.green == 255 assert light.raw_state.warm_white == 0 transport.reset_mock() await light.async_set_white_temp(light.min_temp, 255) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\x00\x00\x00\xff\x00\x0f?" assert light.brightness == 255 assert light.raw_state.red == 0 assert light.raw_state.blue == 0 assert light.raw_state.green == 0 assert light.raw_state.warm_white == 255 @pytest.mark.asyncio async def test_0x06_rgbw_cct_cold(mock_aio_protocol, caplog: pytest.LogCaptureFixture): """Test we can set CCT on RGBW with a cold strip.""" light = AIOWifiLedBulb("192.168.1.166") light.white_channel_channel_type = WhiteChannelType.COLD assert light.white_channel_channel_type == WhiteChannelType.COLD def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x06\x24\x61\x24\x01\x00\xFF\x00\x00\x03\x00\xF0\x23" ) await task assert light.model_num == 0x06 assert light.operating_mode == "RGB&W" assert light.color_modes == {COLOR_MODE_RGBW} assert light.min_temp == MAX_TEMP assert light.max_temp == MAX_TEMP @pytest.mark.asyncio @pytest.mark.skipif(sys.version_info[:3][1] in (7,), reason="no AsyncMock in 3.7") async def test_wrapped_cct_protocol_device(mock_aio_protocol): """Test a wrapped cct protocol device.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, original_aio_protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x1C\x23\x61\x00\x05\x00\x64\x64\x64\x03\x64\x0F\xC8" ) await task assert light.getCCT() == (0, 255) assert light.color_temp == 6500 assert light.brightness == 255 assert isinstance(light._protocol, ProtocolLEDENETCCTWrapped) assert light._protocol.timer_count == 6 assert light._protocol.timer_len == 14 assert light._protocol.timer_response_len == 88 light._aio_protocol.data_received( b"\x81\x1C\x23\x61\x00\x05\x00\x00\x00\x00\x03\x64\x00\x8D" ) assert light.getCCT() == (255, 0) assert light.color_temp == 2700 assert light.brightness == 255 assert light.dimmable_effects is False assert light.requires_turn_on is False assert light._protocol.power_push_updates is True assert light._protocol.state_push_updates is True transport.reset_mock() await light.async_set_brightness(32) assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"\xb0\xb1\xb2\xb3\x00\x01\x01\x00\x00\t5\xb1\x00\r\x00\x00\x00\x03\xf6\xbd" ) assert light.brightness == 33 transport.reset_mock() await light.async_set_brightness(128) assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"\xb0\xb1\xb2\xb3\x00\x01\x01\x01\x00\t5\xb1\x002\x00\x00\x00\x03\x1b\x08" ) assert light.brightness == 128 transport.reset_mock() await light.async_set_brightness(1) assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"\xb0\xb1\xb2\xb3\x00\x01\x01\x02\x00\t5\xb1\x00\x02\x00\x00\x00\x03\xeb\xa9" ) assert light.brightness == 0 transport.reset_mock() await light.async_set_levels(w=0, w2=255) assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"\xb0\xb1\xb2\xb3\x00\x01\x01\x03\x00\t5\xb1dd\x00\x00\x00\x03\xb16" ) assert light.getCCT() == (0, 255) assert light.color_temp == 6500 assert light.brightness == 255 transport.reset_mock() await light.async_set_effect("random", 50) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0].startswith(b"\xb0\xb1\xb2\xb3\x00") # light is on light._aio_protocol.data_received( b"\x81\x1C\x23\x61\x00\x05\x00\x64\x64\x64\x03\x64\x0F\xC8" ) assert light._last_update_time == aiodevice.NEVER_TIME transport.reset_mock() await light.async_update() await light.async_update() await light.async_update() await light.async_update() await asyncio.sleep(0) assert len(transport.mock_calls) == 1 # light is off light._aio_protocol.data_received( b"\x81\x1C\x24\x61\x00\x05\x00\x64\x64\x64\x03\x64\x0F\xC9" ) transport.reset_mock() await light.async_update() await light.async_update() await light.async_update() await light.async_update() await asyncio.sleep(0) assert len(transport.mock_calls) == 0 transport.reset_mock() for _ in range(4): light._last_update_time = aiodevice.NEVER_TIME await light.async_update() await asyncio.sleep(0) assert len(transport.mock_calls) == 4 light._last_update_time = aiodevice.NEVER_TIME for _ in range(4): # First failure should keep the device in # a failure state until we get to an update # time with patch.object( light, "_async_connect", AsyncMock(side_effect=asyncio.TimeoutError) ), pytest.raises(DeviceUnavailableException): await light.async_update() light._aio_protocol = original_aio_protocol # Should not raise now that bulb has recovered light._last_update_time = aiodevice.NEVER_TIME light._aio_protocol.data_received( b"\x81\x1C\x24\x61\x00\x05\x00\x64\x64\x64\x03\x64\x0F\xC9" ) await light.async_update() @pytest.mark.asyncio @pytest.mark.skipif(sys.version_info[:3][1] in (7,), reason="no AsyncMock in 3.7") async def test_cct_protocol_device(mock_aio_protocol): """Test a original cct protocol device.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, original_aio_protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x09\x23\x61\x00\x05\x00\x64\x64\x64\x03\x64\x0F\xB5" ) await task assert light.getCCT() == (0, 255) assert light.color_temp == 6500 assert light.brightness == 255 assert isinstance(light._protocol, ProtocolLEDENETCCT) assert light._protocol.timer_count == 6 assert light._protocol.timer_len == 14 assert light._protocol.timer_response_len == 88 light._aio_protocol.data_received( b"\x81\x1C\x23\x61\x00\x05\x00\x00\x00\x00\x03\x64\x00\x8D" ) assert light.getCCT() == (255, 0) assert light.color_temp == 2700 assert light.brightness == 255 assert light.dimmable_effects is False assert light.requires_turn_on is True assert light._protocol.power_push_updates is True assert light._protocol.state_push_updates is False transport.reset_mock() await light.async_set_brightness(32) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"5\xb1\x00\r\x00\x00\x00\x03\xf6" assert light.brightness == 33 transport.reset_mock() await light.async_set_brightness(128) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"5\xb1\x002\x00\x00\x00\x03\x1b" assert light.brightness == 128 transport.reset_mock() await light.async_set_brightness(1) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"5\xb1\x00\x02\x00\x00\x00\x03\xeb" assert light.brightness == 0 transport.reset_mock() await light.async_set_levels(w=0, w2=255) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"5\xb1dd\x00\x00\x00\x03\xb1" assert light.getCCT() == (0, 255) assert light.color_temp == 6500 assert light.brightness == 255 transport.reset_mock() await light.async_set_effect("random", 50) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0].startswith(b"5\xb1") # light is on light._aio_protocol.data_received( b"\x81\x1C\x23\x61\x00\x05\x00\x64\x64\x64\x03\x64\x0F\xC8" ) assert light._last_update_time == aiodevice.NEVER_TIME transport.reset_mock() await light.async_update() await light.async_update() await light.async_update() await light.async_update() await asyncio.sleep(0) assert len(transport.mock_calls) == 1 # light is off light._aio_protocol.data_received( b"\x81\x1C\x24\x61\x00\x05\x00\x64\x64\x64\x03\x64\x0F\xC9" ) transport.reset_mock() await light.async_update() await light.async_update() await light.async_update() await light.async_update() await asyncio.sleep(0) assert len(transport.mock_calls) == 0 transport.reset_mock() for _ in range(4): light._last_update_time = aiodevice.NEVER_TIME await light.async_update() await asyncio.sleep(0) assert len(transport.mock_calls) == 4 light._last_update_time = aiodevice.NEVER_TIME for _ in range(4): # First failure should keep the device in # a failure state until we get to an update # time with patch.object( light, "_async_connect", AsyncMock(side_effect=asyncio.TimeoutError) ), pytest.raises(DeviceUnavailableException): await light.async_update() light._aio_protocol = original_aio_protocol # Should not raise now that bulb has recovered light._last_update_time = aiodevice.NEVER_TIME light._aio_protocol.data_received( b"\x81\x1C\x24\x61\x00\x05\x00\x64\x64\x64\x03\x64\x0F\xC9" ) await light.async_update() @pytest.mark.asyncio async def test_christmas_protocol_device_turn_on(mock_aio_protocol): """Test a christmas protocol device.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x1a\x23\x61\x00\x00\x00\xff\x00\x00\x01\x00\x06\x25" ) await task assert light.rgb == (0, 255, 0) assert light.brightness == 255 assert len(light.effect_list) == 101 assert light.protocol == PROTOCOL_LEDENET_ADDRESSABLE_CHRISTMAS assert light.dimmable_effects is False assert light.requires_turn_on is False assert light._protocol.power_push_updates is True assert light._protocol.state_push_updates is True data = [] written = [] def _send_data(*args, **kwargs): written.append(args[0]) light._aio_protocol.data_received(data.pop(0)) with patch.object(aiodevice, "POWER_STATE_TIMEOUT", 0.010), patch.object( light._aio_protocol, "write", _send_data ): data = [ b"\x81\x1a\x23\x61\x00\x00\x00\xff\x00\x00\x01\x00\x06\x25", b"\x81\x25\x24\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xdf", ] await light.async_turn_off() await asyncio.sleep(0) assert light.is_on is False assert len(data) == 0 assert written == [ b"\xb0\xb1\xb2\xb3\x00\x01\x01\x00\x00\x04q$\x0f\xa4\x14", b"\xb0\xb1\xb2\xb3\x00\x01\x01\x01\x00\x04\x81\x8a\x8b\x96\xf9", ] @pytest.mark.asyncio async def test_christmas_protocol_device(mock_aio_protocol): """Test a christmas protocol device.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x1a\x23\x61\x00\x00\x00\xff\x00\x00\x01\x00\x06\x25" ) await task assert light.rgb == (0, 255, 0) assert light.brightness == 255 assert len(light.effect_list) == 101 assert light.protocol == PROTOCOL_LEDENET_ADDRESSABLE_CHRISTMAS assert light.dimmable_effects is False assert light.requires_turn_on is False assert light._protocol.power_push_updates is True assert light._protocol.state_push_updates is True transport.reset_mock() await light.async_set_brightness(255) assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"\xb0\xb1\xb2\xb3\x00\x01\x01\x00\x00\x0d\x3b\xa1a\x00\x80\x00\x00\xf0") for _ in range(6)] ) assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"!\xf0\x00\x00\x00\x0c-\x00>a\x00\x80\x00\x00\x00\xf0\xf0\x00\x00\x00\x0c-\x00>a\x00\x80\x00\x00\x00\xf0\xf0\x00\x00\x00\x0c-\x00>a\x00\x80\x00\x00\x00\xf0\xf0\x00\x00\x00\x0c-\x00>a\x00\x80\x00\x00\x00\xf0\xf0\x00\x00\x00\x0c-\x00>a\x00\x80\x00\x00\x00\xf0\xf0\x00\x00\x00\x0c-\x00>a\x00\x80\x00\x00\x00\xf0\x00\xf0a" ) caplog.clear() transport.reset_mock() await light.async_set_timers( [LedTimer(b"\xf0\x00\x00\x00\x0c-\x00>a\x00\x80\x00\x00\xf0") for _ in range(7)] ) assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"!\xf0\x00\x00\x00\x0c-\x00>a\x00\x80\x00\x00\x00\xf0\xf0\x00\x00\x00\x0c-\x00>a\x00\x80\x00\x00\x00\xf0\xf0\x00\x00\x00\x0c-\x00>a\x00\x80\x00\x00\x00\xf0\xf0\x00\x00\x00\x0c-\x00>a\x00\x80\x00\x00\x00\xf0\xf0\x00\x00\x00\x0c-\x00>a\x00\x80\x00\x00\x00\xf0\xf0\x00\x00\x00\x0c-\x00>a\x00\x80\x00\x00\x00\xf0\x00\xf0a" ) assert "too many timers, truncating list" in caplog.text transport.reset_mock() await light.async_set_timers( [LedTimer(b"\xf0\x00\x00\x00\x0c-\x00>a\x00\x80\x00\x00\xf0") for _ in range(2)] ) assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"!\xf0\x00\x00\x00\x0c-\x00>a\x00\x80\x00\x00\x00\xf0\xf0\x00\x00\x00\x0c-\x00>a\x00\x80\x00\x00\x00\xf0\x0f\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0f\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0f\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0f\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xf0\xbd" ) @pytest.mark.asyncio async def test_async_enable_remote_access(mock_aio_protocol): """Test we can enable remote access.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x04\x10\xb6\x00\x98\x19\x04\x25\x0f\xdd" ) await task with patch( "flux_led.aiodevice.AIOBulbScanner.async_enable_remote_access", return_value=mock_coro(True), ) as mock_async_enable_remote_access: await light.async_enable_remote_access("host", 1234) assert mock_async_enable_remote_access.mock_calls == [ call("192.168.1.166", "host", 1234) ] @pytest.mark.asyncio async def test_async_disable_remote_access(mock_aio_protocol): """Test we can disable remote access.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x04\x10\xb6\x00\x98\x19\x04\x25\x0f\xdd" ) await task with patch( "flux_led.aiodevice.AIOBulbScanner.async_disable_remote_access", return_value=mock_coro(True), ) as mock_async_disable_remote_access: await light.async_disable_remote_access() assert mock_async_disable_remote_access.mock_calls == [call("192.168.1.166")] @pytest.mark.asyncio async def test_async_reboot(mock_aio_protocol): """Test we can reboot.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x04\x10\xb6\x00\x98\x19\x04\x25\x0f\xdd" ) await task with patch( "flux_led.aiodevice.AIOBulbScanner.async_reboot", return_value=mock_coro(True), ) as mock_async_reboot: await light.async_reboot() assert mock_async_reboot.mock_calls == [call("192.168.1.166")] @pytest.mark.asyncio async def test_power_state_response_processing( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test we can turn on and off via power state message.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde" ) await task light._aio_protocol.data_received(b"\xf0\x32\xf0\xf0\xf0\xf0\xe2") assert light.power_restore_states == PowerRestoreStates( channel1=PowerRestoreState.LAST_STATE, channel2=PowerRestoreState.LAST_STATE, channel3=PowerRestoreState.LAST_STATE, channel4=PowerRestoreState.LAST_STATE, ) light._aio_protocol.data_received(b"\xf0\x32\x0f\xf0\xf0\xf0\x01") assert light.power_restore_states == PowerRestoreStates( channel1=PowerRestoreState.ALWAYS_ON, channel2=PowerRestoreState.LAST_STATE, channel3=PowerRestoreState.LAST_STATE, channel4=PowerRestoreState.LAST_STATE, ) light._aio_protocol.data_received(b"\xf0\x32\xff\xf0\xf0\xf0\xf1") assert light.power_restore_states == PowerRestoreStates( channel1=PowerRestoreState.ALWAYS_OFF, channel2=PowerRestoreState.LAST_STATE, channel3=PowerRestoreState.LAST_STATE, channel4=PowerRestoreState.LAST_STATE, ) @pytest.mark.asyncio async def test_async_set_power_restore_state( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test we can set power restore state and report it.""" socket = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(socket.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() socket._aio_protocol.data_received( b"\x81\x97\x24\x24\x00\x00\x00\x00\x00\x00\x02\x00\x00\x62" ) # power restore state socket._aio_protocol.data_received(b"\x0F\x32\xF0\xF0\xF0\xF0\x01") await task assert socket.model_num == 0x97 assert socket.power_restore_states == PowerRestoreStates( channel1=PowerRestoreState.LAST_STATE, channel2=PowerRestoreState.LAST_STATE, channel3=PowerRestoreState.LAST_STATE, channel4=PowerRestoreState.LAST_STATE, ) transport.reset_mock() await socket.async_set_power_restore( channel1=PowerRestoreState.ALWAYS_ON, channel2=PowerRestoreState.ALWAYS_ON, channel3=PowerRestoreState.ALWAYS_ON, channel4=PowerRestoreState.ALWAYS_ON, ) assert transport.mock_calls[0][0] == "write" assert transport.mock_calls[0][1][0] == b"1\x0f\x0f\x0f\x0f\xf0]" @pytest.mark.asyncio async def test_async_set_power_restore_state_fails( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test we raise if we do not get a power restore state.""" socket = AIOWifiLedBulb("192.168.1.166", timeout=0.01) def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(socket.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() socket._aio_protocol.data_received( b"\x81\x97\x24\x24\x00\x00\x00\x00\x00\x00\x02\x00\x00\x62" ) # power restore state not sent with pytest.raises(RuntimeError): await task @pytest.mark.asyncio async def test_remote_config_queried( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test power state is queried if discovery shows a compatible remote.""" light = AIOWifiLedBulb("192.168.1.166") light.discovery = FLUX_DISCOVERY_24G_REMOTE def _updated_callback(*args, **kwargs): pass with patch.object(aiodevice, "DEVICE_CONFIG_WAIT_SECONDS", 0): task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde" ) light._aio_protocol.data_received( b"\xb0\xb1\xb2\xb3\x00\x01\x01\x5e\x00\x0e\x2b\x01\x00\x00\x00\x00\x29\x00\x00\x00\x00\x00\x00\x55\xde" ) await task assert light.remote_config == RemoteConfig.DISABLED assert light.paired_remotes == 0 assert transport.mock_calls == [ call.get_extra_info("peername"), call.write(bytearray(b"\x81\x8a\x8b\x96")), call.write( bytearray(b"\xb0\xb1\xb2\xb3\x00\x01\x01\x00\x00\x04+,-\x84\xd4") ), ] @pytest.mark.asyncio async def test_remote_config_response_processing( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test we can turn on and off via power state message.""" light = AIOWifiLedBulb("192.168.1.166") light.discovery = FLUX_DISCOVERY_24G_REMOTE def _updated_callback(*args, **kwargs): pass with patch.object(aiodevice, "DEVICE_CONFIG_WAIT_SECONDS", 0): task = asyncio.create_task(light.async_setup(_updated_callback)) await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde" ) light._aio_protocol.data_received( b"\xb0\xb1\xb2\xb3\x00\x01\x01\x5e\x00\x0e\x2b\x01\x00\x00\x00\x00\x29\x00\x00\x00\x00\x00\x00\x55\xde" ) await task light._aio_protocol.data_received( b"\xb0\xb1\xb2\xb3\x00\x01\x01\x5e\x00\x0e\x2b\x01\x00\x00\x00\x00\x29\x00\x00\x00\x00\x00\x00\x55\xde" ) assert light.remote_config == RemoteConfig.DISABLED assert light.paired_remotes == 0 light._aio_protocol.data_received( b"\xb0\xb1\xb2\xb3\x00\x01\x01\x45\x00\x0e\x2b\x02\x00\x00\x00\x00\x29\x00\x00\x00\x00\x00\x00\x56\xc7" ) assert light.remote_config == RemoteConfig.OPEN assert light.paired_remotes == 0 light._aio_protocol.data_received( b"\xb0\xb1\xb2\xb3\x00\x01\x01\xe3\x00\x0e\x2b\x03\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x30\x19" ) assert light.remote_config == RemoteConfig.PAIRED_ONLY assert light.paired_remotes == 2 @pytest.mark.asyncio async def test_async_config_remotes( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test we can configure remotes.""" light = AIOWifiLedBulb("192.168.1.166") light.discovery = FLUX_DISCOVERY_24G_REMOTE def _updated_callback(*args, **kwargs): pass with patch.object(aiodevice, "DEVICE_CONFIG_WAIT_SECONDS", 0): task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde" ) light._aio_protocol.data_received( b"\xb0\xb1\xb2\xb3\x00\x01\x01\x5e\x00\x0e\x2b\x01\x00\x00\x00\x00\x29\x00\x00\x00\x00\x00\x00\x55\xde" ) await task light._aio_protocol.data_received( b"\xb0\xb1\xb2\xb3\x00\x01\x01\xe3\x00\x0e\x2b\x03\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x30\x19" ) assert light.remote_config == RemoteConfig.PAIRED_ONLY assert light.paired_remotes == 2 transport.reset_mock() await light.async_config_remotes(RemoteConfig.DISABLED) assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"\xb0\xb1\xb2\xb3\x00\x01\x01\x01\x00\x10*\x01\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x0f5C" ) transport.reset_mock() await light.async_config_remotes(RemoteConfig.OPEN) assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"\xb0\xb1\xb2\xb3\x00\x01\x01\x03\x00\x10*\x02\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x0f6G" ) transport.reset_mock() await light.async_config_remotes(RemoteConfig.PAIRED_ONLY) assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"\xb0\xb1\xb2\xb3\x00\x01\x01\x05\x00\x10*\x03\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x0f7K" ) @pytest.mark.asyncio async def test_async_unpair_remotes( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test we can unpair remotes.""" light = AIOWifiLedBulb("192.168.1.166") light.discovery = FLUX_DISCOVERY_24G_REMOTE def _updated_callback(*args, **kwargs): pass with patch.object(aiodevice, "DEVICE_CONFIG_WAIT_SECONDS", 0): task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde" ) light._aio_protocol.data_received( b"\xb0\xb1\xb2\xb3\x00\x01\x01\x5e\x00\x0e\x2b\x01\x00\x00\x00\x00\x29\x00\x00\x00\x00\x00\x00\x55\xde" ) await task light._aio_protocol.data_received( b"\xb0\xb1\xb2\xb3\x00\x01\x01\xe3\x00\x0e\x2b\x03\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x30\x19" ) assert light.remote_config == RemoteConfig.PAIRED_ONLY assert light.paired_remotes == 2 transport.reset_mock() await light.async_unpair_remotes() assert transport.mock_calls[0][0] == "write" assert ( transport.mock_calls[0][1][0] == b"\xb0\xb1\xb2\xb3\x00\x01\x01\x01\x00\x10*\xff\xff\x01\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\xf0\x16\x05" ) @pytest.mark.asyncio async def test_async_config_remotes_unsupported_device( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test we can configure remotes.""" light = AIOWifiLedBulb("192.168.1.166") def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde" ) await task assert light.paired_remotes is None with pytest.raises(ValueError): await light.async_config_remotes(RemoteConfig.PAIRED_ONLY) with pytest.raises(ValueError): await light.async_unpair_remotes() @pytest.mark.asyncio @pytest.mark.skipif(sys.version_info[:3][1] in (7,), reason="no AsyncMock in 3.7") async def test_async_config_remotes_no_response( mock_aio_protocol, caplog: pytest.LogCaptureFixture ): """Test device supports remote config but does not respond.""" light = AIOWifiLedBulb("192.168.1.166", timeout=0.0001) light.discovery = FLUX_DISCOVERY_24G_REMOTE def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde" ) await task assert light.paired_remotes is None assert "Could not determine 2.4ghz remote config" in caplog.text @pytest.mark.asyncio async def test_partial_discovery(mock_aio_protocol, caplog: pytest.LogCaptureFixture): """Test discovery that is missing hardware data.""" light = AIOWifiLedBulb("192.168.1.166") light.discovery = FLUX_DISCOVERY_MISSING_HARDWARE def _updated_callback(*args, **kwargs): pass task = asyncio.create_task(light.async_setup(_updated_callback)) transport, protocol = await mock_aio_protocol() light._aio_protocol.data_received( b"\x81\x25\x23\x61\x05\x10\xb6\x00\x98\x19\x04\x25\x0f\xde" ) light._aio_protocol.data_received( b"\xb0\xb1\xb2\xb3\x00\x01\x01\x5e\x00\x0e\x2b\x01\x00\x00\x00\x00\x29\x00\x00\x00\x00\x00\x00\x55\xde" ) await task assert light.hardware is None @pytest.mark.asyncio async def test_async_scanner(mock_discovery_aio_protocol): """Test scanner.""" scanner = AIOBulbScanner() task = asyncio.ensure_future( scanner.async_scan(timeout=0.1, address="192.168.213.252") ) transport, protocol = await mock_discovery_aio_protocol() protocol.datagram_received(b"HF-A11ASSISTHREAD", ("127.0.0.1", 48899)) protocol.datagram_received( b"192.168.1.193,DC4F22E6462E,AK001-ZJ200", ("192.168.1.193", 48899) ) protocol.datagram_received( b"+ok=25_18_20170908_Armacost\r", ("192.168.1.193", 48899) ) protocol.datagram_received( b"+ok=TCP,8806,mhc8806us.magichue.net\r", ("192.168.1.193", 48899) ) protocol.datagram_received( b"192.168.213.252,B4E842E10588,AK001-ZJ2145", ("192.168.213.252", 48899) ) protocol.datagram_received( b"192.168.198.198,B4E842E10522,AK001-ZJ2149", ("192.168.198.198", 48899) ) protocol.datagram_received( b"192.168.198.197,B4E842E10521,AK001-ZJ2146", ("192.168.198.197", 48899) ) protocol.datagram_received( b"192.168.198.196,B4E842E10520,AK001-ZJ2144", ("192.168.198.196", 48899) ) protocol.datagram_received( b"192.168.211.230,A020A61D892B,AK001-ZJ100", ("192.168.211.230", 48899) ) protocol.datagram_received( b"+ok=TCP,GARBAGE,ra8816us02.magichue.net\r", ("192.168.213.252", 48899) ) protocol.datagram_received( b"192.168.213.259,B4E842E10586,AK001-ZJ2145", ("192.168.213.259", 48899) ) protocol.datagram_received( b"+ok=TCP,8816,ra8816us02.magichue.net\r", ("192.168.213.252", 48899) ) protocol.datagram_received( b"+ok=TCP,8806,mhc8806us.magichue.net", ("192.168.211.230", 48899) ) protocol.datagram_received(b"AT+LVER\r", ("127.0.0.1", 48899)) protocol.datagram_received( b"+ok=GARBAGE_GARBAGE_GARBAGE_ZG-BL\r", ("192.168.213.252", 48899) ) protocol.datagram_received( b"+ok=08_15_20210204_ZG-BL\r", ("192.168.213.252", 48899) ) protocol.datagram_received(b"+ok=52_3_20210204\r", ("192.168.198.198", 48899)) protocol.datagram_received(b"+ok=62_3\r", ("192.168.198.197", 48899)) protocol.datagram_received(b"+ok=41_3_202\r", ("192.168.198.196", 48899)) protocol.datagram_received( b"+ok=35_62_20210109_ZG-BL-PWM\r", ("192.168.213.259", 48899) ) protocol.datagram_received( b"192.168.213.65,F4CFA23E1AAF,AK001-ZJ2104", ("192.168.213.65", 48899) ) protocol.datagram_received( b"+ok=33_11_20170307_IR_mini\r\n", ("192.168.211.230", 48899) ) protocol.datagram_received(b"+ok=", ("192.168.213.65", 48899)) protocol.datagram_received(b"+ok=A2_33_20200428_ZG-LX\r", ("192.168.213.65", 48899)) protocol.datagram_received(b"+ok=", ("192.168.213.259", 48899)) protocol.datagram_received( b"+ok=TCP,8816,ra8816us02.magichue.net\r", ("192.168.198.196", 48899) ) data = await task assert data == [ { "firmware_date": datetime.date(2017, 9, 8), "id": "DC4F22E6462E", "ipaddr": "192.168.1.193", "model": "AK001-ZJ200", "model_description": "Controller RGB/WW/CW", "model_info": "Armacost", "model_num": 37, "remote_access_enabled": True, "remote_access_host": "mhc8806us.magichue.net", "remote_access_port": 8806, "version_num": 24, }, { "firmware_date": datetime.date(2021, 2, 4), "id": "B4E842E10588", "ipaddr": "192.168.213.252", "model": "AK001-ZJ2145", "model_description": "Controller RGB with MIC", "model_info": "ZG-BL", "model_num": 8, "remote_access_enabled": True, "remote_access_host": "ra8816us02.magichue.net", "remote_access_port": 8816, "version_num": 21, }, { "firmware_date": datetime.date(2021, 2, 4), "id": "B4E842E10522", "ipaddr": "192.168.198.198", "model": "AK001-ZJ2149", "model_description": "Bulb CCT", "model_info": None, "model_num": 82, "remote_access_enabled": None, "remote_access_host": None, "remote_access_port": None, "version_num": 3, }, { "firmware_date": None, "id": "B4E842E10521", "ipaddr": "192.168.198.197", "model": "AK001-ZJ2146", "model_description": "Controller CCT", "model_info": None, "model_num": 98, "remote_access_enabled": None, "remote_access_host": None, "remote_access_port": None, "version_num": 3, }, { "firmware_date": None, "id": "B4E842E10520", "ipaddr": "192.168.198.196", "model": "AK001-ZJ2144", "model_description": "Controller Dimmable", "model_info": None, "model_num": 65, "remote_access_enabled": True, "remote_access_host": "ra8816us02.magichue.net", "remote_access_port": 8816, "version_num": 3, }, { "firmware_date": datetime.date(2017, 3, 7), "id": "A020A61D892B", "ipaddr": "192.168.211.230", "model": "AK001-ZJ100", "model_description": "Controller RGB IR Mini", "model_info": "IR_mini", "model_num": 51, "remote_access_enabled": True, "remote_access_host": "mhc8806us.magichue.net", "remote_access_port": 8806, "version_num": 17, }, { "firmware_date": datetime.date(2021, 1, 9), "id": "B4E842E10586", "ipaddr": "192.168.213.259", "model": "AK001-ZJ2145", "model_description": "Bulb RGBCW", "model_info": "ZG-BL-PWM", "model_num": 53, "remote_access_enabled": False, "remote_access_host": None, "remote_access_port": None, "version_num": 98, }, { "firmware_date": datetime.date(2020, 4, 28), "id": "F4CFA23E1AAF", "ipaddr": "192.168.213.65", "model": "AK001-ZJ2104", "model_description": "Addressable v2", "model_info": "ZG-LX", "model_num": 162, "remote_access_enabled": False, "remote_access_host": None, "remote_access_port": None, "version_num": 51, }, ] @pytest.mark.asyncio async def test_async_scanner_specific_address(mock_discovery_aio_protocol): """Test scanner with a specific address.""" scanner = AIOBulbScanner() task = asyncio.ensure_future( scanner.async_scan(timeout=10, address="192.168.213.252") ) transport, protocol = await mock_discovery_aio_protocol() protocol.datagram_received( b"192.168.213.252,B4E842E10588,AK001-ZJ2145", ("192.168.213.252", 48899) ) protocol.datagram_received( b"+ok=08_15_20210204_ZG-BL\r", ("192.168.213.252", 48899) ) protocol.datagram_received( b"+ok=TCP,8816,ra8816us02.magichue.net\r", ("192.168.213.252", 48899) ) data = await task assert data == [ { "firmware_date": datetime.date(2021, 2, 4), "id": "B4E842E10588", "ipaddr": "192.168.213.252", "model": "AK001-ZJ2145", "model_description": "Controller RGB with MIC", "model_info": "ZG-BL", "model_num": 8, "version_num": 21, "remote_access_enabled": True, "remote_access_host": "ra8816us02.magichue.net", "remote_access_port": 8816, } ] assert scanner.getBulbInfoByID("B4E842E10588") == { "firmware_date": datetime.date(2021, 2, 4), "id": "B4E842E10588", "ipaddr": "192.168.213.252", "model": "AK001-ZJ2145", "model_description": "Controller RGB with MIC", "model_info": "ZG-BL", "model_num": 8, "version_num": 21, "remote_access_enabled": True, "remote_access_host": "ra8816us02.magichue.net", "remote_access_port": 8816, } assert scanner.getBulbInfo() == [ { "firmware_date": datetime.date(2021, 2, 4), "id": "B4E842E10588", "ipaddr": "192.168.213.252", "model": "AK001-ZJ2145", "model_description": "Controller RGB with MIC", "model_info": "ZG-BL", "model_num": 8, "version_num": 21, "remote_access_enabled": True, "remote_access_host": "ra8816us02.magichue.net", "remote_access_port": 8816, } ] @pytest.mark.asyncio async def test_async_scanner_specific_address_legacy_device( mock_discovery_aio_protocol, ): """Test scanner with a specific address of a legacy device.""" scanner = AIOBulbScanner() task = asyncio.ensure_future( scanner.async_scan(timeout=10, address="192.168.213.252") ) transport, protocol = await mock_discovery_aio_protocol() protocol.datagram_received( b"192.168.213.252,ACCF232E5124,HF-A11-ZJ002", ("192.168.213.252", 48899) ) protocol.datagram_received(b"+ok=15\r\n\r\n", ("192.168.213.252", 48899)) protocol.datagram_received(b"+ERR=-2\r\n\r\n", ("192.168.213.252", 48899)) data = await task assert data == [ { "firmware_date": None, "id": "ACCF232E5124", "ipaddr": "192.168.213.252", "model": "HF-A11-ZJ002", "model_description": None, "model_info": None, "model_num": None, "remote_access_enabled": None, "remote_access_host": None, "remote_access_port": None, "version_num": 21, } ] assert is_legacy_device(data[0]) is True @pytest.mark.asyncio async def test_async_scanner_times_out_with_nothing(mock_discovery_aio_protocol): """Test scanner.""" scanner = AIOBulbScanner() task = asyncio.ensure_future(scanner.async_scan(timeout=0.025)) transport, protocol = await mock_discovery_aio_protocol() data = await task assert data == [] @pytest.mark.asyncio async def test_async_scanner_times_out_with_nothing_specific_address( mock_discovery_aio_protocol, ): """Test scanner.""" scanner = AIOBulbScanner() task = asyncio.ensure_future( scanner.async_scan(timeout=0.025, address="192.168.213.252") ) transport, protocol = await mock_discovery_aio_protocol() data = await task assert data == [] @pytest.mark.asyncio async def test_async_scanner_falls_back_to_any_source_port_if_socket_in_use(): """Test port fallback.""" hold_socket = create_udp_socket(AIOBulbScanner.DISCOVERY_PORT) assert hold_socket.getsockname() == ("0.0.0.0", 48899) random_socket = create_udp_socket(AIOBulbScanner.DISCOVERY_PORT) assert random_socket.getsockname() != ("0.0.0.0", 48899) @pytest.mark.asyncio async def test_async_scanner_enable_remote_access(mock_discovery_aio_protocol): """Test scanner enabling remote access with a specific address.""" scanner = AIOBulbScanner() task = asyncio.ensure_future( scanner.async_enable_remote_access( timeout=10, address="192.168.213.252", remote_access_host="ra8815us02.magichue.net", remote_access_port=8815, ) ) transport, protocol = await mock_discovery_aio_protocol() protocol.datagram_received( b"192.168.213.252,B4E842E10588,AK001-ZJ2145", ("192.168.213.252", 48899) ) protocol.datagram_received(b"+ok\r", ("192.168.213.252", 48899)) protocol.datagram_received(b"+ok\r", ("192.168.213.252", 48899)) await task assert transport.mock_calls == [ call.sendto(b"HF-A11ASSISTHREAD", ("192.168.213.252", 48899)), call.sendto( b"AT+SOCKB=TCP,8815,ra8815us02.magichue.net\r", ("192.168.213.252", 48899) ), call.sendto(b"AT+Z\r", ("192.168.213.252", 48899)), call.close(), ] @pytest.mark.asyncio async def test_async_scanner_disable_remote_access(mock_discovery_aio_protocol): """Test scanner disable remote access with a specific address.""" scanner = AIOBulbScanner() task = asyncio.ensure_future( scanner.async_disable_remote_access( timeout=10, address="192.168.213.252", ) ) transport, protocol = await mock_discovery_aio_protocol() protocol.datagram_received( b"192.168.213.252,B4E842E10588,AK001-ZJ2145", ("192.168.213.252", 48899) ) protocol.datagram_received(b"+ok\r", ("192.168.213.252", 48899)) protocol.datagram_received(b"+ok\r", ("192.168.213.252", 48899)) await task assert transport.mock_calls == [ call.sendto(b"HF-A11ASSISTHREAD", ("192.168.213.252", 48899)), call.sendto(b"AT+SOCKB=NONE\r", ("192.168.213.252", 48899)), call.sendto(b"AT+Z\r", ("192.168.213.252", 48899)), call.close(), ] @pytest.mark.asyncio async def test_async_scanner_reboot(mock_discovery_aio_protocol): """Test scanner reboot with a specific address.""" scanner = AIOBulbScanner() task = asyncio.ensure_future( scanner.async_reboot( timeout=10, address="192.168.213.252", ) ) transport, protocol = await mock_discovery_aio_protocol() protocol.datagram_received( b"192.168.213.252,B4E842E10588,AK001-ZJ2145", ("192.168.213.252", 48899) ) protocol.datagram_received(b"+ok\r", ("192.168.213.252", 48899)) await task assert transport.mock_calls == [ call.sendto(b"HF-A11ASSISTHREAD", ("192.168.213.252", 48899)), call.sendto(b"AT+Z\r", ("192.168.213.252", 48899)), call.close(), ] @pytest.mark.asyncio async def test_async_scanner_disable_remote_access_timeout(mock_discovery_aio_protocol): """Test scanner disable remote access with a specific address failure.""" scanner = AIOBulbScanner() task = asyncio.ensure_future( scanner.async_disable_remote_access( timeout=0.02, address="192.168.213.252", ) ) transport, protocol = await mock_discovery_aio_protocol() protocol.datagram_received( b"192.168.213.252,B4E842E10588,AK001-ZJ2145", ("192.168.213.252", 48899) ) protocol.datagram_received(b"+ok\r", ("192.168.213.252", 48899)) with pytest.raises(asyncio.TimeoutError): await task assert transport.mock_calls == [ call.sendto(b"HF-A11ASSISTHREAD", ("192.168.213.252", 48899)), call.sendto(b"AT+SOCKB=NONE\r", ("192.168.213.252", 48899)), call.sendto(b"AT+Z\r", ("192.168.213.252", 48899)), call.close(), ] def test_merge_discoveries() -> None: """Unit test to make sure we can merge two discoveries.""" full = FLUX_DISCOVERY.copy() partial = FLUX_DISCOVERY_PARTIAL.copy() merge_discoveries(partial, full) assert partial == FLUX_DISCOVERY assert full == FLUX_DISCOVERY full = FLUX_DISCOVERY.copy() partial = FLUX_DISCOVERY_PARTIAL.copy() merge_discoveries(full, partial) assert full == FLUX_DISCOVERY @pytest.mark.asyncio async def test_armacost(): """Test armacost uses port 34001.""" discovery = FluxLEDDiscovery( { "firmware_date": datetime.date(2017, 9, 8), "id": "DC4F22E6462E", "ipaddr": "192.168.1.193", "model": "AK001-ZJ200", "model_description": "Controller RGB/WW/CW", "model_info": "Armacost", "model_num": 37, "remote_access_enabled": True, "remote_access_host": "mhc8806us.magichue.net", "remote_access_port": 8806, "version_num": 24, } ) light = AIOWifiLedBulb("192.168.1.193") light.discovery = discovery assert light.port == 34001 @pytest.mark.asyncio async def test_not_armacost(): """Test not armacost uses 5577.""" discovery = FluxLEDDiscovery( { "firmware_date": datetime.date(2021, 2, 4), "id": "B4E842E10588", "ipaddr": "192.168.213.252", "model": "AK001-ZJ2145", "model_description": "Controller RGB with MIC", "model_info": "ZG-BL", "model_num": 8, "remote_access_enabled": True, "remote_access_host": "ra8816us02.magichue.net", "remote_access_port": 8816, "version_num": 21, } ) light = AIOWifiLedBulb("192.168.213.252") light.discovery = discovery assert light.port == 5577