├── .editorconfig ├── .gitchangelog.rc ├── .gitignore ├── .gitlab-ci.yml ├── .pre-commit-config.yaml ├── AUTHORS.rst ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.rst ├── requirements_dev.in ├── requirements_dev.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py └── test_yeecli.py ├── tox.ini └── yeecli ├── __init__.py └── cli.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.gitchangelog.rc: -------------------------------------------------------------------------------- 1 | ## 2 | ## Format 3 | ## 4 | ## ACTION: [AUDIENCE:] COMMIT_MSG [!TAG ...] 5 | ## 6 | ## Description 7 | ## 8 | ## ACTION is one of 'chg', 'fix', 'new' 9 | ## 10 | ## Is WHAT the change is about. 11 | ## 12 | ## 'chg' is for refactor, small improvement, cosmetic changes... 13 | ## 'fix' is for bug fixes 14 | ## 'new' is for new features, big improvement 15 | ## 16 | ## AUDIENCE is optional and one of 'dev', 'usr', 'pkg', 'test', 'doc' 17 | ## 18 | ## Is WHO is concerned by the change. 19 | ## 20 | ## 'dev' is for developpers (API changes, refactors...) 21 | ## 'usr' is for final users (UI changes) 22 | ## 'pkg' is for packagers (packaging changes) 23 | ## 'test' is for testers (test only related changes) 24 | ## 'doc' is for doc guys (doc only changes) 25 | ## 26 | ## COMMIT_MSG is ... well ... the commit message itself. 27 | ## 28 | ## TAGs are additionnal adjective as 'refactor' 'minor' 'cosmetic' 29 | ## 30 | ## They are preceded with a '!' or a '@' (prefer the former, as the 31 | ## latter is wrongly interpreted in github.) Commonly used tags are: 32 | ## 33 | ## 'refactor' is obviously for refactoring code only 34 | ## 'minor' is for a very meaningless change (a typo, adding a comment) 35 | ## 'cosmetic' is for cosmetic driven change (re-indentation, 80-col...) 36 | ## 'wip' is for partial functionality but complete subfunctionality. 37 | ## 38 | ## Example: 39 | ## 40 | ## new: usr: support of bazaar implemented 41 | ## chg: re-indentend some lines !cosmetic 42 | ## new: dev: updated code to be compatible with last version of killer lib. 43 | ## fix: pkg: updated year of licence coverage. 44 | ## new: test: added a bunch of test around user usability of feature X. 45 | ## fix: typo in spelling my name in comment. !minor 46 | ## 47 | ## Please note that multi-line commit message are supported, and only the 48 | ## first line will be considered as the "summary" of the commit message. So 49 | ## tags, and other rules only applies to the summary. The body of the commit 50 | ## message will be displayed in the changelog without reformatting. 51 | 52 | 53 | ## 54 | ## ``ignore_regexps`` is a line of regexps 55 | ## 56 | ## Any commit having its full commit message matching any regexp listed here 57 | ## will be ignored and won't be reported in the changelog. 58 | ## 59 | ignore_regexps = [ 60 | r'@minor', r'!minor', 61 | r'@cosmetic', r'!cosmetic', 62 | r'@refactor', r'!refactor', 63 | r'@wip', r'!wip', 64 | r'^([cC]hg|[fF]ix|[nN]ew|[fF]eat)\s*:\s*[p|P]kg:', 65 | r'^([cC]hg|[fF]ix|[nN]ew|[Ff]eat)\s*:\s*[d|D]ev:', 66 | r'^(.{3,3}\s*:)?\s*[fF]irst commit.?\s*$', 67 | r'^\d+\.\d+\.\d+$', 68 | ] 69 | 70 | 71 | ## ``section_regexps`` is a list of 2-tuples associating a string label and a 72 | ## list of regexp 73 | ## 74 | ## Commit messages will be classified in sections thanks to this. Section 75 | ## titles are the label, and a commit is classified under this section if any 76 | ## of the regexps associated is matching. 77 | ## 78 | ## Please note that ``section_regexps`` will only classify commits and won't 79 | ## make any changes to the contents. So you'll probably want to go check 80 | ## ``subject_process`` (or ``body_process``) to do some changes to the subject, 81 | ## whenever you are tweaking this variable. 82 | ## 83 | section_regexps = [ 84 | ('Features', [ 85 | r'^([nN]ew|[fF]eat)\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 86 | ]), 87 | ('Changes', [ 88 | r'^[cC]hg\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 89 | ]), 90 | ('Fixes', [ 91 | r'^[fF]ix\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 92 | ]), 93 | ] 94 | 95 | 96 | ## ``body_process`` is a callable 97 | ## 98 | ## This callable will be given the original body and result will 99 | ## be used in the changelog. 100 | ## 101 | ## Available constructs are: 102 | ## 103 | ## - any python callable that take one txt argument and return txt argument. 104 | ## 105 | ## - ReSub(pattern, replacement): will apply regexp substitution. 106 | ## 107 | ## - Indent(chars=" "): will indent the text with the prefix 108 | ## Please remember that template engines gets also to modify the text and 109 | ## will usually indent themselves the text if needed. 110 | ## 111 | ## - Wrap(regexp=r"\n\n"): re-wrap text in separate paragraph to fill 80-Columns 112 | ## 113 | ## - noop: do nothing 114 | ## 115 | ## - ucfirst: ensure the first letter is uppercase. 116 | ## (usually used in the ``subject_process`` pipeline) 117 | ## 118 | ## - final_dot: ensure text finishes with a dot 119 | ## (usually used in the ``subject_process`` pipeline) 120 | ## 121 | ## - strip: remove any spaces before or after the content of the string 122 | ## 123 | ## Additionally, you can `pipe` the provided filters, for instance: 124 | #body_process = Wrap(regexp=r'\n(?=\w+\s*:)') | Indent(chars=" ") 125 | #body_process = Wrap(regexp=r'\n(?=\w+\s*:)') 126 | #body_process = noop 127 | #body_process = ReSub(r'((^|\n)[A-Z]\w+(-\w+)*: .*(\n\s+.*)*)+$', r'') | strip 128 | body_process = lambda text: "" 129 | 130 | 131 | ## ``subject_process`` is a callable 132 | ## 133 | ## This callable will be given the original subject and result will 134 | ## be used in the changelog. 135 | ## 136 | ## Available constructs are those listed in ``body_process`` doc. 137 | subject_process = (strip | 138 | ReSub(r'^([cC]hg|[fF]ix|[nN]ew|[fF]eat)\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n@]*)(@[a-z]+\s+)*$', r'\4') | 139 | ucfirst | final_dot) 140 | 141 | 142 | ## ``tag_filter_regexp`` is a regexp 143 | ## 144 | ## Tags that will be used for the changelog must match this regexp. 145 | ## 146 | tag_filter_regexp = r'^v?[0-9]+\.[0-9]+(\.[0-9]+)?$' 147 | 148 | 149 | ## ``unreleased_version_label`` is a string 150 | ## 151 | ## This label will be used as the changelog Title of the last set of changes 152 | ## between last valid tag and HEAD if any. 153 | unreleased_version_label = "Unreleased" 154 | 155 | 156 | ## ``output_engine`` is a callable 157 | ## 158 | ## This will change the output format of the generated changelog file 159 | ## 160 | ## Available choices are: 161 | ## 162 | ## - rest_py 163 | ## 164 | ## Legacy pure python engine, outputs ReSTructured text. 165 | ## This is the default. 166 | ## 167 | ## - mustache() 168 | ## 169 | ## Template name could be any of the available templates in 170 | ## ``templates/mustache/*.tpl``. 171 | ## Requires python package ``pystache``. 172 | ## Examples: 173 | ## - mustache("markdown") 174 | ## - mustache("restructuredtext") 175 | ## 176 | ## - makotemplate() 177 | ## 178 | ## Template name could be any of the available templates in 179 | ## ``templates/mako/*.tpl``. 180 | ## Requires python package ``mako``. 181 | ## Examples: 182 | ## - makotemplate("restructuredtext") 183 | ## 184 | #output_engine = rest_py 185 | #output_engine = mustache("restructuredtext") 186 | output_engine = mustache("markdown") 187 | #output_engine = makotemplate("restructuredtext") 188 | 189 | 190 | ## ``include_merge`` is a boolean 191 | ## 192 | ## This option tells git-log whether to include merge commits in the log. 193 | ## The default is to include them. 194 | include_merge = True 195 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | .venv 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | 56 | # Sphinx documentation 57 | docs/_build/ 58 | 59 | # PyBuilder 60 | target/ 61 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: themattrix/tox 2 | 3 | stages: 4 | - static_check 5 | 6 | before_script: 7 | - pyenv global $(pyenv global | grep "^3.5") # Set Python 3.5 as default. 8 | - pip install -r requirements_dev.txt 9 | 10 | static_check: 11 | stage: static_check 12 | script: 13 | - flake8 . 14 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/ambv/black 3 | rev: 19.10b0 4 | hooks: 5 | - id: black 6 | args: [--line-length=120] 7 | - repo: git://github.com/doublify/pre-commit-isort 8 | rev: v4.3.0 9 | hooks: 10 | - id: isort 11 | - repo: https://gitlab.com/pycqa/flake8 12 | rev: '3.8.3' 13 | hooks: 14 | - id: flake8 15 | args: ["--config=setup.cfg"] 16 | language_version: python3 17 | - repo: https://github.com/pre-commit/mirrors-mypy 18 | rev: v0.782 19 | hooks: 20 | - id: mypy 21 | args: ["--ignore-missing-imports"] 22 | - repo: local 23 | hooks: 24 | - id: gitchangelog 25 | language: system 26 | always_run: true 27 | pass_filenames: false 28 | name: Generate changelog 29 | entry: bash -c "gitchangelog > CHANGELOG.md" 30 | stages: [commit] 31 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Stavros Korokithakis 9 | 10 | Contributors 11 | ------------ 12 | 13 | None yet. Why not be the first? 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | ## Unreleased 5 | 6 | ### Features 7 | 8 | * Add support for bulb groups. [Stavros Korokithakis] 9 | 10 | ### Fixes 11 | 12 | * Harmonize the sunrise preset with the others. [Stavros Korokithakis] 13 | 14 | * Fix exception when used with white bulbs (#3) [pklapperich] 15 | 16 | * Add stderr output to hex_to_color (#4) [pklapperich] 17 | 18 | * Add default value for sunrise preset; fixes exception (#5) [pklapperich] 19 | 20 | * Print the rgb color in hex in the `status` command (#1) [Семён Марьясин] 21 | 22 | * Add arguments to two presets. [Stavros Korokithakis] 23 | 24 | 25 | ## v0.1.0 (2017-06-05) 26 | 27 | ### Features 28 | 29 | * Add more presets. [Stavros Korokithakis] 30 | 31 | 32 | ## v0.0.16 (2016-11-15) 33 | 34 | ### Features 35 | 36 | * Add preset stop command. [Stavros Korokithakis] 37 | 38 | * Add BPM option to the disco preset. [Stavros Korokithakis] 39 | 40 | 41 | ## v0.0.15 (2016-11-15) 42 | 43 | ### Features 44 | 45 | * Add short options. [Stavros Korokithakis] 46 | 47 | 48 | ## v0.0.14 (2016-11-15) 49 | 50 | ### Features 51 | 52 | * Add presets. [Stavros Korokithakis] 53 | 54 | 55 | ## v0.0.13 (2016-11-15) 56 | 57 | ### Features 58 | 59 | * Add disco mode. [Stavros Korokithakis] 60 | 61 | * Add preliminary multiple bulb support. [Stavros Korokithakis] 62 | 63 | 64 | ## v0.0.12 (2016-11-14) 65 | 66 | ### Fixes 67 | 68 | * Make the pulse command leave the bulb the way it found it. [Stavros Korokithakis] 69 | 70 | 71 | ## v0.0.11 (2016-11-14) 72 | 73 | ### Fixes 74 | 75 | * Use the latest yeelight library. [Stavros Korokithakis] 76 | 77 | 78 | ## v0.0.10 (2016-11-14) 79 | 80 | ### Features 81 | 82 | * Support multiple bulbs. [Stavros Korokithakis] 83 | 84 | 85 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Stavros Korokithakis 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 17 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 18 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 19 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 20 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 21 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 22 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | 24 | The views and conclusions contained in the software and documentation are those 25 | of the authors and should not be interpreted as representing official policies, 26 | either expressed or implied, of the FreeBSD Project. 27 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include LICENSE 3 | include README.rst 4 | 5 | recursive-include tests * 6 | recursive-exclude * __pycache__ 7 | recursive-exclude * *.py[co] 8 | 9 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 10 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | yeecli 3 | ====== 4 | 5 | .. image:: https://img.shields.io/pypi/v/yeecli.svg 6 | :target: https://pypi.python.org/pypi/yeecli 7 | 8 | .. image:: https://gitlab.com/stavros/yeecli/badges/master/build.svg 9 | :target: https://gitlab.com/stavros/yeecli/pipelines 10 | 11 | 12 | yeecli is a command-line utility for controlling the YeeLight RGB LED lightbulb. 13 | It is released under the BSD license. 14 | 15 | 16 | Quick start 17 | ----------- 18 | 19 | You can install yeecli with pip:: 20 | 21 | pip install yeecli 22 | 23 | You're done (make sure developer mode is enabled for your bulb in the app)! Here are a few sample commands:: 24 | 25 | yee --ip=192.168.0.34 turn on 26 | yee --ip=192.168.0.34,192.168.0.28:8329 toggle 27 | yee --ip=192.168.0.34 rgb ff00ff 28 | yee --ip=192.168.0.34 brightness 100 29 | 30 | 31 | Features 32 | -------- 33 | 34 | This is a list of features supported right now and features that I'm wanting to 35 | add later. 36 | 37 | Currently supported: 38 | 39 | * Non-music modes 40 | * All flow transitions in the protocol 41 | * Additional HSV flow transition 42 | * Presets 43 | * Multiple bulbs 44 | * Bulb groups 45 | 46 | Will probably be supported at some point: 47 | 48 | * Music mode 49 | * Discovery 50 | 51 | 52 | Usage 53 | ----- 54 | 55 | To see the commands supported by yeecli, just run it without any commands. It 56 | allows you to turn the light bulb on or off, set the RGB value, the color 57 | temperature, the HSV value, etc. 58 | 59 | yeecli does not support discovery, so you have to specify the IP of the bulb you 60 | want to use every time. To make this easier, yeecli supports using 61 | a configuration file. 62 | 63 | Simply create a file in `~/.config/yeecli/yeecli.cfg` that looks something like 64 | this:: 65 | 66 | [default] 67 | ip = 192.168.12.3 68 | port = 55433 69 | effect = smooth 70 | duration = 500 71 | 72 | And the defaults will be loaded from it. All the values in it are optional, and 73 | you can override them in the command line when running the script. 74 | 75 | You can also specify multiple bulbs like so:: 76 | 77 | [default] 78 | ip = 192.168.12.3 79 | port = 55433 80 | effect = smooth 81 | duration = 500 82 | 83 | [bedroom] 84 | ip = 192.168.12.4 85 | effect = smooth 86 | duration = 500 87 | 88 | [hallway] 89 | ip = 192.168.12.5:88273,192.168.12.3 90 | 91 | Then, to select a specific bulb/bulb group, just pass it to the ``--bulb`` option:: 92 | 93 | yee --bulb=bedroom brightness 100 94 | -------------------------------------------------------------------------------- /requirements_dev.in: -------------------------------------------------------------------------------- 1 | wheel 2 | flake8 3 | flake8-docstrings 4 | flake8-import-order 5 | flake8-tidy-imports 6 | pep8-naming 7 | tox 8 | coverage 9 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile 3 | # To update, run: 4 | # 5 | # pip-compile --output-file requirements_dev.txt requirements_dev.in 6 | # 7 | coverage==4.4.1 8 | # via -r requirements_dev.in 9 | flake8-docstrings==1.1.0 10 | # via -r requirements_dev.in 11 | flake8-import-order==0.12 12 | # via -r requirements_dev.in 13 | flake8-polyfill==1.0.1 14 | # via flake8-docstrings 15 | flake8-tidy-imports==1.0.6 16 | # via -r requirements_dev.in 17 | flake8==3.3.0 18 | # via 19 | # -r requirements_dev.in 20 | # flake8-docstrings 21 | # flake8-polyfill 22 | # flake8-tidy-imports 23 | mccabe==0.6.1 24 | # via flake8 25 | pep8-naming==0.4.1 26 | # via -r requirements_dev.in 27 | pluggy==0.4.0 28 | # via tox 29 | py==1.10.0 30 | # via tox 31 | pycodestyle==2.3.1 32 | # via 33 | # flake8 34 | # flake8-import-order 35 | pydocstyle==2.0.0 36 | # via flake8-docstrings 37 | pyflakes==1.5.0 38 | # via flake8 39 | six==1.10.0 40 | # via pydocstyle 41 | snowballstemmer==1.2.1 42 | # via pydocstyle 43 | tox==2.7.0 44 | # via -r requirements_dev.in 45 | virtualenv==15.1.0 46 | # via tox 47 | wheel==0.29.0 48 | # via -r requirements_dev.in 49 | 50 | # The following packages are considered to be unsafe in a requirements file: 51 | # setuptools 52 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | exclude = docs 6 | ignore=E501,D100,E231,E203 7 | import-order-style = smarkets 8 | 9 | [semantic_release] 10 | version_variable = yeecli/__init__.py:__version__ 11 | 12 | [isort] 13 | include_trailing_comma = true 14 | line_length = 120 15 | multi_line_output = 5 16 | skip=migrations,node_modules 17 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from yeecli import __version__ 4 | 5 | try: 6 | from setuptools import setup 7 | except ImportError: 8 | from distutils.core import setup 9 | 10 | 11 | with open("README.rst") as readme_file: 12 | readme = readme_file.read() 13 | 14 | requirements = [ 15 | "yeelight>=0.3.0", 16 | "click>=6.6", 17 | ] 18 | 19 | test_requirements = [] # type: ignore 20 | 21 | setup( 22 | name="yeecli", 23 | version=__version__, 24 | description="yeecli is a command-line utility for controlling the YeeLight RGB LED lightbulb.", 25 | long_description=readme, 26 | author="Stavros Korokithakis", 27 | author_email="hi@stavros.io", 28 | url="https://github.com/skorokithakis/yeecli", 29 | packages=["yeecli"], 30 | package_dir={"yeecli": "yeecli"}, 31 | include_package_data=True, 32 | install_requires=requirements, 33 | license="BSD", 34 | zip_safe=False, 35 | keywords="yeelight xiaomi led rgb yeecli", 36 | classifiers=[ 37 | "Development Status :: 2 - Pre-Alpha", 38 | "Intended Audience :: End Users/Desktop", 39 | "License :: OSI Approved :: BSD License", 40 | "Natural Language :: English", 41 | "Programming Language :: Python :: 3", 42 | "Programming Language :: Python :: 3.3", 43 | "Programming Language :: Python :: 3.4", 44 | "Programming Language :: Python :: 3.5", 45 | ], 46 | test_suite="tests", 47 | tests_require=test_requirements, 48 | entry_points={"console_scripts": ["yeecli=yeecli.cli:cli", "yee=yeecli.cli:cli",],}, 49 | ) 50 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Test package.""" 3 | -------------------------------------------------------------------------------- /tests/test_yeecli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import unittest 5 | 6 | 7 | class TestThings(unittest.TestCase): 8 | """Tests.""" 9 | 10 | def test_stuff(self): 11 | """Test stuff.""" 12 | pass 13 | 14 | 15 | if __name__ == "__main__": 16 | import sys 17 | 18 | sys.exit(unittest.main()) 19 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py33, py34, py35 3 | 4 | [testenv] 5 | setenv = 6 | PYTHONPATH = {toxinidir}:{toxinidir}/yeecli 7 | commands = python setup.py test 8 | 9 | [testenv:py33] 10 | basepython = python3.3 11 | 12 | [testenv:py34] 13 | basepython = python3.4 14 | 15 | [testenv:py35] 16 | basepython = python3.5 17 | 18 | [testenv:py36] 19 | basepython = python3.6 20 | -------------------------------------------------------------------------------- /yeecli/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Various settings.""" 3 | 4 | __author__ = "Stavros Korokithakis" 5 | __email__ = "hi@stavros.io" 6 | __version__ = "0.2.0" 7 | -------------------------------------------------------------------------------- /yeecli/cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import click 5 | import yeelight # noqa 6 | from yeelight import transitions as tr 7 | 8 | try: 9 | import ConfigParser 10 | except ImportError: 11 | import configparser as ConfigParser # type: ignore 12 | 13 | try: 14 | import tbvaccine 15 | 16 | tbvaccine.add_hook() 17 | except: # noqa 18 | pass 19 | 20 | try: 21 | from . import __version__ 22 | except (SystemError, ValueError): 23 | from __init__ import __version__ # type: ignore 24 | 25 | BULBS = [] 26 | 27 | 28 | def hex_color_to_rgb(color): 29 | """Convert a hex color string to an RGB tuple.""" 30 | color = color.strip("#") 31 | try: 32 | red, green, blue = tuple(int(color[i : i + 2], 16) for i in (0, 2, 4)) 33 | except (TypeError, ValueError): 34 | print("Unrecognized color, changing to red...", file=sys.stderr) 35 | red, green, blue = (255, 0, 0) 36 | return red, green, blue 37 | 38 | 39 | def param_or_config(param, config, section, name, default): 40 | """Return a parameter, config item or default, in thar order of priority.""" 41 | try: 42 | conf_param = config.get(section, name) 43 | except (ConfigParser.NoSectionError, ConfigParser.NoOptionError): 44 | conf_param = None 45 | 46 | try: 47 | # Try to see if this parameter is an integer. 48 | conf_param = int(conf_param) 49 | except (TypeError, ValueError): 50 | pass 51 | 52 | return param or conf_param or default 53 | 54 | 55 | @click.group(context_settings={"help_option_names": ["-h", "--help"]}) 56 | @click.version_option( 57 | version=__version__, prog_name="yeecli", message="%(prog)s %(version)s: And there was light.", 58 | ) 59 | @click.option("--ip", metavar="IP", help="The bulb's IP address.") 60 | @click.option("--port", metavar="PORT", help="The bulb's port.", type=int) 61 | @click.option( 62 | "--effect", "-e", metavar="EFFECT", help="The transition effect.", type=click.Choice(["smooth", "sudden"]), 63 | ) 64 | @click.option( 65 | "--duration", 66 | "-d", 67 | metavar="DURATION_MS", 68 | help="The transition effect duration.", 69 | type=click.IntRange(1, 60000, clamp=True), 70 | ) 71 | @click.option( 72 | "--bulb", "-b", metavar="NAME", default="default", help="The name of the bulb in the config file.", type=str, 73 | ) 74 | @click.option( 75 | "--auto-on/--no-auto-on", 76 | default=True, 77 | help="Whether to turn the bulb on automatically before a command (on by default).", 78 | ) 79 | def cli(ip, port, effect, duration, bulb, auto_on): 80 | """Easily control the YeeLight RGB LED lightbulb.""" 81 | config = ConfigParser.ConfigParser() 82 | config.read([os.path.expanduser("~/.config/yeecli/yeecli.cfg")]) 83 | 84 | ips = param_or_config(ip, config, bulb, "ip", None) 85 | port = param_or_config(port, config, bulb, "port", 55443) 86 | effect = param_or_config(effect, config, bulb, "effect", "sudden") 87 | duration = param_or_config(duration, config, bulb, "duration", 500) 88 | 89 | if not ips: 90 | click.echo("No IP address specified.") 91 | sys.exit(1) 92 | 93 | for ip in ips.split(","): 94 | ip = ip.strip() 95 | if ":" in ip: 96 | ip, prt = ip.split(":") 97 | else: 98 | # If no port is specified, use the default one. 99 | prt = port 100 | 101 | BULBS.append(yeelight.Bulb(ip=ip, port=prt, effect=effect, duration=duration, auto_on=auto_on,)) 102 | 103 | 104 | @cli.command() 105 | @click.argument("value", type=click.IntRange(1, 100, clamp=True)) 106 | def brightness(value): 107 | """Set the brightness of the bulb.""" 108 | click.echo("Setting the bulb to {} brightness...".format(value)) 109 | for bulb in BULBS: 110 | bulb.set_brightness(value) 111 | 112 | 113 | @cli.command() 114 | @click.argument("degrees", type=click.IntRange(1700, 6500, clamp=True)) 115 | def temperature(degrees): 116 | """Set the color temperature of the bulb.""" 117 | click.echo("Setting the bulb's color temperature to {}...".format(degrees)) 118 | for bulb in BULBS: 119 | bulb.set_color_temp(degrees) 120 | 121 | 122 | @cli.command() 123 | @click.argument("hue", type=click.IntRange(0, 359, clamp=True)) 124 | @click.argument("saturation", type=click.IntRange(0, 100, clamp=True)) 125 | def hsv(hue, saturation): 126 | """Set the HSV value of the bulb.""" 127 | click.echo("Setting the bulb to HSV {}, {}...".format(hue, saturation)) 128 | for bulb in BULBS: 129 | bulb.set_hsv(hue, saturation) 130 | 131 | 132 | @cli.command() 133 | @click.argument("hex_color", type=str) 134 | def rgb(hex_color): 135 | """Set the RGB value of the bulb.""" 136 | red, green, blue = hex_color_to_rgb(hex_color) 137 | click.echo("Setting the bulb to RGB {}...".format(hex_color)) 138 | for bulb in BULBS: 139 | bulb.set_rgb(red, green, blue) 140 | 141 | 142 | @cli.command() 143 | def toggle(): 144 | """Toggle the bulb's state on or off.""" 145 | click.echo("Toggling the bulb...") 146 | for bulb in BULBS: 147 | bulb.toggle() 148 | 149 | 150 | @cli.command() 151 | @click.argument("hex_color", type=str) 152 | @click.option( 153 | "--pulses", "-p", metavar="COUNT", type=int, default=2, help="The number of times to pulse.", 154 | ) 155 | def pulse(hex_color, pulses): 156 | """Pulse the bulb in a specific color.""" 157 | red, green, blue = hex_color_to_rgb(hex_color) 158 | transitions = tr.pulse(red, green, blue) 159 | 160 | for bulb in BULBS: 161 | # Get the initial bulb state. 162 | if bulb.get_properties().get("power", "off") == "off": 163 | action = yeelight.Flow.actions.off 164 | else: 165 | action = yeelight.Flow.actions.recover 166 | 167 | bulb.start_flow(yeelight.Flow(count=pulses, action=action, transitions=transitions)) 168 | 169 | 170 | @cli.command() 171 | @click.argument("state", type=click.Choice(["on", "off"])) 172 | def turn(state): 173 | """Turn the bulb on or off.""" 174 | click.echo("Turning the bulb {}...".format(state)) 175 | for bulb in BULBS: 176 | if state == "on": 177 | bulb.turn_on() 178 | elif state == "off": 179 | bulb.turn_off() 180 | 181 | 182 | @cli.group() 183 | def preset(): 184 | """Various presets.""" 185 | 186 | 187 | @preset.command() 188 | def alarm(): 189 | """Flash a red alarm.""" 190 | click.echo("Alarm!") 191 | transitions = tr.alarm(500) 192 | flow = yeelight.Flow(count=0, transitions=transitions) 193 | for bulb in BULBS: 194 | bulb.start_flow(flow) 195 | 196 | 197 | @preset.command() 198 | def christmas(): 199 | """Christmas lights.""" 200 | click.echo("Happy holidays.") 201 | transitions = tr.christmas() 202 | flow = yeelight.Flow(count=0, transitions=transitions) 203 | for bulb in BULBS: 204 | bulb.start_flow(flow) 205 | 206 | 207 | @preset.command() 208 | @click.option( 209 | "--bpm", metavar="BPM", type=int, default=200, help="The beats per minute to pulse at.", 210 | ) 211 | def disco(bpm): 212 | """Party.""" 213 | click.echo("Party mode: activated.") 214 | flow = yeelight.Flow(count=0, transitions=tr.disco(bpm)) 215 | for bulb in BULBS: 216 | bulb.start_flow(flow) 217 | 218 | 219 | @preset.command() 220 | @click.option( 221 | "-d", 222 | "--duration", 223 | metavar="DURATION", 224 | type=int, 225 | default=3000, 226 | help="The number of milliseconds to take for each change.", 227 | ) 228 | def lsd(duration): 229 | """Color changes to a trippy palette.""" 230 | click.echo("Enjoy your trip.") 231 | transitions = tr.lsd(duration=duration) 232 | flow = yeelight.Flow(count=0, transitions=transitions) 233 | for bulb in BULBS: 234 | bulb.start_flow(flow) 235 | 236 | 237 | @preset.command() 238 | def police(): 239 | """Police lights.""" 240 | click.echo("It's the fuzz!") 241 | transitions = tr.police() 242 | flow = yeelight.Flow(count=0, transitions=transitions) 243 | for bulb in BULBS: 244 | bulb.start_flow(flow) 245 | 246 | 247 | @preset.command() 248 | def police2(): 249 | """More police lights.""" 250 | click.echo("It's the fuzz again!") 251 | transitions = tr.police2() 252 | flow = yeelight.Flow(count=0, transitions=transitions) 253 | for bulb in BULBS: 254 | bulb.start_flow(flow) 255 | 256 | 257 | @preset.command() 258 | @click.option( 259 | "-d", 260 | "--duration", 261 | metavar="DURATION", 262 | type=int, 263 | default=750, 264 | help="The number of milliseconds to take for each change.", 265 | ) 266 | def random(duration): 267 | """Random colors.""" 268 | click.echo("Random colors!") 269 | transitions = tr.randomloop(duration=duration) 270 | flow = yeelight.Flow(count=0, transitions=transitions) 271 | for bulb in BULBS: 272 | bulb.start_flow(flow) 273 | 274 | 275 | @preset.command() 276 | def redgreenblue(): 277 | """Change from red to green to blue.""" 278 | click.echo("Pretty colors.") 279 | transitions = tr.rgb() 280 | flow = yeelight.Flow(count=0, transitions=transitions) 281 | for bulb in BULBS: 282 | bulb.start_flow(flow) 283 | 284 | 285 | @preset.command() 286 | def slowdown(): 287 | """Cycle with increasing transition time.""" 288 | click.echo("Sloooooowwwwlllyyy..") 289 | transitions = tr.slowdown() 290 | flow = yeelight.Flow(count=0, transitions=transitions) 291 | for bulb in BULBS: 292 | bulb.start_flow(flow) 293 | 294 | 295 | @preset.command() 296 | def strobe(): 297 | """Epilepsy warning.""" 298 | click.echo("Strobing.") 299 | transitions = tr.strobe() 300 | flow = yeelight.Flow(count=0, transitions=transitions) 301 | for bulb in BULBS: 302 | bulb.start_flow(flow) 303 | 304 | 305 | @preset.command() 306 | def temp(): 307 | """Slowly-changing color temperature.""" 308 | click.echo("Enjoy.") 309 | transitions = tr.temp() 310 | flow = yeelight.Flow(count=0, transitions=transitions) 311 | for bulb in BULBS: 312 | bulb.start_flow(flow) 313 | 314 | 315 | @preset.command() 316 | @click.option( 317 | "-d", 318 | "--duration", 319 | metavar="DURATION", 320 | type=click.IntRange(50, 24 * 60 * 60), 321 | default=5 * 60, 322 | help="The number of seconds until the bulb reaches full color.", 323 | ) 324 | def sunrise(duration): 325 | """Simulate sunrise in seconds (default 5min).""" 326 | click.echo("Good morning!") 327 | # We're using seconds for duration because it's a more natural timescale 328 | # for this preset. 329 | duration = duration * 1000 330 | transitions = [ 331 | # First set to minimum temperature, low brightness, nearly immediately. 332 | tr.TemperatureTransition(1700, duration=50, brightness=1), 333 | # Then slowly transition to higher temperature, max brightness. 334 | # 5000 is about regular daylight white. 335 | tr.TemperatureTransition(2100, duration=duration / 2, brightness=50), 336 | tr.TemperatureTransition(5000, duration=duration / 2, brightness=100), 337 | ] 338 | flow = yeelight.Flow(count=1, action=yeelight.flow.Action.stay, transitions=transitions) 339 | for bulb in BULBS: 340 | bulb.start_flow(flow) 341 | 342 | 343 | @preset.command() 344 | def stop(): 345 | """Stop any currently playing presets and return to the prior state.""" 346 | for bulb in BULBS: 347 | bulb.stop_flow() 348 | 349 | 350 | @cli.command() 351 | def save(): 352 | """Save the current settings as default.""" 353 | click.echo("Saving settings...") 354 | for bulb in BULBS: 355 | bulb.set_default() 356 | 357 | 358 | @cli.command() 359 | def status(): 360 | """Show the bulb's status.""" 361 | for bulb in BULBS: 362 | click.echo("\nBulb parameters:") 363 | for key, value in bulb.get_properties().items(): 364 | if key == "rgb": 365 | try: 366 | value = hex(int(value)).replace("0x", "#") 367 | except TypeError: 368 | # Ignore exception on white-only bulbs. 369 | pass 370 | click.echo("* {}: {}".format(key, value)) 371 | 372 | 373 | if __name__ == "__main__": 374 | cli() 375 | --------------------------------------------------------------------------------