pax_global_header 0000666 0000000 0000000 00000000064 14477345651 0014532 g ustar 00root root 0000000 0000000 52 comment=bfd1bbef9d3a6e2f168a422cb9b3a350475defec
Danielhiversen-flux_led-bfd1bbe/ 0000775 0000000 0000000 00000000000 14477345651 0017121 5 ustar 00root root 0000000 0000000 Danielhiversen-flux_led-bfd1bbe/.coveragerc 0000664 0000000 0000000 00000000770 14477345651 0021246 0 ustar 00root root 0000000 0000000 [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/ 0000775 0000000 0000000 00000000000 14477345651 0020461 5 ustar 00root root 0000000 0000000 Danielhiversen-flux_led-bfd1bbe/.github/stale.yml 0000664 0000000 0000000 00000001254 14477345651 0022316 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 14477345651 0022516 5 ustar 00root root 0000000 0000000 Danielhiversen-flux_led-bfd1bbe/.github/workflows/python-package.yml 0000664 0000000 0000000 00000003140 14477345651 0026151 0 ustar 00root root 0000000 0000000 # 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.yml 0000664 0000000 0000000 00000001516 14477345651 0026231 0 ustar 00root root 0000000 0000000
# 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/.gitignore 0000664 0000000 0000000 00000001355 14477345651 0021115 0 ustar 00root root 0000000 0000000 # ---> 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/.pylintrc 0000664 0000000 0000000 00000000034 14477345651 0020763 0 ustar 00root root 0000000 0000000 [FORMAT]
max-line-length=240 Danielhiversen-flux_led-bfd1bbe/.vscode/ 0000775 0000000 0000000 00000000000 14477345651 0020462 5 ustar 00root root 0000000 0000000 Danielhiversen-flux_led-bfd1bbe/.vscode/tasks.json 0000664 0000000 0000000 00000005234 14477345651 0022506 0 ustar 00root root 0000000 0000000 {
"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/LICENSE 0000664 0000000 0000000 00000016743 14477345651 0020141 0 ustar 00root root 0000000 0000000 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.md 0000664 0000000 0000000 00000032001 14477345651 0020374 0 ustar 00root root 0000000 0000000 [![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/ 0000775 0000000 0000000 00000000000 14477345651 0020737 5 ustar 00root root 0000000 0000000 Danielhiversen-flux_led-bfd1bbe/examples/aio.py 0000664 0000000 0000000 00000001310 14477345651 0022054 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000001325 14477345651 0026241 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000000371 14477345651 0023434 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000005406 14477345651 0023145 0 ustar 00root root 0000000 0000000 import 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.py 0000775 0000000 0000000 00000004665 14477345651 0025013 0 ustar 00root root 0000000 0000000 #!/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.py 0000664 0000000 0000000 00000000433 14477345651 0025610 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000000537 14477345651 0025440 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000013740 14477345651 0023427 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000000414 14477345651 0022602 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000000240 14477345651 0022736 0 ustar 00root root 0000000 0000000 import 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.py 0000775 0000000 0000000 00000010744 14477345651 0025225 0 ustar 00root root 0000000 0000000 #!/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/ 0000775 0000000 0000000 00000000000 14477345651 0020723 5 ustar 00root root 0000000 0000000 Danielhiversen-flux_led-bfd1bbe/flux_led/__init__.py 0000664 0000000 0000000 00000000621 14477345651 0023033 0 ustar 00root root 0000000 0000000 """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.py 0000664 0000000 0000000 00000000104 14477345651 0022040 0 ustar 00root root 0000000 0000000 from .aiodevice import AIOWifiLedBulb
__all__ = ["AIOWifiLedBulb"]
Danielhiversen-flux_led-bfd1bbe/flux_led/aiodevice.py 0000664 0000000 0000000 00000105424 14477345651 0023233 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000004011 14477345651 0023623 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000015236 14477345651 0023426 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000000300 14477345651 0023117 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000136745 14477345651 0023546 0 ustar 00root root 0000000 0000000 import 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.py 0000775 0000000 0000000 00000007627 14477345651 0022442 0 ustar 00root root 0000000 0000000 """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.py 0000664 0000000 0000000 00000031476 14477345651 0022547 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000065520 14477345651 0022750 0 ustar 00root root 0000000 0000000 #!/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.py 0000775 0000000 0000000 00000140024 14477345651 0023231 0 ustar 00root root 0000000 0000000 """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.py 0000664 0000000 0000000 00000114747 14477345651 0022770 0 ustar 00root root 0000000 0000000 from 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.py 0000775 0000000 0000000 00000253353 14477345651 0023154 0 ustar 00root root 0000000 0000000 """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.typed 0000664 0000000 0000000 00000000000 14477345651 0022410 0 ustar 00root root 0000000 0000000 Danielhiversen-flux_led-bfd1bbe/flux_led/scanner.py 0000664 0000000 0000000 00000026565 14477345651 0022744 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000003115 14477345651 0022234 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000026216 14477345651 0022424 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000025751 14477345651 0022447 0 ustar 00root root 0000000 0000000 import 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.ini 0000664 0000000 0000000 00000001270 14477345651 0020620 0 ustar 00root root 0000000 0000000 [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.ini 0000664 0000000 0000000 00000000033 14477345651 0021146 0 ustar 00root root 0000000 0000000 [pytest]
asyncio_mode=auto
Danielhiversen-flux_led-bfd1bbe/requirements.txt 0000664 0000000 0000000 00000000021 14477345651 0022376 0 ustar 00root root 0000000 0000000 webcolors==1.11.1 Danielhiversen-flux_led-bfd1bbe/requirements_test.txt 0000664 0000000 0000000 00000000134 14477345651 0023442 0 ustar 00root root 0000000 0000000 webcolors
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.cfg 0000664 0000000 0000000 00000001126 14477345651 0020742 0 ustar 00root root 0000000 0000000 [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.py 0000664 0000000 0000000 00000004427 14477345651 0020642 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000306770 14477345651 0020652 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000402224 14477345651 0021471 0 ustar 00root root 0000000 0000000 import 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