Repository: samclane/LIFX-Control-Panel
Branch: master
Commit: 23bae89e8e6e
Files: 37
Total size: 138.9 KB
Directory structure:
gitextract_a92s5kuz/
├── .github/
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ └── feature_request.md
│ ├── stale.yml
│ └── workflows/
│ └── main.yml
├── .gitignore
├── .pre-commit-config.yaml
├── CODE_OF_CONDUCT.md
├── LICENSE.txt
├── MANIFEST.in
├── README.md
├── build_all.bat
├── codecov.yml
├── default.ini
├── lifx_control_panel/
│ ├── __init__.py
│ ├── __main__.pyw
│ ├── _constants.py
│ ├── frames.py
│ ├── test/
│ │ ├── __init__.py
│ │ ├── dummy_devices.py
│ │ ├── dummy_tests.py
│ │ └── functional_test.py
│ ├── ui/
│ │ ├── __init__.py
│ │ ├── colorscale.py
│ │ ├── icon_list.py
│ │ ├── settings.py
│ │ └── splashscreen.py
│ └── utilities/
│ ├── __init__.py
│ ├── async_bulb_interface.py
│ ├── audio.py
│ ├── color_thread.py
│ ├── keypress.py
│ └── utils.py
├── requirements-dev.txt
├── requirements.txt
├── setup.cfg
└── setup.py
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: [samclane]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: sawyermclane
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: ['https://www.paypal.me/sawyermclane']
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
---
**Describe the bug**
A clear and concise description of what the bug is. Please include any error messages you see.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- Lifx-Control-Panel Version: [Found in the "About" menu. e.g. "1.2.0"]
- OS: [e.g. Windows 7, Windows 10, Plan 9]
**Please attach your logfile (lifx-control-panel.log)**
Attempt to reproduce the problem, then attach your `lifx_ctrl.log` file. This will give us the most information about what went wrong.
**Additional context**
Add any other context about the problem here.
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Does an existing product have this feature?**
If so, please provide the source. Providing a live example will make capturing the desired behavior much easier.
**Additional context**
Add any other context or screenshots about the feature request here.
================================================
FILE: .github/stale.yml
================================================
# 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
- help wanted
# 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: >
This issue has been automatically marked as closed because it has not had
any further activity since being marked `stale`. Please contact the repository
owner if you think this is in error. Thank you.
================================================
FILE: .github/workflows/main.yml
================================================
name: Smoke Build And Test
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
smoke-build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
uses: actions/setup-python@v1
with:
python-version: '3.8.x' # Semantic version range syntax or exact version of a Python version
architecture: 'x64'
- name: Cache pip
uses: actions/cache@v1
with:
path: ~/.cache/pip # This path is specific to Ubuntu
# Look to see if there is a cache hit for the corresponding requirements file
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
${{ runner.os }}-
- name: Install swig
env:
ACTIONS_ALLOW_UNSECURE_COMMANDS: true
run: |
(New-Object System.Net.WebClient).DownloadFile("http://prdownloads.sourceforge.net/swig/swigwin-4.0.1.zip","swigwin-4.0.1.zip");
Expand-Archive .\swigwin-4.0.1.zip .;
echo "::add-path::./swigwin-4.0.1"
- name: Check swig
run: swig -version
- name: Install python and deps
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pyinstaller==4.8
- name: Build Project
run: |
cd ./lifx_control_panel
set PYTHONOPTIMIZE=1 && pyinstaller --onefile --noupx build_all.spec
cd ..
test:
runs-on: windows-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
uses: actions/setup-python@v1
with:
python-version: '3.8.x' # Semantic version range syntax or exact version of a Python version
architecture: 'x64'
- name: Cache pip
uses: actions/cache@v1
with:
path: ~/.cache/pip # This path is specific to Ubuntu
# Look to see if there is a cache hit for the corresponding requirements file
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
${{ runner.os }}-
- name: Test Project
run: |
pip3 install --user -r requirements.txt
pip3 install --user -r requirements-dev.txt
cd ./lifx_control_panel
set PYTHONPATH=.
coverage run -m unittest discover test -p "*test*.py"
coverage report
coverage xml -o coverage.xml
cd ..
- name: Upload Coverage to Codecov
uses: codecov/codecov-action@v2
with:
files: ./lifx_control_panel/coverage.xml
flags: unittests
================================================
FILE: .gitignore
================================================
# Created by .ignore support plugin (hsz.mobi)
### Python template
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# 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/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
venv*/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# IDE
.idea/
lifxlan/
================================================
FILE: .pre-commit-config.yaml
================================================
repos:
- repo: https://github.com/ambv/black
rev: stable
hooks:
- id: black
language_version: python3.8
================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at samclane@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/
================================================
FILE: LICENSE.txt
================================================
MIT License
Copyright (c) 2018 Sawyer McLane
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: MANIFEST.in
================================================
recursive-include lifx_control_panel/res *
================================================
FILE: README.md
================================================
## Development is now being continued on the new [Mantle](https://github.com/samclane/mantle) project
# LIFX-Control-Panel
[](https://codecov.io/gh/samclane/LIFX-Control-Panel)

LIFX-Control-Panel is an open source application for controlling your LIFX brand lights. It integrates simple features,
such as monitoring and changing bulb color, with more advanced ones, like:
* Average Screen Color
* Color Eyedropper
* Custom color palette
* Keybindings
#### Color Averaging Demo (Click for Video):
[](https://youtu.be/C-jZISM9MC0)
The application uses a fork of [mclarkk's](https://github.com/mclarkk)'s [lifxlan](https://github.com/mclarkk/lifxlan) module to
discover and send commands to the lights.
[The fork can be found here.](https://github.com/samclane/lifxlan)
# Quick Start
There are ~~2~~ **3** ways to install:
1. Go over to [releases](https://github.com/samclane/LIFX-Control-Panel/releases) and download the latest `.exe` file.
2. Run `pip install lifx-control-panel`. To start run `python -m lifx_control_panel`.
Starting the program takes a moment, as it first must scan your LAN for any LIFX devices.
# Running the source code
You can now install through PyPI, by running `pip install lifx-control-panel`. This will automatically install dependencies.
To manually install the dependencies, run `pip install -r requirements.txt`.
Due to some initial PyCharm cruft, the environment paths are a bit messed up.
- The main script path is:
- `..\LIFX-Control-Panel\lifx_control_panel\__main__.pyw`
- The Working Directory is:
- `..\LIFX-Control-Panel\lifx_control_panel`
- Additionally, the `Add content roots to PYTHONPATH` and `Add source roots to PYTHONPATH` boxes are checked
- I haven't been able to reproduce this in VSCode, yet.
# Building
LIFX-Control-Panel uses PyInstaller. After downloading the repository, open a command window in the `LIFX-Control-Panel`
directory, and run `pyinstaller __main__.pyw`. This should initialize the necessary file structure to build the project.
As admin, run `pip install -r requirements.txt` to install project dependencies. Note that pyaudio requires the use of pipwin to install - use `pip install pipwin` to obtain pipwin then install pyaudio using `pipwin install pyaudio`
To build the project, simply open a terminal in the same folder and run `build_all.bat` in the command prompt. It will
call `pyinstaller` on `build_all.spec`. This should generate `.exe` files in the `/dist`
folder of the project for each of the 3 specs:
- `main`
- This is the file that is used to build the main binary. The console, as well as verbose logging methods, are disabled.
- `debug`
- This spec file enables the console to run in the background, as well as verbose logging.
- `demo`
- The demo mode simulates adding several "dummy" lights to the LAN, allowing the software to be demonstrated on networks
that do not have any LIFX devices on them.
If you need help using PyInstaller, more instructions are located [here](https://pythonhosted.org/PyInstaller/usage.html).
# Testing progress
I have currently only tested on the following operating systems:
- Windows 10
and on the following LIFX devices:
- LIFX A19 Firmware v2.76
- LIFX A13 Firmware v2.76
- LIFX Z Firmware v1.22
- LIFX Mini White Firmware v3.41
- LIFX Beam
I've tried to test on the following operating systems:
- MacOS X
- Fedora Linux
However, the biggest hurdle seems to be the `tk` GUI library, which is not supported on MacOS X, and requires
extra library installations on Linux.
# Feedback
If you have any comments or concerns, please feel free to make a post on the [Issues page](https://github.com/samclane/LIFX-Control-Panel/issues).
If you enjoy LIFX-Control-Panel, please Like and leave a review on [AlternativeTo](https://alternativeto.net/software/lifx-control-panel/).
### NEW
[Join our Discord Server](https://discord.gg/Wse9jX94Vq)
# Donate
LIFX-Control-Panel will always be free and open source. However, if you appreciate the work I'm doing and would like to
contribute financially, you can donate below. Thanks for your support!
[](https://www.paypal.me/sawyermclane)
================================================
FILE: build_all.bat
================================================
cd .\lifx_control_panel
set PYTHONOPTIMIZE=1 && pyinstaller --onefile --noupx build_all.spec
cd ..
================================================
FILE: codecov.yml
================================================
codecov.yml
================================================
FILE: default.ini
================================================
[AppSettings]
start_minimized = False
[AverageColor]
defaultmonitor = get_primary_monitor()
duration = 0.07
brightnessoffset = 0
[PresetColors]
[Keybinds]
[Audio]
inputindex = 0
================================================
FILE: lifx_control_panel/__init__.py
================================================
import os
import sys
RED = [0, 65535, 65535, 3500] # Fixes RED from appearing BLACK
HEARTBEAT_RATE_MS = 3000 # 3 seconds
FRAME_PERIOD_MS = 1500 # 1.5 seconds
LOGFILE = "lifx-control-panel.log"
APPLICATION_PATH = os.path.dirname(sys.executable)
================================================
FILE: lifx_control_panel/__main__.pyw
================================================
# -*- coding: utf-8 -*-
"""Main lifx_control_panel GUI control
This module contains several ugly God-classes that control the GUI functions and reactions.
Notes
-----
This is the "main" function of the app, and can be run simply with 'python main.pyw'
"""
import logging
import os
import sys
import threading
import tkinter
import tkinter.colorchooser
import traceback
from collections import OrderedDict
from logging.handlers import RotatingFileHandler
from PIL import Image
from tkinter import messagebox, ttk
from typing import List, Dict, Union, Optional
import pystray
import lifxlan
if os.name == 'nt':
import pystray._win32
from lifx_control_panel import HEARTBEAT_RATE_MS, FRAME_PERIOD_MS, LOGFILE
from lifx_control_panel._constants import BUILD_DATE, AUTHOR, DEBUGGING, VERSION
from lifx_control_panel.frames import LightFrame, MultiZoneFrame, GroupFrame
from lifx_control_panel.ui import settings
from lifx_control_panel.ui.icon_list import BulbIconList
from lifx_control_panel.ui.settings import config
from lifx_control_panel.ui.splashscreen import Splash
from lifx_control_panel.utilities import audio
from lifx_control_panel.utilities.async_bulb_interface import AsyncBulbInterface
from lifx_control_panel.utilities.keypress import KeybindManager
from lifx_control_panel.utilities.utils import (resource_path,
Color,
str2tuple)
# determine if application is a script file or frozen exe
APPLICATION_PATH = os.path.dirname(__file__)
LOGFILE = os.path.join(APPLICATION_PATH, LOGFILE)
SPLASH_FILE = resource_path('res/splash_vector.png')
class LifxFrame(ttk.Frame): # pylint: disable=too-many-ancestors
""" Parent frame of application. Holds icons for each Device/Group. """
bulb_interface: AsyncBulbInterface
current_lightframe: LightFrame
def __init__(self, master: tkinter.Tk, lifx_instance: lifxlan.LifxLAN, bulb_interface: AsyncBulbInterface):
# We take a lifx instance, so we can inject our own for testing.
# Start showing splash_screen while processing
self.splashscreen = Splash(master, SPLASH_FILE)
self.splashscreen.__enter__()
# Setup frame and grid
ttk.Frame.__init__(self, master, padding="3 3 12 12")
self.master: tkinter.Tk = master
self.master.protocol("WM_DELETE_WINDOW", self.on_closing)
self.grid(column=0, row=0, sticky=(tkinter.N, tkinter.W, tkinter.E, tkinter.S))
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
self.lifx: lifxlan.LifxLAN = lifx_instance
self.bulb_interface: AsyncBulbInterface = bulb_interface
self.audio_interface = audio.AudioInterface()
self.audio_interface.init_audio(config)
# Setup logger
master_logger: str = master.logger.name if hasattr(master, 'logger') else "root"
self.logger = logging.getLogger(master_logger + '.' + self.__class__.__name__)
self.logger.info('Root logger initialized: %s', self.logger.name)
self.logger.info('Binary Version: %s', VERSION)
self.logger.info('Build time: %s', BUILD_DATE)
# Setup menu
self.menubar = tkinter.Menu(master)
file_menu = tkinter.Menu(self.menubar, tearoff=0)
file_menu.add_command(label="Rescan", command=self.scan_for_lights)
file_menu.add_command(label="Settings", command=self.show_settings)
file_menu.add_separator()
file_menu.add_command(label="Exit", command=self.on_closing)
self.menubar.add_cascade(label="File", menu=file_menu)
self.menubar.add_command(label="About", command=self.show_about)
self.master.config(menu=self.menubar)
# Initialize LIFX objects
self.tk_light_name = tkinter.StringVar(self)
self.device_map: Dict[str, Union[lifxlan.Device, lifxlan.Group]] = OrderedDict() # LifxLight objects
self.frame_map: Dict[str, LightFrame] = {} # corresponding LightFrame GUI
self.current_lightframe: Optional[LightFrame] = None # currently selected and visible LightFrame
self.current_light: Optional[lifxlan.Light]
self.bulb_icons = BulbIconList(self)
self.group_icons = BulbIconList(self, is_group=True)
self.scan_for_lights()
if any(self.device_map):
self.tk_light_name.set(next(iter(self.device_map.keys())))
self.current_light = self.device_map[self.tk_light_name.get()]
else:
messagebox.showwarning("No lights found.", "No LIFX devices were found on your LAN. Try using File->Rescan"
" to search again.")
self.bulb_icons.grid(row=1, column=1, sticky='w')
self.bulb_icons.canvas.bind('', self.on_bulb_canvas_click)
# Keep light-name in sync with drop-down selection
self.tk_light_name.trace('w', self.bulb_changed)
self.group_icons.grid(row=2, column=1, sticky='w')
self.group_icons.canvas.bind('', self.on_bulb_canvas_click)
# Setup tray icon
def lambda_quit(self_):
""" Build an anonymous function call w/ correct 'self' scope"""
return lambda *_, **__: self_.on_closing()
def lambda_adjust(self_):
return lambda *_, **__: self_.master.deiconify()
def run_tray_icon():
""" Allow SysTrayIcon in a separate thread """
image = Image.open(resource_path('res/icon_vector.ico'))
icon = pystray.Icon("LIFX Control Panel", image, menu=pystray.Menu(
pystray.MenuItem('Open',
lambda_adjust(self),
default=True),
pystray.MenuItem('Quit',
lambda_quit(self)),
))
icon.run()
self.systray_thread = threading.Thread(target=run_tray_icon, daemon=True)
self.systray_thread.start()
self.master.bind('', lambda *_, **__: self.master.withdraw()) # Minimize to taskbar
# Setup keybinding listener
self.key_listener = KeybindManager(self)
for keypress, function in dict(config['Keybinds']).items():
light, color = function.split(':')
color = Color(*globals()[color]) if color in globals().keys() else str2tuple(
color, int)
self.save_keybind(light, keypress, color)
# Stop splashscreen and start main function
self.splashscreen.__exit__(None, None, None)
# Start icon callback
self.after(FRAME_PERIOD_MS, self.update_icons)
# Minimize if in config
if config.getboolean("AppSettings", "start_minimized"):
self.master.withdraw()
def scan_for_lights(self):
""" Communicating with the interface Thread, attempt to find any new devices """
# Stop and restart the bulb interface
stop_event: threading.Event = self.bulb_interface.stopped
if not stop_event.is_set():
stop_event.set()
device_list: List[Union[lifxlan.Group, lifxlan.Light, lifxlan.MultiZoneLight]] = self.lifx.get_devices()
if self.bulb_interface:
del self.bulb_interface
self.bulb_interface = AsyncBulbInterface(stop_event, HEARTBEAT_RATE_MS)
self.bulb_interface.set_device_list(device_list)
self.bulb_interface.daemon = True
stop_event.clear()
self.bulb_interface.start()
light: lifxlan.Device
for light in self.bulb_interface.device_list:
try:
product: str = lifxlan.product_map[light.get_product()]
label: str = light.get_label()
# light.get_color()
self.device_map[label] = light
self.logger.info('Light found: %s: "%s"', product, label)
if label not in self.bulb_icons.bulb_dict:
self.bulb_icons.draw_bulb_icon(light, label)
if label not in self.frame_map:
if light.supports_multizone():
self.frame_map[label] = MultiZoneFrame(self, light)
else:
self.frame_map[label] = LightFrame(self, light)
self.current_lightframe = self.frame_map[label]
try:
self.bulb_icons.set_selected_bulb(label)
except KeyError:
self.group_icons.set_selected_bulb(label)
self.logger.info("Building new frame: %s", self.frame_map[label].get_label())
group_label = light.get_group_label()
if group_label not in self.device_map.keys():
self.build_group_frame(group_label)
except lifxlan.WorkflowException as exc:
self.logger.warning("Error when communicating with LIFX device: %s", exc)
except KeyError as exc:
self.logger.warning("Unknown device: %s: %s",
light.get_product(), light.get_label())
def build_group_frame(self, group_label):
self.device_map[group_label] = self.lifx.get_devices_by_group(group_label)
self.device_map[group_label].get_label = lambda: group_label # pylint: disable=cell-var-from-loop
# Giving an attribute here is a bit dirty, but whatever
self.device_map[group_label].label = group_label
self.group_icons.draw_bulb_icon(None, group_label)
self.logger.info("Group found: %s", group_label)
self.frame_map[group_label] = GroupFrame(self, self.device_map[group_label])
self.logger.info("Building new frame: %s", self.frame_map[group_label].get_label())
def bulb_changed(self, *_, **__):
""" Change current display frame when bulb icon is clicked. """
self.master.unbind('') # unregister unmap so grid_remove doesn't trip it
new_light_label = self.tk_light_name.get()
self.current_light = self.device_map[new_light_label]
# loop below removes all other frames; not just the current one (this fixes sync bugs for some reason)
for frame in self.frame_map.values():
frame.grid_remove()
self.frame_map[new_light_label].grid() # should bring to front
self.logger.info(
"Brought existing frame to front: %s", self.frame_map[new_light_label].get_label())
self.current_lightframe = self.frame_map[new_light_label]
self.current_lightframe.restart()
if self.current_lightframe.get_label() != self.tk_light_name.get():
self.logger.error("Mismatch between LightFrame (%s) and Dropdown (%s)", self.current_lightframe.get_label(),
self.tk_light_name.get())
self.master.bind('', lambda *_, **__: self.master.withdraw()) # reregister callback
def on_bulb_canvas_click(self, event):
""" Called whenever somebody clicks on one of the Device/Group icons. Switches LightFrame being shown. """
canvas = event.widget
# Convert to Canvas coords as we are using a Scrollbar, so Frame coords doesn't always match up.
x_canvas = canvas.canvasx(event.x)
y_canvas = canvas.canvasy(event.y)
item = canvas.find_closest(x_canvas, y_canvas)
light_name = canvas.gettags(item)[0]
self.tk_light_name.set(light_name)
if not canvas.master.is_group: # BulbIconList
self.bulb_icons.set_selected_bulb(light_name)
if self.group_icons.current_icon:
self.group_icons.clear_selected()
else:
self.group_icons.set_selected_bulb(light_name)
if self.bulb_icons.current_icon:
self.bulb_icons.clear_selected()
def update_icons(self):
""" If the window isn't minimized, redraw icons to reflect their current power/color state. """
if self.master.winfo_viewable():
for frame in self.frame_map.values():
if not isinstance(frame, GroupFrame) and frame.icon_update_flag:
self.bulb_icons.update_icon(frame.target)
frame.icon_update_flag = False
self.after(FRAME_PERIOD_MS, self.update_icons)
def save_keybind(self, light, keypress, color):
""" Builds a new anonymous function changing light to color when keypress is entered. """
def lambda_factory(self, light, color):
""" https://stackoverflow.com/questions/938429/scope-of-lambda-functions-and-their-parameters """
return lambda *_, **__: self.device_map[light].set_color(color,
duration=float(config["AverageColor"]["duration"]))
func = lambda_factory(self, light, color)
self.key_listener.register_function(keypress, func)
def delete_keybind(self, keycombo):
""" Deletes anonymous function from key_listener. Don't know why this is needed. """
self.key_listener.unregister_function(keycombo)
def show_settings(self):
""" Show the settings dialog box over the master window. """
self.key_listener.shutdown()
settings.SettingsDisplay(self, "Settings")
self.current_lightframe.update_user_dropdown()
self.audio_interface.init_audio(config)
for frame in self.frame_map.values():
frame.music_button.config(state="normal" if self.audio_interface.initialized else "disabled")
self.key_listener.restart()
@staticmethod
def show_about():
""" Show the about info-box above the master window. """
messagebox.showinfo("About", f"lifx_control_panel\n"
f"Version {VERSION}\n"
f"{AUTHOR}, {BUILD_DATE}\n"
f"Bulb Icons by Quixote\n"
f"Please consider donating at ko-fi.com/sawyermclane")
def on_closing(self):
""" Should always be called before the application exits. Shuts down all threads and closes the program. """
self.logger.info('Shutting down.\n')
self.master.destroy()
self.bulb_interface.stopped.set()
sys.exit(0)
def main():
""" Start the GUI, bulb_interface, loggers, exception handling, and finally run the app """
root = None
try:
root = tkinter.Tk()
root.title("lifx_control_panel")
root.resizable(False, False)
# Setup main_icon
root.iconbitmap(resource_path('res/icon_vector.ico'))
root.logger = logging.getLogger('root')
root.logger.setLevel(logging.DEBUG)
file_handler = RotatingFileHandler(LOGFILE, maxBytes=5 * 1024 * 1024, backupCount=1)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
file_handler.setFormatter(formatter)
root.logger.addHandler(file_handler)
stream_handler = logging.StreamHandler()
stream_handler.setLevel(logging.DEBUG)
stream_handler.setFormatter(formatter)
root.logger.addHandler(stream_handler)
root.logger.info('Logger initialized.')
def custom_handler(type_, value, trace_back):
""" A custom exception handler that logs exceptions in the root window's logger. """
root.logger.exception(
"Uncaught exception: %s:%s:%s", repr(type_), str(value), repr(trace_back))
sys.excepthook = custom_handler
lifxlan.light_products.append(38) # TODO Hotfix for missing LIFX Beam
LifxFrame(root, lifxlan.LifxLAN(verbose=DEBUGGING), AsyncBulbInterface(threading.Event(), HEARTBEAT_RATE_MS))
# Run main app
root.mainloop()
except Exception as exc: # pylint: disable=broad-except
if root and hasattr(root, "logger"):
root.logger.exception(exc)
else:
logging.exception(exc)
messagebox.showerror("Unhandled Exception", f'Unhandled runtime exception: {traceback.format_exc()}\n\n'
f'Please report this at:'
f' https://github.com/samclane/lifx_control_panel/issues'
)
os._exit(1) # pylint: disable=protected-access
if __name__ == "__main__":
main()
================================================
FILE: lifx_control_panel/_constants.py
================================================
VERSION = "2.3.0"
BUILD_DATE = "2022-12-12T06:22:59.747968"
AUTHOR = "Sawyer McLane"
DEBUGGING = False
================================================
FILE: lifx_control_panel/frames.py
================================================
import logging
import tkinter
from tkinter import ttk, font as font, messagebox, _setit
from typing import Union, List, Tuple, Dict, Mapping
import mouse
import lifxlan
import win32api
from lifxlan import (
ORANGE,
YELLOW,
GREEN,
CYAN,
BLUE,
PURPLE,
PINK,
WHITE,
COLD_WHITE,
WARM_WHITE,
GOLD,
)
from lifx_control_panel import RED, FRAME_PERIOD_MS
from lifx_control_panel.ui.colorscale import ColorScale
from lifx_control_panel.ui.settings import config
from lifx_control_panel.utilities import color_thread
from lifx_control_panel.utilities.color_thread import (
get_screen_as_image,
normalize_rectangles,
)
from lifx_control_panel.utilities.utils import (
Color,
tuple2hex,
hsbk_to_rgb,
hsv_to_rgb,
kelvin_to_rgb,
get_primary_monitor,
str2list,
str2tuple,
get_display_rects,
)
MAX_KELVIN_DEFAULT = 9000
MIN_KELVIN_DEFAULT = 1500
class LightFrame(ttk.Labelframe): # pylint: disable=too-many-ancestors
""" Holds control and state information about a single device. """
label: str
target: Union[lifxlan.Group, lifxlan.Device]
###
screen_region_lf: ttk.LabelFrame
screen_region_entries: Dict[str, tkinter.Entry]
avg_screen_btn: tkinter.Button
dominant_screen_btn: tkinter.Button
music_button: tkinter.Button
preset_colors_lf: ttk.LabelFrame
color_var: tkinter.StringVar
default_colors: Mapping[str, Color]
preset_dropdown: tkinter.OptionMenu
tk_user_def_color_var: tkinter.StringVar
user_dropdown: tkinter.OptionMenu
current_color: tkinter.Canvas
hsbk: Tuple[tkinter.IntVar, tkinter.IntVar, tkinter.IntVar, tkinter.IntVar]
hsbk_labels: Tuple[tkinter.Label, tkinter.Label, tkinter.Label, tkinter.Label]
hsbk_scale: Tuple[ColorScale, ColorScale, ColorScale, ColorScale]
hsbk_display: Tuple[tkinter.Canvas, tkinter.Canvas, tkinter.Canvas, tkinter.Canvas]
threads: Dict[str, color_thread.ColorThreadRunner]
tk_power_var: tkinter.BooleanVar
option_on: tkinter.Radiobutton
option_off: tkinter.Radiobutton
logger: logging.Logger
min_kelvin: int = MIN_KELVIN_DEFAULT
max_kelvin: int = MAX_KELVIN_DEFAULT
def __init__(self, master, target: lifxlan.Device):
super().__init__(
master,
padding="3 3 12 12",
labelwidget=tkinter.Label(
master,
text="",
font=font.Font(size=12),
fg="#0046d5",
relief=tkinter.RIDGE,
),
)
self.icon_update_flag: bool = True
# Initialize LightFrames
bulb_power, init_color = self._get_light_info(target)
# Reconfigure label with correct name
self.configure(
labelwidget=tkinter.Label(
master,
text=self.label,
font=font.Font(size=12),
fg="#0046d5",
relief=tkinter.RIDGE,
)
)
self.grid(column=1, row=0, sticky=(tkinter.N, tkinter.W, tkinter.E, tkinter.S))
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
self.target = target
# Setup logger
self._setup_logger()
# Initialize vars to hold on/off state
self.setup_power_controls(bulb_power)
# Initialize vars to hold and display bulb color
self.setup_color_controls(init_color)
# Add buttons for pre-made colors
self._setup_color_dropdowns()
# Add buttons for special routines
self.special_functions_lf = ttk.LabelFrame(
self, text="Special Functions", padding="3 3 12 12"
)
####
self._setup_special_functions()
####
# Add custom screen region (real ugly)
self._setup_screen_region_select()
# Start update loop
self.update_status_from_bulb()
def _get_light_info(self, target: lifxlan.Device) -> Tuple[int, Color]:
bulb_power: int = 0
init_color: Color = Color(*lifxlan.WARM_WHITE)
try:
self.label = target.get_label()
bulb_power = target.get_power()
if target.supports_multizone():
target: lifxlan.MultiZoneLight
init_color = Color(*target.get_color_zones()[0])
else:
target: lifxlan.Light
init_color = Color(*target.get_color())
self.min_kelvin = (
target.product_features.get("min_kelvin") or MIN_KELVIN_DEFAULT
)
self.max_kelvin = (
target.product_features.get("max_kelvin") or MAX_KELVIN_DEFAULT
)
except lifxlan.WorkflowException as exc:
messagebox.showerror(
f"Error building {self.__class__.__name__}",
f"Error thrown when trying to get label from bulb:\n{exc}",
)
self.master.on_closing()
# TODO Let this fail safely and try again later
return bulb_power, init_color
def _setup_screen_region_select(self):
self.screen_region_lf = ttk.LabelFrame(
self, text="Screen Avg. Region", padding="3 3 12 12"
)
self.screen_region_entries = {
"left": tkinter.Entry(self.screen_region_lf, width=6),
"width": tkinter.Entry(self.screen_region_lf, width=6),
"top": tkinter.Entry(self.screen_region_lf, width=6),
"height": tkinter.Entry(self.screen_region_lf, width=6),
}
region = config["AverageColor"][
self.label
if self.label in config["AverageColor"].keys()
else "defaultmonitor"
]
if region == "full":
region = ["full"] * 4
elif region[:19] == "get_primary_monitor":
region = get_primary_monitor()
else:
region = str2list(region, int)
self.screen_region_entries["left"].insert(tkinter.END, region[0])
self.screen_region_entries["top"].insert(tkinter.END, region[1])
self.screen_region_entries["width"].insert(tkinter.END, region[2])
self.screen_region_entries["height"].insert(tkinter.END, region[3])
self._grid_horiz_coordinate_box("left", 7, "width")
self._grid_horiz_coordinate_box("top", 8, "height")
tkinter.Button(
self.screen_region_lf, text="Save", command=self.save_monitor_bounds
).grid(row=9, column=1, sticky="w")
self.screen_region_lf.grid(row=7, columnspan=4)
def _grid_horiz_coordinate_box(self, text: str, row, arg2):
tkinter.Label(self.screen_region_lf, text=text).grid(
row=row, column=0, sticky="e"
)
self.screen_region_entries[text].grid(row=row, column=1, padx=(0, 10))
tkinter.Label(self.screen_region_lf, text=arg2).grid(row=row, column=2)
self.screen_region_entries[arg2].grid(row=row, column=3)
def _setup_special_functions(self):
# Color cycle
self.threads["cycle"] = color_thread.ColorThreadRunner(
self.target, color_thread.ColorCycle(), self
)
def start_color_cycle():
self.color_cycle_btn.config(bg="Green")
self.threads["cycle"].start()
self.color_cycle_btn = tkinter.Button(
self.special_functions_lf, text="Color Cycle", command=start_color_cycle,
)
self.color_cycle_btn.grid(row=9, column=0)
# Screen Avg.
self.threads["screen"] = color_thread.ColorThreadRunner(
self.target,
color_thread.avg_screen_color,
self,
func_bounds=self.get_monitor_bounds,
)
def start_screen_avg():
""" Allow the screen avg. to be run in a separate thread. Also turns button green while running. """
self.avg_screen_btn.config(bg="Green")
self.threads["screen"].start()
self.avg_screen_btn = tkinter.Button(
self.special_functions_lf,
text="Avg. Screen Color",
command=start_screen_avg,
)
self.avg_screen_btn.grid(row=6, column=0)
tkinter.Button(
self.special_functions_lf,
text="Pick Color",
command=self.get_color_from_palette,
).grid(row=6, column=1)
# Screen Dominant
self.threads["dominant"] = color_thread.ColorThreadRunner(
self.target,
color_thread.dominant_screen_color,
self,
func_bounds=self.get_monitor_bounds,
)
def start_screen_dominant():
self.dominant_screen_btn.config(bg="Green")
self.threads["dominant"].start()
self.dominant_screen_btn = tkinter.Button(
self.special_functions_lf,
text="Dominant Screen Color",
command=start_screen_dominant,
)
self.dominant_screen_btn.grid(row=7, column=0)
# Audio
self.threads["audio"] = color_thread.ColorThreadRunner(
self.target, self.master.audio_interface.get_music_color, self
)
def start_audio():
""" Allow the audio to be run in a separate thread. Also turns button green while running. """
self.music_button.config(bg="Green")
self.threads["audio"].start()
self.music_button = tkinter.Button(
self.special_functions_lf,
text="Music Color",
command=start_audio,
state="disabled"
if not self.master.audio_interface.initialized
else "normal",
)
self.music_button.grid(row=8, column=0)
self.threads["eyedropper"] = color_thread.ColorThreadRunner(
self.target, self.eyedropper, self, continuous=False
)
tkinter.Button(
self.special_functions_lf,
text="Color Eyedropper",
command=self.threads["eyedropper"].start,
).grid(row=7, column=1)
tkinter.Button(
self.special_functions_lf, text="Stop effects", command=self.stop_threads
).grid(row=8, column=1)
self.special_functions_lf.grid(row=6, columnspan=4)
def _setup_color_dropdowns(self):
self.preset_colors_lf = ttk.LabelFrame(
self, text="Preset Colors", padding="3 3 12 12"
)
self.color_var = tkinter.StringVar(self, value="Presets")
self.default_colors = {
"RED": RED,
"ORANGE": ORANGE,
"YELLOW": YELLOW,
"GREEN": GREEN,
"CYAN": CYAN,
"BLUE": BLUE,
"PURPLE": PURPLE,
"PINK": PINK,
"WHITE": WHITE,
"COLD_WHITE": COLD_WHITE,
"WARM_WHITE": WARM_WHITE,
"GOLD": GOLD,
}
self.preset_dropdown = tkinter.OptionMenu(
self.preset_colors_lf, self.color_var, *self.default_colors
)
self.preset_dropdown.grid(row=0, column=0)
self.preset_dropdown.configure(width=13)
self.color_var.trace("w", self.change_preset_dropdown)
self.tk_user_def_color_var = tkinter.StringVar(self, value="User Presets")
self.user_dropdown = tkinter.OptionMenu(
self.preset_colors_lf,
self.tk_user_def_color_var,
*(
[*config["PresetColors"].keys()]
if any(config["PresetColors"].keys())
else [None]
),
)
self.user_dropdown.grid(row=0, column=1)
self.user_dropdown.config(width=13)
self.tk_user_def_color_var.trace("w", self.change_user_dropdown)
self.preset_colors_lf.grid(row=5, columnspan=4)
def setup_color_controls(self, init_color: Color):
self.logger.info("Initial light color HSBK: %s", init_color)
self.current_color = tkinter.Canvas(
self,
background=tuple2hex(hsbk_to_rgb(init_color)),
width=40,
height=20,
borderwidth=3,
relief=tkinter.GROOVE,
)
self.current_color.grid(row=0, column=2)
self.hsbk = (
tkinter.IntVar(self, init_color.hue, "Hue"),
tkinter.IntVar(self, init_color.saturation, "Saturation"),
tkinter.IntVar(self, init_color.brightness, "Brightness"),
tkinter.IntVar(self, init_color.kelvin, "Kelvin"),
)
for i in self.hsbk:
i.trace("w", self.trigger_icon_update)
self.hsbk_labels: Tuple[
tkinter.Label, tkinter.Label, tkinter.Label, tkinter.Label
] = (
tkinter.Label(self, text=f"{360 * (self.hsbk[0].get() / 65535):.3g}"),
tkinter.Label(
self, text=str(f"{100 * self.hsbk[1].get() / 65535:.3g}") + "%"
),
tkinter.Label(
self, text=str(f"{100 * self.hsbk[2].get() / 65535:.3g}") + "%"
),
tkinter.Label(self, text=str(self.hsbk[3].get()) + " K"),
)
self.hsbk_scale: Tuple[ColorScale, ColorScale, ColorScale, ColorScale] = (
ColorScale(
self,
to=65535.0,
variable=self.hsbk[0],
command=self.update_color_from_ui,
),
ColorScale(
self,
from_=0,
to=65535,
variable=self.hsbk[1],
command=self.update_color_from_ui,
gradient="wb",
),
ColorScale(
self,
from_=0,
to=65535,
variable=self.hsbk[2],
command=self.update_color_from_ui,
gradient="bw",
),
ColorScale(
self,
from_=self.min_kelvin,
to=self.max_kelvin,
variable=self.hsbk[3],
command=self.update_color_from_ui,
gradient="kelvin",
),
)
relief = tkinter.GROOVE
self.hsbk_display: Tuple[
tkinter.Canvas, tkinter.Canvas, tkinter.Canvas, tkinter.Canvas
] = (
tkinter.Canvas(
self,
background=tuple2hex(hsv_to_rgb(360 * (init_color.hue / 65535))),
width=20,
height=20,
borderwidth=3,
relief=relief,
),
tkinter.Canvas(
self,
background=tuple2hex(
(
int(255 * (init_color.saturation / 65535)),
int(255 * (init_color.saturation / 65535)),
int(255 * (init_color.saturation / 65535)),
)
),
width=20,
height=20,
borderwidth=3,
relief=relief,
),
tkinter.Canvas(
self,
background=tuple2hex(
(
int(255 * (init_color.brightness / 65535)),
int(255 * (init_color.brightness / 65535)),
int(255 * (init_color.brightness / 65535)),
)
),
width=20,
height=20,
borderwidth=3,
relief=relief,
),
tkinter.Canvas(
self,
background=tuple2hex(kelvin_to_rgb(init_color.kelvin)),
width=20,
height=20,
borderwidth=3,
relief=relief,
),
)
scale: ColorScale
for key, scale in enumerate(self.hsbk_scale):
tkinter.Label(self, text=self.hsbk[key]).grid(row=key + 1, column=0)
scale.grid(row=key + 1, column=1)
self.hsbk_labels[key].grid(row=key + 1, column=2)
self.hsbk_display[key].grid(row=key + 1, column=3)
self.threads: Dict[str, color_thread.ColorThreadRunner] = {}
def setup_power_controls(self, bulb_power: int):
self.tk_power_var = tkinter.BooleanVar(self)
self.tk_power_var.set(bool(bulb_power))
self.option_on = tkinter.Radiobutton(
self,
text="On",
variable=self.tk_power_var,
value=65535,
command=self.update_power,
)
self.option_off = tkinter.Radiobutton(
self,
text="Off",
variable=self.tk_power_var,
value=0,
command=self.update_power,
)
if self.tk_power_var.get() == 0:
# Light is off
self.option_off.select()
self.option_on.selection_clear()
else:
self.option_on.select()
self.option_off.selection_clear()
self.option_on.grid(row=0, column=0)
self.option_off.grid(row=0, column=1)
def _setup_logger(self):
self.logger = logging.getLogger(
self.master.logger.name + "." + self.__class__.__name__ + f"({self.label})"
)
self.logger.setLevel(logging.DEBUG)
self.logger.info(
"%s logger initialized: %s // Device: %s",
self.__class__.__name__,
self.logger.name,
self.label,
)
def restart(self):
""" Get updated information for the bulb when clicked. """
self.update_status_from_bulb()
self.logger.info("Light frame Restarted.")
def get_label(self):
""" Getter method for the label attribute. Often is monkey-patched. """
return self.label
def trigger_icon_update(self, *_, **__):
""" Just sets a flag for now. Could be more advanced in the future. """
self.icon_update_flag = True
def get_color_values_hsbk(self):
""" Get color values entered into GUI"""
return Color(*tuple(v.get() for v in self.hsbk))
def stop_threads(self):
""" Stop all ColorRunner threads """
self.music_button.config(bg="SystemButtonFace")
self.avg_screen_btn.config(bg="SystemButtonFace")
self.dominant_screen_btn.config(bg="SystemButtonFace")
self.color_cycle_btn.config(bg="SystemButtonFace")
for thread in self.threads.values():
thread.stop()
def update_power(self):
""" Send new power state to bulb when UI is changed. """
self.stop_threads()
self.target.set_power(self.tk_power_var.get())
def update_color_from_ui(self, *_, **__):
""" Send new color state to bulb when UI is changed. """
self.stop_threads()
self.set_color(self.get_color_values_hsbk(), rapid=True)
def set_color(self, color, rapid=False):
""" Should be called whenever the bulb wants to change color. Sends bulb command and updates UI accordingly. """
self.stop_threads()
try:
self.target.set_color(
color,
duration=0
if rapid
else float(config["AverageColor"]["duration"]) * 1000,
rapid=rapid,
)
except lifxlan.WorkflowException as exc:
if not rapid:
raise exc
if not rapid:
self.logger.debug(
"Color changed to HSBK: %s", color
) # Don't pollute log with rapid color changes
def update_label(self, key: int):
""" Update scale labels, formatted accordingly. """
return [
self.hsbk_labels[0].config(
text=str(f"{360 * (self.hsbk[0].get() / 65535):.3g}")
),
self.hsbk_labels[1].config(
text=str(f"{100 * (self.hsbk[1].get() / 65535):.3g}") + "%"
),
self.hsbk_labels[2].config(
text=str(f"{100 * (self.hsbk[2].get() / 65535):.3g}") + "%"
),
self.hsbk_labels[3].config(text=str(self.hsbk[3].get()) + " K"),
][key]
def update_display(self, key: int):
""" Update color swatches to match current device state """
h, s, b, k = self.get_color_values_hsbk() # pylint: disable=invalid-name
if key == 0:
self.hsbk_display[0].config(
background=tuple2hex(hsv_to_rgb(360 * (h / 65535)))
)
elif key == 1:
s = 65535 - s # pylint: disable=invalid-name
self.hsbk_display[1].config(
background=tuple2hex(
(
int(255 * (s / 65535)),
int(255 * (s / 65535)),
int(255 * (s / 65535)),
)
)
)
elif key == 2:
self.hsbk_display[2].config(
background=tuple2hex(
(
int(255 * (b / 65535)),
int(255 * (b / 65535)),
int(255 * (b / 65535)),
)
)
)
elif key == 3:
self.hsbk_display[3].config(background=tuple2hex(kelvin_to_rgb(k)))
def get_color_from_palette(self):
""" Asks users for color selection using standard color palette dialog. """
color = tkinter.colorchooser.askcolor(
initialcolor=hsbk_to_rgb(self.get_color_values_hsbk())
)[0]
if color:
# RGBtoHBSK sometimes returns >65535, so we have to truncate
hsbk = [min(c, 65535) for c in lifxlan.RGBtoHSBK(color, self.hsbk[3].get())]
self.set_color(hsbk)
self.logger.info("Color set to HSBK %s from palette.", hsbk)
def update_status_from_bulb(self, run_once=False):
"""
Periodically update status from the bulb to keep UI in sync.
run_once - Don't call `after` statement at end. Keeps a million workers from being instanced.
"""
require_icon_update = False
power_queue = self.master.bulb_interface.power_queue
if (
self.label in power_queue
and not self.master.bulb_interface.power_queue[self.label].empty()
):
power = self.master.bulb_interface.power_queue[self.label].get()
require_icon_update = True
self.tk_power_var.set(power)
if self.tk_power_var.get() == 0:
# Light is off
self.option_off.select()
self.option_on.selection_clear()
else:
self.option_on.select()
self.option_off.selection_clear()
color_queue = self.master.bulb_interface.color_queue
if (
self.label in color_queue
and not self.master.bulb_interface.color_queue[self.label].empty()
):
hsbk = self.master.bulb_interface.color_queue[self.label].get()
require_icon_update = True
for key, _ in enumerate(self.hsbk):
self.hsbk[key].set(hsbk[key])
self.update_label(key)
self.update_display(key)
self.current_color.config(background=tuple2hex(hsbk_to_rgb(hsbk)))
if require_icon_update:
self.trigger_icon_update()
if not run_once:
self.after(FRAME_PERIOD_MS, self.update_status_from_bulb)
def eyedropper(self, *_, **__):
""" Allows user to select a color pixel from the screen. """
self.master.master.withdraw() # Hide window
state_left = win32api.GetKeyState(
0x01
) # Left button down = 0 or 1. tkinter.Button up = -127 or -128
while True:
action = win32api.GetKeyState(0x01)
if action != state_left: # tkinter.Button state changed
state_left = action
if action >= 0:
break
lifxlan.sleep(0.001)
# tkinter.Button state changed
screen_img = get_screen_as_image()
cursor_pos = mouse.get_position()
# Convert display coords to image coords
cursor_pos = normalize_rectangles(
get_display_rects() + [(cursor_pos[0], cursor_pos[1], 0, 0)]
)[-1][:2]
color = screen_img.getpixel(cursor_pos)
self.master.master.deiconify() # Reshow window
self.logger.info("Eyedropper color found RGB %s", color)
return lifxlan.RGBtoHSBK(color, temperature=self.get_color_values_hsbk().kelvin)
def change_preset_dropdown(self, *_, **__):
""" Change device color to selected preset option. """
color = Color(*globals()[self.color_var.get()])
self.preset_dropdown.config(
bg=tuple2hex(hsbk_to_rgb(color)),
activebackground=tuple2hex(hsbk_to_rgb(color)),
)
self.set_color(color, False)
def change_user_dropdown(self, *_, **__):
""" Change device color to selected user-defined option. """
color = str2tuple(config["PresetColors"][self.tk_user_def_color_var.get()], int)
self.user_dropdown.config(
bg=tuple2hex(hsbk_to_rgb(color)),
activebackground=tuple2hex(hsbk_to_rgb(color)),
)
self.set_color(color, rapid=False)
def update_user_dropdown(self):
""" Add newly defined color to the user color dropdown menu. """
# self.tk_user_def_color_var.set('')
self.user_dropdown["menu"].delete(0, "end")
for choice in config["PresetColors"]:
self.user_dropdown["menu"].add_command(
label=choice, command=_setit(self.tk_user_def_color_var, choice)
)
def get_monitor_bounds(self):
""" Return the 4 rectangle coordinates from the entry boxes in the UI """
return (
f"[{self.screen_region_entries['left'].get()}, {self.screen_region_entries['top'].get()}, "
f"{self.screen_region_entries['width'].get()}, {self.screen_region_entries['height'].get()}]"
)
def save_monitor_bounds(self):
""" Write monitor bounds entered into the UI into the config file. """
config["AverageColor"][self.label] = self.get_monitor_bounds()
# Write to config file
with open("config.ini", "w", encoding="utf-8") as cfg:
config.write(cfg)
class GroupFrame(LightFrame):
def _get_light_info(self, target: lifxlan.Group) -> Tuple[int, Color]:
bulb_power: int = 0
init_color: Color = Color(*lifxlan.WARM_WHITE)
try:
devices: List[
Union[lifxlan.Group, lifxlan.Light, lifxlan.MultiZoneLight]
] = target.get_device_list()
if not devices:
logging.error("No devices found in group list")
self.label = ""
self.min_kelvin, self.max_kelvin = 0, 99999 # arbitrary range
return 0, Color(0, 0, 0, 0)
self.label = devices[0].get_group_label()
bulb_power = devices[0].get_power()
# Find an init_color- ensure device has color attribute, otherwise fallback
color_devices: List[
Union[lifxlan.Group, lifxlan.Light, lifxlan.MultiZoneLight]
] = list(filter(lambda d: d.supports_color(), devices))
if color_devices and hasattr(color_devices[0], "get_color"):
init_color = Color(*color_devices[0].get_color())
self.min_kelvin = min(
device.product_features.get("min_kelvin") or MIN_KELVIN_DEFAULT
for device in target.get_device_list()
)
self.max_kelvin = max(
device.product_features.get("max_kelvin") or MAX_KELVIN_DEFAULT
for device in target.get_device_list()
)
except lifxlan.WorkflowException as exc:
messagebox.showerror(
f"Error building {self.__class__.__name__}",
f"Error thrown when trying to get label from bulb:\n{exc}",
)
self.master.on_closing()
# TODO Let this fail safely and try again later
return bulb_power, init_color
def update_status_from_bulb(self, run_once=False):
return
class MultiZoneFrame(LightFrame):
pass
================================================
FILE: lifx_control_panel/test/__init__.py
================================================
================================================
FILE: lifx_control_panel/test/dummy_devices.py
================================================
""" This is not a standard test file (yet), but used to simulate a multi-device environment. """
import logging
import os
import time
import traceback
from tkinter import *
from tkinter import messagebox
from typing import Iterable
from lifxlan import Group
from utilities.utils import Color as DummyColor
from utilities.utils import resource_path
LOGFILE = "lifx-control-panel.log"
# determine if application is a script file or frozen exe
if getattr(sys, "frozen", False):
application_path = os.path.dirname(sys.executable)
elif __file__:
application_path = os.path.dirname(__file__)
LOGFILE = os.path.join(application_path, LOGFILE)
# Dummy classes
class DummyDevice:
def __init__(self, label="No label"):
self.label = label
self.power = False
self.mac_addr = "00:16:3e:2a:8d:00"
self.ip_addr = "63.218.207.187"
self.build_timestamp = 1521690429000000000
self.version = 2.75
self.wifi_signal_mw = 32.0
self.wifi_tx_bytes = 0
self.wifi_rx_bytes = 0
self.wifi_build_timestamp = 0
self.wifi_version = 0.0
self.vendor = 1
self.product = 0x65
self.location_label = "My Home"
self.location_tuple = (
3,
238,
83,
151,
159,
43,
44,
177,
180,
149,
11,
191,
243,
219,
79,
115,
)
self.location_updated_at = 1516997252637000000
self.group_label = "Room 2"
self.group_tuple = (
50,
71,
100,
191,
135,
165,
21,
163,
195,
54,
66,
226,
0,
175,
217,
223,
)
self.group_updated_at = 1516997252642000000
self._start_time = time.time()
def is_light(self):
return True
def set_label(self, val: str):
self.label = val
def set_power(self, val: bool, rapid: bool = False):
self.power = val
return self.get_power()
def get_mac_address(self):
return self.mac_addr
def get_ip_addr(self):
return self.ip_addr
def get_service(self):
return 1 # returns in, 1 = UDP
def get_port(self):
return 56700
def get_label(self):
return self.label
def get_power(self):
return self.power
def get_host_firmware_tuple(self):
return self.build_timestamp, self.version
def get_host_firmware_build_timestamp(self):
return self.build_timestamp
def get_host_firmware_version(self):
return self.version
def get_wifi_info_tuple(self):
return self.wifi_signal_mw, self.wifi_tx_bytes, self.wifi_rx_bytes
def get_wifi_signal_mw(self):
return self.wifi_signal_mw
def get_wifi_tx_bytes(self):
return self.wifi_tx_bytes
def get_wifi_rx_bytes(self):
return self.wifi_rx_bytes
def get_wifi_firmware_tuple(self):
return self.wifi_build_timestamp, self.wifi_version
def get_wifi_firmware_build_timestamp(self):
return self.wifi_build_timestamp
def get_wifi_firmware_version(self):
return self.wifi_version
def get_version_tuple(self):
return self.vendor, self.product, self.version
def get_location(self):
return self.location_label
def get_location_tuple(self):
return self.location_tuple, self.location_label, self.location_updated_at
def get_location_label(self):
return self.location_label
def get_location_updated_at(self):
return self.location_updated_at
def get_group(self):
return self.group_label
def get_group_tuple(self):
return self.group_tuple, self.group_label, self.group_updated_at
def get_group_label(self):
return self.group_label
def get_group_updated_a(self):
return self.group_updated_at
def get_vendor(self):
return self.vendor
def get_product(self):
return self.product
def get_version(self):
return self.version
def get_info_tuple(self):
return time.time(), self.get_uptime(), self.get_downtime()
def get_time(self):
return time.time()
def get_uptime(self):
return time.time() - self._start_time
def get_downtime(self): # no way to make this work. Shouldn't need it
return 0
def supports_color(self):
return True
def supports_temperature(self):
return True
def supports_multizone(self):
return True
def supports_infrared(self):
return True
class DummyBulb(DummyDevice):
def __init__(self, color=None, label="N/A"):
super().__init__(label)
self.color = color or DummyColor(6097, 46851, 38791, 3014,)
self.power: int = 0
# Official api
@property
def power_level(self):
return self.power
@power_level.setter
def power_level(self, val):
self.power = val
def set_power(self, val: int, duration: int = 0, rapid: bool = False):
self.power = val
def set_color(self, val: DummyColor, duration: int = 0, rapid: bool = False):
self.color = val
return self.get_color()
def set_waveform(self, is_transient, color, period, cycles, duty_cycle, waveform):
pass
def get_power(self):
return self.power
def get_color(self):
return self.color
def get_infared(self):
return self.infared_brightness
def set_infared(self, val: int):
self.infared_brightness = val
def set_hue(self, hue, duration=0, rapid=False):
self.color.hue = hue
def set_brightness(self, brightness, duration=0, rapid=False):
self.color.brightness = brightness
def set_saturation(self, saturation, duration=0, rapid=False):
self.color.saturation = saturation
def set_colortemp(self, kelvin, duration=0, rapid=False):
self.color.kelvin = kelvin
class MultiZoneDummy(DummyBulb):
def __init__(self, color=DummyColor(0, 0, 0, 1500), label="N/A"):
super().__init__(color, label)
# Multizone API
def get_color_zones(self, start=0, end=0):
pass
def set_zone_color(self, start, end, color, duration=0, rapid=False, apply=1):
pass
def set_zone_colors(self, colors, duration=0, rapid=False):
pass
class TileDummy(DummyBulb):
pass
class TileChainDummy(DummyBulb):
def __init__(self, color=DummyColor(0, 0, 0, 1500), label="N/A", x=1, y=1):
super().__init__(color, label)
self.tiles = []
self.cache = []
self.x = x
self.y = y
def get_tile_info(self, refresh_cache=False):
if refresh_cache:
self.cache = self.tiles
return self.tiles
return self.cache
def get_tile_count(self, refresh_cache=False):
if refresh_cache:
self.cache = self.tiles
return len(self.tiles)
return len(self.cache)
def get_tile_colors(self, start_index, tile_count=0, x=0, y=0, width=0):
return [
tile.get_color()
for tile in self.tiles[start_index : start_index + tile_count]
]
def set_tile_colors(
self,
start_index,
colors,
duration=0,
tile_count=0,
x=0,
y=0,
width=0,
rapid=False,
):
for index, tile in enumerate(
self.tiles[start_index : start_index + tile_count]
):
tile.set_color(colors[index])
def get_tilechain_colors(self):
return [tile.get_color() for tile in self.tiles]
def set_tilechain_colors(self, tilechain_colors, duration=0, rapid=False):
for index, tile in enumerate(self.tiles):
tile.set_color(tilechain_colors[index])
def project_matrix(self, hsvk_matrix, duration, rapid):
pass
def get_canvas_dimensions(self, refresh_cache):
return self.x, self.y
def recenter_coordinates(self):
pass
def set_tile_coordinates(self, tile_index, x, y):
pass
def get_tile_map(self, refresh_cache):
pass
class LifxLANDummy:
def __init__(self, verbose=False):
self.devices = {}
# Non-offical api to manipulate for testing
def add_dummy_light(self, light: DummyBulb):
self.devices[light.get_label()] = light
# Official api
def get_lights(self):
return tuple(self.devices.values())
def get_color_lights(self):
return tuple(light for light in self.devices.values() if light.supports_color())
def get_infrared_lights(self):
return tuple(
light for light in self.devices.values() if light.supports_infrared()
)
def get_multizone_lights(self):
return tuple(
light for light in self.devices.values() if light.supports_multizone()
)
def get_tilechain_lights(self):
return tuple(
light for light in self.devices.values() if type(light) is TileChainDummy
)
def get_device_by_name(self, name: str):
return self.devices[name]
def get_devices_by_names(self, names: Iterable[str]):
return Group(
[light for light in self.devices.values() if light.get_label() in names]
)
def get_devices_by_group(self, group_id):
return Group(
[light for light in self.devices.values() if light.get_group() == group_id]
)
def get_devices_by_location(self, location: str):
return Group(
[
light
for light in self.devices.values()
if light.get_location() == location
]
)
def set_power_all_lights(self, power, duration=0, rapid=False):
for light in self.devices:
light.set_power(power)
def set_color_all_lights(self, color, duration=0, rapid=False):
for light in self.devices:
light.set_color(color)
def set_waveform_all_lights(
self, is_transient, color, period, cycles, duty_cycle, wavform, rapid=False
):
for light in self.devices:
light.set_waveform(
is_transient, color, period, cycles, duty_cycle, wavform, rapid
)
def get_power_all_lights(self):
return dict([((light, light.get_power()) for light in self.devices)])
def get_color_all_lights(self):
return dict([((light, light.get_color()) for light in self.devices)])
class DummyGroup:
def __init__(self, devices: list, label: str = "N/A"):
self.devices = devices
self.label = label
def __iter__(self):
return iter(self.devices)
def add_device(self, device: DummyDevice):
self.devices.append(device)
def remove_device(self, device: DummyDevice):
self.devices.remove(device)
def remove_device_by_name(self, device_name: str):
for index, device in enumerate(self.devices):
if device.get_label() == device_name:
del self.devices[index]
break
def get_device_list(self):
return self.devices
def set_power(self, power, duration=0, rapid=False):
for device in self.devices:
device.set_power(power, duration, rapid)
def set_color(self, color, duration=0, rapid=False):
for device in self.devices:
device.set_color(color, duration, rapid)
def set_hue(self, hue, duration=0, rapid=False):
for device in self.devices:
device.set_hue(hue, duration, rapid)
def set_brightness(self, brightness, duration=0, rapid=False):
for device in self.devices:
device.set_hue(brightness, duration, rapid)
def set_saturation(self, saturation, duration=0, rapid=False):
for device in self.devices:
device.set_hue(saturation, duration, rapid)
def set_colortemp(self, kelvin, duration=0, rapid=False):
for device in self.devices:
device.set_hue(kelvin, duration, rapid)
def set_infrared(self, infrared, duration=0, rapid=False):
for device in self.devices:
device.set_hue(infrared, duration, rapid)
def set_zone_color(self, start, end, color, duration=0, rapid=False, apply=1):
for device in self.devices[start:end]:
device.set_color(color, duration, rapid)
def set_zone_colors(self, colors, duration=0, rapid=False):
for index, device in enumerate(self.devices):
device.set_color(colors[index], duration, rapid)
def main():
from lifxlan import LifxLAN
from ..__main__ import LifxFrame
# Build mixed list of fake and real lights
lifx = LifxLANDummy()
lifx.add_dummy_light(DummyBulb(label="A Light"))
lifx.add_dummy_light(DummyBulb(label="B Light"))
lifx.add_dummy_light(DummyBulb(label="C Light"))
lifx.add_dummy_light(DummyBulb(label="D Light"))
lifx.add_dummy_light(DummyBulb(label="E Light"))
lifx.add_dummy_light(DummyBulb(label="F Light"))
lifx.add_dummy_light(DummyBulb(label="G Light"))
lifx.add_dummy_light(DummyBulb(label="H Light"))
lifx.add_dummy_light(DummyBulb(label="I Light"))
lifx.add_dummy_light(DummyBulb(label="J Light"))
for light in LifxLAN().get_lights():
lifx.add_dummy_light(light)
root = Tk()
root.title("lifx_control_panel")
# Setup main_icon
root.iconbitmap(resource_path("res/icon_vector.ico"))
root.logger = logging.getLogger("root")
root.logger.setLevel(logging.DEBUG)
fh = logging.FileHandler(LOGFILE, mode="w")
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
fh.setFormatter(formatter)
sh = logging.StreamHandler()
sh.setLevel(logging.DEBUG)
sh.setFormatter(formatter)
root.logger.addHandler(sh)
root.logger.info("Logger initialized.")
mainframe = LifxFrame(root, lifx)
# Setup exception logging
logger = mainframe.logger
def myHandler(type, value, tb):
logger.exception(
"Uncaught exception: {}:{}:{}".format(repr(type), str(value), repr(tb))
)
# sys.excepthook = myHandler # dont' want to log exceptions when we're testing
# Run main app
root.mainloop()
if __name__ == "__main__":
try:
main()
except Exception as e:
messagebox.showerror(
"Unhandled Exception",
"Unhandled runtime exception: {}\n\n"
"Please report this at: {}".format(
traceback.format_exc(),
r"https://github.com/samclane/LIFX-Control-Panel/issues",
),
)
raise e
================================================
FILE: lifx_control_panel/test/dummy_tests.py
================================================
import unittest
from test.dummy_devices import *
from utilities.utils import Color
class TestLAN(unittest.TestCase):
def setUp(self):
self.lifx = LifxLANDummy()
self.light_labels = ["Bedroom Lamp", "Patio-Lights", "Andy's Room"]
def test_add_lights(self):
for label in self.light_labels:
self.lifx.add_dummy_light(DummyBulb(label=label))
for label in self.light_labels:
self.assertIn(label, self.lifx.devices.keys())
def test_set_color_all_lights(self):
color = Color(1, 2, 3, 3501)
self.lifx.set_color_all_lights(color)
for device in self.lifx.get_devices_by_names(self.light_labels).devices:
self.assertEqual(color, device.get_color())
def test_set_power_all_lights(self):
power = 1
self.lifx.set_power_all_lights(power)
for device in self.lifx.get_devices_by_names(self.light_labels).devices:
self.assertEqual(power, device.get_power())
class TestDevice(unittest.TestCase):
def setUp(self):
self.device = DummyDevice("TestDevice")
def test_set_label(self):
current = self.device.get_label()
label = "TestDevice"
self.device.set_label(label)
self.assertEqual(label, self.device.get_label())
self.device.set_label(current)
self.assertEqual(current, self.device.get_label())
class TestBulb(unittest.TestCase):
def setUp(self):
self.bulb = DummyBulb(label="TestBulb")
def test_set_label(self):
current = self.bulb.get_label()
label = "TestBulb"
self.bulb.set_label(label)
self.assertEqual(label, self.bulb.get_label())
self.bulb.set_label(current)
self.assertEqual(current, self.bulb.get_label())
def test_power_duration(self):
self.skipTest("DummyDevice duration not implemented")
self.bulb.set_power(False)
self.assertEqual(self.bulb.get_power(), False, "Bulb init off")
duration = 3
self.bulb.set_power(True, duration)
self.assertEqual(self.bulb.get_power(), True, "Duration on")
time.sleep(duration + 1)
self.assertEqual(self.bulb.get_power(), False, "Reset to off")
def test_color_duration(self):
self.skipTest("DummyDevice duration not implemented")
color_a = Color(1, 2, 3, 3501)
color_b = Color(4, 5, 6, 6311)
self.bulb.set_color(color_a)
self.assertEqual(self.bulb.get_color(), color_a, "bulb init color")
duration = 2
self.bulb.set_color(color_b, duration)
self.assertEqual(self.bulb.get_color(), color_b, "bulb change color")
time.sleep(duration + 1)
self.assertEqual(self.bulb.get_color(), color_a, "bulb reset color")
if __name__ == "__main__":
unittest.main()
================================================
FILE: lifx_control_panel/test/functional_test.py
================================================
import unittest
from utilities.utils import (
Color,
hsbk_to_rgb,
hsv_to_rgb,
tuple2hex,
str2list,
str2tuple,
)
class TestFunctions(unittest.TestCase):
def setUp(self):
pass
def _cmp_color(self, c, h, s, b, k):
self.assertEqual(c.hue, h)
self.assertEqual(c.saturation, s)
self.assertEqual(c.brightness, b)
self.assertEqual(c.kelvin, k)
def test_color(self):
c1 = Color(0, 0, 0, 0)
self._cmp_color(c1, 0, 0, 0, 0)
for v in c1:
self.assertEqual(v, 0)
c2 = Color(65535, 65535, 65535, 9000)
self._cmp_color(c2, 65535, 65535, 65535, 9000)
c3 = c1 + c2
self._cmp_color(c3, 65535, 65535, 65535, 9000)
self.assertEqual(c3 - c2, c1)
self.assertEqual(str(c1), "[0, 0, 0, 0]")
c3[0] = 12345
self._cmp_color(c3, 12345, 65535, 65535, 9000)
for i, v in enumerate(c3):
self.assertEqual(v, c3[i])
def test_conversion(self):
c1 = Color(0, 0, 0, 0)
rgb1 = hsbk_to_rgb(c1)
self.assertEqual(rgb1, (0, 0, 0))
hsv1 = hsv_to_rgb(*(0, 0, 0))
self.assertEqual(hsv1, rgb1)
def test_str_conversion(self):
rgb1 = (1, 2, 3)
self.assertEqual(tuple2hex(rgb1), "#010203")
strlist_int = "[1, 2, 3]"
self.assertEqual(str2list(strlist_int, int), [1, 2, 3])
self.assertEqual(str2tuple(strlist_int, int), (1, 2, 3))
if __name__ == "__main__":
unittest.main()
================================================
FILE: lifx_control_panel/ui/__init__.py
================================================
================================================
FILE: lifx_control_panel/ui/colorscale.py
================================================
import logging
import tkinter as tk
from typing import List
from ..utilities.utils import tuple2hex, hsv_to_rgb, kelvin_to_rgb
class ColorScale(tk.Canvas):
"""
A canvas that displays a color scale.
"""
def __init__(
self,
parent,
val=0,
height=13,
width=100,
variable=None,
from_=0,
to=360,
command=None,
gradient="hue",
**kwargs,
):
"""
Create a ColorScale.
Keyword arguments:
* parent: parent window
* val: initially selected value
* height: canvas length in y direction
* width: canvas length in x direction
* variable: IntVar linked to the alpha value
* from_: The minimum value the slider can take on
* to: The maximum value of the slider
* command: A function callback, invoked every time the slider is moved
* gradient: The type of background coloration
* **kwargs: Any other keyword argument accepted by a tkinter Canvas
"""
tk.Canvas.__init__(self, parent, width=width, height=height, **kwargs)
self.parent = parent
self.max = to
self.min = from_
self.range = self.max - self.min
self._variable = variable
self.command = command
self.color_grad = gradient
self.logger = logging.getLogger(self.parent.__class__.__name__ + ".ColorScale")
if variable is not None:
try:
val = int(variable.get())
except Exception as e:
self.logger.exception(e)
else:
self._variable = tk.IntVar(self)
val = max(min(self.max, val), self.min)
self._variable.set(val)
self._variable.trace("w", self._update_val)
self.gradient = tk.PhotoImage(master=self, width=int(width), height=int(height))
self.bind("", lambda _: self._draw_gradient(val))
self.bind("", self._on_click)
# self.bind('', self._on_release)
self.bind("", self._on_move)
def _draw_gradient(self, val):
"""Draw the gradient and put the cursor on val."""
self.delete("gradient")
self.delete("cursor")
del self.gradient
width = self.winfo_width()
height = self.winfo_height()
self.gradient = tk.PhotoImage(master=self, width=width, height=height)
line: List[str] = []
def gradfunc(x_coord):
return line.append(tuple2hex((0, 0, 0)))
if self.color_grad == "bw":
def gradfunc(x_coord):
line.append(tuple2hex((int(float(x_coord) / width * 255),) * 3))
elif self.color_grad == "wb":
def gradfunc(x_coord):
line.append(tuple2hex((int((1 - (float(x_coord) / width)) * 255),) * 3))
elif self.color_grad == "kelvin":
def gradfunc(x_coord):
line.append(
tuple2hex(
kelvin_to_rgb(
int(((float(x_coord) / width) * self.range) + self.min)
)
)
)
elif self.color_grad == "hue":
def gradfunc(x_coord):
line.append(tuple2hex(hsv_to_rgb(float(x_coord) / width * 360)))
else:
raise ValueError(f"gradient value {self.color_grad} not recognized")
for x_coord in range(width):
gradfunc(x_coord)
line: str = "{" + " ".join(line) + "}"
self.gradient.put(" ".join([line for _ in range(height)]))
self.create_image(0, 0, anchor="nw", tags="gradient", image=self.gradient)
self.lower("gradient")
x_start: float = self.min
try:
x_start = (val - self.min) / float(self.range) * width
except ZeroDivisionError:
x_start = self.min
self.create_line(
x_start, 0, x_start, height, width=4, fill="white", tags="cursor"
)
self.create_line(x_start, 0, x_start, height, width=2, tags="cursor")
def _on_click(self, event):
"""Move selection cursor on click."""
x_coord = event.x
if x_coord >= 0:
width = self.winfo_width()
self.update_slider_value(width, x_coord)
def update_slider_value(self, width, x_coord):
"""Update the slider value based on slider x coordinate."""
height = self.winfo_height()
for x_start in self.find_withtag("cursor"):
self.coords(x_start, x_coord, 0, x_coord, height)
self._variable.set(round((float(self.range) * x_coord) / width + self.min, 2))
if self.command is not None:
self.command()
def _on_move(self, event):
"""Make selection cursor follow the cursor."""
x_coord = event.x
if x_coord >= 0:
width = self.winfo_width()
x_coord = min(max(abs(x_coord), 0), width)
self.update_slider_value(width, x_coord)
def _update_val(self, *_):
val = int(self._variable.get())
val = min(max(val, self.min), self.max)
self.set(val)
self.event_generate("<>")
def get(self):
"""Return val of color under cursor."""
coords = self.coords("cursor")
width = self.winfo_width()
return round(self.range * coords[0] / width, 2)
def set(self, val):
"""Set cursor position on the color corresponding to the value"""
width = self.winfo_width()
try:
x_coord = (val - self.min) / float(self.range) * width
except ZeroDivisionError:
return
for x_start in self.find_withtag("cursor"):
self.coords(x_start, x_coord, 0, x_coord, self.winfo_height())
self._variable.set(val)
================================================
FILE: lifx_control_panel/ui/icon_list.py
================================================
from __future__ import annotations
from dataclasses import dataclass, field
import tkinter
from typing import Dict, Union
from PIL import Image as pImage
import lifxlan
from ..utilities import utils
@dataclass
class BulbIconListSettings:
""" Encapsulates all constants for the bulb icon list """
window_width: int
icon_width: int
icon_height: int
icon_padding: int
highlight_saturation: int
color_code: dict[str, int] = field(default_factory=dict)
def __post_init__(self):
self.window_width = max(0, self.window_width)
self.icon_width = max(0, self.icon_width)
self.icon_height = max(0, self.icon_height)
self.icon_padding = max(0, self.icon_padding)
self.highlight_saturation = min(max(0, self.highlight_saturation), 255)
class BulbIconList(tkinter.Frame): # pylint: disable=too-many-instance-attributes
""" Holds the dynamic icons for each Device and Group """
def __init__(self, *args, is_group: bool = False, **kwargs):
# Parameters
self.is_group = is_group
# Constants
self.settings = BulbIconListSettings(
window_width=285,
icon_width=50,
icon_height=75,
icon_padding=5,
highlight_saturation=95,
color_code={"BULB_TOP": 11, "BACKGROUND": 15},
)
# Initialization
super().__init__(
*args,
width=self.settings.window_width,
height=self.settings.icon_height,
**kwargs
)
self.scroll_x = 0
self.scroll_y = 0
self.bulb_dict: dict[str, tuple[tkinter.PhotoImage, int, int]] = {}
self.canvas = tkinter.Canvas(
self,
width=self.settings.window_width,
height=self.settings.icon_height,
scrollregion=(0, 0, self.scroll_x, self.scroll_y),
)
h_scroll = tkinter.Scrollbar(self, orient=tkinter.HORIZONTAL)
h_scroll.pack(side=tkinter.BOTTOM, fill=tkinter.X)
h_scroll.config(command=self.canvas.xview)
self.canvas.config(
width=self.settings.window_width, height=self.settings.icon_height
)
self.canvas.config(xscrollcommand=h_scroll.set)
self.canvas.pack(side=tkinter.LEFT, expand=True, fill=tkinter.BOTH)
self.current_icon_width = 0
path = self.icon_path()
self.original_icon = pImage.open(path).load()
self._current_icon = None
@property
def current_icon(self):
""" Returns the name of the currently selected Device/Group """
return self._current_icon
def icon_path(self):
""" Returns the correct icon path for single Device or Group """
return (
utils.resource_path("res/group.png")
if self.is_group
else utils.resource_path("res/lightbulb.png")
)
@property
def icon_paths(self) -> Dict[type, Union[int, bytes]]:
""" Returns a dictionary of the icon paths for each device type """
return {
lifxlan.Group: utils.resource_path("res/group.png"),
lifxlan.Light: utils.resource_path("res/lightbulb.png"),
lifxlan.MultiZoneLight: utils.resource_path("res/multizone.png"),
}
def draw_bulb_icon(self, bulb, label):
""" Given a bulb and a name, add the icon to the end of the row. """
# Make room on canvas
self.scroll_x += self.settings.icon_width
self.canvas.configure(scrollregion=(0, 0, self.scroll_x, self.scroll_y))
# Build icon
path = self.icon_path()
sprite = tkinter.PhotoImage(file=path, master=self.master)
image = self.canvas.create_image(
(
self.current_icon_width
+ self.settings.icon_width
- self.settings.icon_padding,
self.settings.icon_height / 2 + 2 * self.settings.icon_padding,
),
image=sprite,
anchor=tkinter.SE,
tags=[label],
)
text = self.canvas.create_text(
self.current_icon_width + self.settings.icon_padding / 2,
self.settings.icon_height / 2 + 2 * self.settings.icon_padding,
text=label[:8],
anchor=tkinter.NW,
tags=[label],
)
self.bulb_dict[label] = (sprite, image, text)
self.update_icon(bulb)
# update sizing info
self.current_icon_width += self.settings.icon_width
def update_icon(self, bulb: lifxlan.Device):
""" If changes have been detected in the interface, update the bulb state. """
if self.is_group:
return
try:
# this is ugly, but only way to update icon accurately
bulb_color = self.master.bulb_interface.color_cache[bulb.label]
bulb_power = self.master.bulb_interface.power_cache[bulb.label]
bulb_brightness = bulb_color[2]
sprite, image, _ = self.bulb_dict[bulb.label]
except TypeError:
# First run will give us None; Is immediately corrected on next pass
return
# Calculate what number, 0-11, corresponds to current brightness
brightness_scale = (int((bulb_brightness / 65535) * 10) * (bulb_power > 0)) - 1
color_string = ""
for y in range(sprite.height()): # pylint: disable=invalid-name
color_string += "{"
for x in range(sprite.width()): # pylint: disable=invalid-name
# If the tick is < brightness, color it. Otherwise, set it back to the default color
icon_rgb = self.original_icon[x, y][:3]
if (
all(
(
v <= brightness_scale
or v == self.settings.color_code["BULB_TOP"]
)
for v in icon_rgb
)
and self.original_icon[x, y][3] == 255
):
bulb_color = (
bulb_color[0],
bulb_color[1],
bulb_color[2],
bulb_color[3],
)
color = utils.hsbk_to_rgb(bulb_color)
elif (
all(
v
in (
self.settings.color_code["BACKGROUND"],
self.settings.highlight_saturation,
)
for v in icon_rgb
)
and self.original_icon[x, y][3] == 255
):
color = sprite.get(x, y)[:3]
else:
color = icon_rgb
color_string += utils.tuple2hex(color) + " "
color_string += "} "
# Write the final colorstring to the sprite, then update the GUI
sprite.put(color_string, (0, 0, sprite.height(), sprite.width()))
self.canvas.itemconfig(image, image=sprite)
def set_selected_bulb(self, light_name):
""" Highlight the newly selected bulb icon when changed. """
if self._current_icon:
self.clear_selected()
sprite, image, _ = self.bulb_dict[light_name]
color_string = ""
for y in range(sprite.height()): # pylint: disable=invalid-name
color_string += "{"
for x in range(sprite.width()): # pylint: disable=invalid-name
icon_rgb = sprite.get(x, y)[:3]
if (
all(v == self.settings.color_code["BACKGROUND"] for v in icon_rgb)
and self.original_icon[x, y][3] == 255
):
color = (self.settings.highlight_saturation,) * 3
else:
color = icon_rgb
color_string += utils.tuple2hex(color) + " "
color_string += "} "
sprite.put(color_string, (0, 0, sprite.height(), sprite.width()))
self.canvas.itemconfig(image, image=sprite)
self._current_icon = light_name
def clear_selected(self):
""" Reset background to original state (from highlighted). """
sprite, image, _ = self.bulb_dict[self._current_icon]
color_string = ""
for y in range(sprite.height()): # pylint: disable=invalid-name
color_string += "{"
for x in range(sprite.width()): # pylint: disable=invalid-name
icon_rgb = sprite.get(x, y)[:3]
if (
all(v == self.settings.highlight_saturation for v in icon_rgb)
and self.original_icon[x, y][3] == 255
):
color = (self.settings.color_code["BACKGROUND"],) * 3
else:
color = icon_rgb
color_string += utils.tuple2hex(color) + " "
color_string += "} "
sprite.put(color_string, (0, 0, sprite.height(), sprite.width()))
self.canvas.itemconfig(image, image=sprite)
self._current_icon = None
================================================
FILE: lifx_control_panel/ui/settings.py
================================================
# -*- coding: utf-8 -*-
"""UI Logic and Interface Elements for Settings
This module contains several ugly God-classes that control the settings GUI functions and reactions.
Notes
-----
Uses a really funky design pattern for a dialog that I copied from an old project. It's bad and I should probably
ript it out
"""
import configparser
import logging
from tkinter import ttk
from tkinter import (
Toplevel,
Frame,
Button,
ACTIVE,
LEFT,
YES,
Label,
Listbox,
FLAT,
X,
BOTH,
RAISED,
FALSE,
VERTICAL,
Y,
Scrollbar,
END,
BooleanVar,
Checkbutton,
StringVar,
OptionMenu,
Scale,
HORIZONTAL,
Entry,
)
from tkinter.colorchooser import askcolor
import mss
from lifxlan.utils import RGBtoHSBK
from ..utilities.keypress import KeybindManager
from ..utilities.utils import resource_path, str2list
config = configparser.ConfigParser() # pylint: disable=invalid-name
config.read([resource_path("default.ini"), "config.ini"])
# Compare datetimes
DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.%f"
# boilerplate code from http://effbot.org/tkinterbook/tkinter-dialog-windows.htm
class Dialog(Toplevel):
""" Template for dialogs that include an Ok and Cancel button, and return validated user input data. """
def __init__(self, parent, title=None):
Toplevel.__init__(self, parent)
self.transient(parent)
if title:
self.title(title)
self.parent = parent
self.result = None
body = Frame(self)
self.initial_focus = self.body(body)
body.pack(padx=5, pady=5)
self.buttonbox()
self.grab_set()
if not self.initial_focus:
self.initial_focus = self
self.protocol("WM_DELETE_WINDOW", self.cancel)
self.geometry("+%d+%d" % (parent.winfo_rootx() + 50, parent.winfo_rooty() + 50))
self.initial_focus.focus_set()
self.wait_window(self)
# construction hooks
def body(self, master):
"""create dialog body. return widget that should have initial focus. This method should be overridden"""
def buttonbox(self):
""" add standard button box. override if you don't want the standard buttons """
box = Frame(self)
# pylint: disable=invalid-name
ok = Button(box, text="OK", width=10, command=self.ok, default=ACTIVE)
ok.pack(side=LEFT, padx=5, pady=5)
cancel = Button(box, text="Cancel", width=10, command=self.cancel)
cancel.pack(side=LEFT, padx=5, pady=5)
self.bind("", self.ok)
self.bind("", self.cancel)
box.pack()
def ok(self, _=None): # pylint: disable=invalid-name
""" Standard ok semantics """
if not self.validate():
self.initial_focus.focus_set() # put focus back
return
self.withdraw()
self.update_idletasks()
self.apply()
self.cancel()
def cancel(self, _=None):
"""put focus back to the parent window"""
self.parent.focus_set()
self.destroy()
return 0
# command hooks
def validate(self): # pylint: disable=no-self-use
""" Override """
return 1 # override
def apply(self):
""" Override """
class MultiListbox(Frame): # pylint: disable=too-many-ancestors
""" Shows information about items in a column-format
https://www.safaribooksonline.com/library/view/python-cookbook/0596001673/ch09s05.html """
def __init__(self, master, lists):
Frame.__init__(self, master)
self.lists = []
for list_, widget in lists:
frame = Frame(self)
frame.pack(side=LEFT, expand=YES, fill=BOTH)
Label(frame, text=list_, borderwidth=1, relief=RAISED).pack(fill=X)
list_box = Listbox(
frame,
width=widget,
borderwidth=0,
selectborderwidth=0,
relief=FLAT,
exportselection=FALSE,
)
list_box.pack(expand=YES, fill=BOTH)
self.lists.append(list_box)
list_box.bind("", lambda e, s=self: s._select(e.y))
list_box.bind("", lambda e, s=self: s._select(e.y))
list_box.bind("", lambda e: "break")
list_box.bind("", lambda e, s=self: s._b2motion(e.x, e.y))
list_box.bind("", lambda e, s=self: s._button2(e.x, e.y))
frame = Frame(self)
frame.pack(side=LEFT, fill=Y)
Label(frame, borderwidth=1, relief=RAISED).pack(fill=X)
scroll = Scrollbar(frame, orient=VERTICAL, command=self._scroll)
scroll.pack(expand=YES, fill=Y)
self.lists[0]["yscrollcommand"] = scroll.set
def _select(self, y): # pylint: disable=invalid-name
""" Select a row when clicked """
row = self.lists[0].nearest(y)
self.selection_clear(0, END)
self.selection_set(row)
return "break"
def _button2(self, x, y): # pylint: disable=invalid-name
for list_ in self.lists:
list_.scan_mark(x, y)
return "break"
def _b2motion(self, x, y): # pylint: disable=invalid-name
for list_ in self.lists:
list_.scan_dragto(x, y)
return "break"
def _scroll(self, *args):
""" Move the list down """
for list_ in self.lists:
list_.yview(*args)
def curselection(self):
""" Return currently selected list item """
return self.lists[0].curselection()
def delete(self, first, last=None):
""" Remove an item from the list and GUI """
for list_ in self.lists:
list_.delete(first, last)
def get(self, first, last=None):
""" Get specific item from the list """
result = [list_.get(first, last) for list_ in self.lists]
if last:
return map(*([None] + result))
return result
def index(self, index):
""" Get index of item at index"""
self.lists[0].index(index)
def insert(self, index, *elements):
""" Insert element into list"""
for elm in elements:
for i, list_ in enumerate(self.lists):
list_.insert(index, elm[i])
def size(self):
""" Size of internal list at call time """
return self.lists[0].size()
def see(self, index):
""" Wrapper for see function that calls on each list """
for list_ in self.lists:
list_.see(index)
def selection_anchor(self, index):
for list_ in self.lists:
list_.selection_anchor(index)
def selection_clear(self, first, last=None):
""" Clear selection highlight """
for list_ in self.lists:
list_.selection_clear(first, last)
def selection_includes(self, index):
""" Check if item at index is in user selection """
return self.lists[0].selection_includes(index)
def selection_set(self, first, last=None):
""" Manually change the selection """
for list_ in self.lists:
list_.selection_set(first, last)
class SettingsDisplay(Dialog):
""" Settings form User Interface"""
def body(self, master):
self.root_window = master.master.master # This is really gross. I'm sorry.
self.logger = logging.getLogger(
self.root_window.logger.name + ".SettingsDisplay"
)
self.key_listener = KeybindManager(self, sticky=True)
# Labels
Label(master, text="Start Minimized?: ").grid(row=0, column=0)
Label(master, text="Avg. Monitor Default: ").grid(row=1, column=0)
Label(master, text="Smooth Transition Time (sec): ").grid(row=2, column=0)
Label(master, text="Brightness Offset: ").grid(row=3, column=0)
Label(master, text="Add Preset Color: ").grid(row=4, column=0)
Label(master, text="Audio Input Source: ").grid(row=5, column=0)
Label(master, text="Add keyboard shortcut").grid(row=6, column=0)
# Widgets
# Starting minimized
self.start_mini = BooleanVar(
master, value=config.getboolean("AppSettings", "start_minimized")
)
self.start_mini_check = Checkbutton(master, variable=self.start_mini)
# Avg monitor color match
self.avg_monitor = StringVar(
master, value=config["AverageColor"]["DefaultMonitor"]
)
with mss.mss() as sct:
options = [
"full",
"get_primary_monitor",
*[tuple(m.values()) for m in sct.monitors],
]
# lst = get_display_rects()
# for i in range(1, len(lst) + 1):
# els = [list(x) for x in itertools.combinations(lst, i)]
# options.extend(els)
self.avg_monitor_dropdown = OptionMenu(master, self.avg_monitor, *options)
self.duration_scale = Scale(
master, from_=0, to_=2, resolution=1 / 15, orient=HORIZONTAL
)
self.duration_scale.set(float(config["AverageColor"]["Duration"]))
self.brightness_offset = Scale(
master, from_=0, to_=65535, resolution=1, orient=HORIZONTAL
)
self.brightness_offset.set(int(config["AverageColor"]["brightnessoffset"]))
# Custom preset color
self.preset_color_name = Entry(master)
self.preset_color_name.insert(END, "Enter color name...")
self.preset_color_button = Button(
master, text="Choose and add!", command=self.get_color
)
# Audio dropdown
device_names = self.master.audio_interface.get_device_names()
try:
init_string = (
" "
+ config["Audio"]["InputIndex"]
+ " "
+ device_names[int(config["Audio"]["InputIndex"])]
)
except ValueError:
init_string = " None"
self.audio_source = StringVar(
master, init_string
) # AudioSource index is grabbed from [1], so add a space at [0]
as_choices = device_names.items()
self.as_dropdown = OptionMenu(master, self.audio_source, *as_choices)
# Add keybindings
light_names = list(self.root_window.device_map.keys())
self.keybind_bulb_selection = StringVar(master, value=light_names[0])
self.keybind_bulb_dropdown = OptionMenu(
master, self.keybind_bulb_selection, *light_names
)
self.keybind_keys_select = Entry(master)
self.keybind_keys_select.insert(END, "Add key-combo...")
self.keybind_keys_select.config(state="readonly")
self.keybind_keys_select.bind("", self.on_keybind_keys_click)
self.keybind_keys_select.bind(
"", lambda *_: self.keybind_keys_select.config(state="readonly")
)
self.keybind_color_selection = StringVar(master, value="Color")
self.keybind_color_dropdown = OptionMenu(
master,
self.keybind_color_selection,
*self.root_window.frame_map[
self.keybind_bulb_selection.get()
].default_colors,
*(
[*config["PresetColors"].keys()]
if any(config["PresetColors"].keys())
else [None]
)
)
self.keybind_add_button = Button(
master,
text="Add keybind",
command=lambda *_: self.register_keybinding(
self.keybind_bulb_selection.get(),
self.keybind_keys_select.get(),
self.keybind_color_selection.get(),
),
)
self.keybind_delete_button = Button(
master, text="Delete keybind", command=self.delete_keybind
)
# Insert
self.start_mini_check.grid(row=0, column=1)
ttk.Separator(master, orient=HORIZONTAL).grid(
row=0, sticky="esw", columnspan=100
)
self.avg_monitor_dropdown.grid(row=1, column=1)
self.duration_scale.grid(row=2, column=1)
self.brightness_offset.grid(row=3, column=1)
ttk.Separator(master, orient=HORIZONTAL).grid(
row=3, sticky="esw", columnspan=100
)
self.preset_color_name.grid(row=4, column=1)
self.preset_color_button.grid(row=4, column=2)
ttk.Separator(master, orient=HORIZONTAL).grid(
row=4, sticky="esw", columnspan=100
)
self.as_dropdown.grid(row=5, column=1)
ttk.Separator(master, orient=HORIZONTAL).grid(
row=5, sticky="esw", columnspan=100
)
self.keybind_bulb_dropdown.grid(row=6, column=1)
self.keybind_keys_select.grid(row=6, column=2)
self.keybind_color_dropdown.grid(row=6, column=3)
self.keybind_add_button.grid(row=6, column=4)
self.mlb = MultiListbox(master, (("Bulb", 5), ("Keybind", 5), ("Color", 5)))
for keypress, fnx in dict(config["Keybinds"]).items():
label, color = fnx.split(":")
self.mlb.insert(END, (label, keypress, color))
self.mlb.grid(row=7, columnspan=100, sticky="esw")
self.keybind_delete_button.grid(row=8, column=0)
def validate(self) -> int:
config["AppSettings"]["start_minimized"] = str(self.start_mini.get())
config["AverageColor"]["DefaultMonitor"] = str(self.avg_monitor.get())
config["AverageColor"]["Duration"] = str(self.duration_scale.get())
config["AverageColor"]["BrightnessOffset"] = str(self.brightness_offset.get())
config["Audio"]["InputIndex"] = str(self.audio_source.get()[1])
# Write to config file
with open("config.ini", "w", encoding="utf-8") as cfg:
config.write(cfg)
self.key_listener.shutdown()
return 1
def get_color(self):
""" Present user with color palette dialog and return color in HSBK """
color = askcolor()[0]
if color:
# RGBtoHBSK sometimes returns >65535, so we have to clamp
hsbk = [min(c, 65535) for c in RGBtoHSBK(color)]
config["PresetColors"][self.preset_color_name.get()] = str(hsbk)
def register_keybinding(self, bulb: str, keys: str, color: str):
""" Get the keybind from the input box and pass the color off to the root window. """
try:
color = self.root_window.frame_map[
self.keybind_bulb_selection.get()
].default_colors[color]
except KeyError: # must be using a custom color
color = str2list(config["PresetColors"][color], int)
self.root_window.save_keybind(bulb, keys, color)
config["Keybinds"][str(keys)] = str(bulb + ":" + str(color))
self.mlb.insert(END, (str(bulb), str(keys), str(color)))
self.keybind_keys_select.config(state="normal")
self.keybind_keys_select.delete(0, "end")
self.keybind_keys_select.insert(END, "Add key-combo...")
self.keybind_keys_select.config(state="readonly")
self.preset_color_name.focus_set() # Set focus to a dummy widget to reset the Entry
def on_keybind_keys_click(self, event):
""" Call when cursor is in key-combo entry """
self.update()
self.update_idletasks()
self.key_listener.restart()
self.keybind_keys_select.config(state="normal")
self.update()
self.update_idletasks()
while self.focus_get() == self.keybind_keys_select:
self.keybind_keys_select.delete(0, "end")
self.keybind_keys_select.insert(END, self.key_listener.key_combo_code)
self.update()
self.update_idletasks()
def delete_keybind(self):
""" Delete keybind currently selected in the multi-list box. """
_, keybind, _ = self.mlb.get(ACTIVE)
self.mlb.delete(ACTIVE)
self.root_window.delete_keybind(keybind)
config.remove_option("Keybinds", keybind)
================================================
FILE: lifx_control_panel/ui/splashscreen.py
================================================
# -*- coding: utf-8 -*-
"""Splash-screen class
Displays lifx_control_panel's icon while GUI loads
"""
from tkinter import Toplevel, Canvas, PhotoImage
class Splash:
""" From http://code.activestate.com/recipes/576936/ """
def __init__(self, root, file):
self.__root = root
self.__file = file
# Save the variables for later cleanup.
self.__window = None
self.__canvas = None
self.__splash = None
def __enter__(self):
# Hide the root while it is built.
self.__root.withdraw()
# Create components of splash screen.
window = Toplevel(self.__root)
canvas = Canvas(window)
splash = PhotoImage(master=window, file=self.__file)
# Get the screen's width and height.
screen_width = window.winfo_screenwidth()
screen_height = window.winfo_screenheight()
# Get the images's width and height.
img_width = splash.width()
img_height = splash.height()
# Compute positioning for splash screen.
xpos = (screen_width - img_width) // 2
ypos = (screen_height - img_height) // 2
# Configure the window showing the logo.
window.overrideredirect(True)
window.geometry("+{}+{}".format(xpos, ypos))
# Setup canvas on which image is drawn.
canvas.configure(width=img_width, height=img_height, highlightthickness=0)
canvas.grid()
# Show the splash screen on the monitor.
canvas.create_image(img_width // 2, img_height // 2, image=splash)
window.update()
# Save the variables for later cleanup.
self.__window = window
self.__canvas = canvas
self.__splash = splash
def __exit__(self, exc_type, exc_val, exc_tb):
# Free used resources in reverse order.
del self.__splash
self.__canvas.destroy()
self.__window.destroy()
# Give control back to the root program.
self.__root.update_idletasks()
self.__root.deiconify()
================================================
FILE: lifx_control_panel/utilities/__init__.py
================================================
================================================
FILE: lifx_control_panel/utilities/async_bulb_interface.py
================================================
# -*- coding: utf-8 -*-
import concurrent.futures
import logging
import queue
import threading
from typing import List, Union
import lifxlan
class AsyncBulbInterface(threading.Thread):
""" Asynchronous networking layer between LIFX devices and the GUI. """
def __init__(self, event, heartbeat_ms):
threading.Thread.__init__(self)
self.stopped = event
self.hb_rate = heartbeat_ms
self.device_list = []
self.color_queue = {}
self.color_cache = {}
self.power_queue = {}
self.power_cache = {}
self.logger = logging.getLogger("root")
def set_device_list(
self, device_list: List[lifxlan.Device],
):
""" Set internet device list to passed list of LIFX devices. """
for dev in device_list:
try:
label = dev.get_label()
self.color_queue[label] = queue.Queue()
try:
if dev.supports_multizone():
dev: lifxlan.MultiZoneLight
color = dev.get_color_zones()[0]
else:
color = getattr(dev, "color", None)
except Exception as e:
self.logger.error(e)
color = None
self.color_cache[dev.label] = color
self.power_queue[dev.label] = queue.Queue()
try:
self.power_cache[dev.label] = dev.power_level or dev.get_power()
except Exception as e:
self.logger.error(e)
self.power_cache[dev.label] = 0
self.device_list.append(dev)
except lifxlan.WorkflowException as exc:
self.logger.warning(
"Error when communicating with LIFX device: %s", exc
)
def query_device(self, target):
""" Check if target has new state. If it does, push it to the queue and cache the value. """
try:
pwr = target.get_power()
if pwr != self.power_cache[target.label]:
self.power_queue[target.label].put(pwr)
self.power_cache[target.label] = pwr
clr = target.get_color()
if clr != self.color_cache[target.label]:
self.color_queue[target.label].put(clr)
self.color_cache[target.label] = clr
except lifxlan.WorkflowException:
pass
def run(self):
""" Continuous loop that has a thread query each device every HEARTBEAT ms. """
with concurrent.futures.ThreadPoolExecutor(
max_workers=max(1, len(self.device_list))
) as executor:
while not self.stopped.wait(self.hb_rate / 1000):
executor.map(self.query_device, self.device_list)
================================================
FILE: lifx_control_panel/utilities/audio.py
================================================
# -*- coding: utf-8 -*-
"""Audio Processing Tools
Tools for real-time audio processing and color-following. For co-use with color_threads.py
Notes
-----
Not really complete yet; still need to integrate with other screen averaging functions.
"""
import audioop
from collections import deque
from math import ceil
from tkinter import messagebox
import pyaudio
# Audio processing constants
CHUNK = 1024
FORMAT = pyaudio.paInt16
CHANNELS = 2
RATE = 44100
# RMS -> Brightness control constants
SCALE = 8 # Change if too dim/bright
EXPONENT = 2 # Change if too little/too much difference between loud and quiet sounds
N_POINTS = 15 # Length of sliding average window for smoothing
class AudioInterface:
""" Instantiate a connection to audio device (selected in Settings). Also provides a color-following function for
music intensity. """
def __init__(self):
self.interface = pyaudio.PyAudio()
self.num_devices = 0
self.stream = None
self.initialized = False
self.window = deque([0] * N_POINTS)
def init_audio(self, config):
""" Attempt to make a connection to the audio device given in config.ini or Stereo Mix. Will attempt
to automatically find a Stereo Mix """
if self.initialized:
self.interface.close(self.stream)
self.num_devices = 0
try:
self.init_configured_device(config)
except (ValueError, OSError) as exc:
if self.initialized: # only show error if main app has already started
messagebox.showerror("Invalid Sound Input", exc)
self.initialized = False
def init_configured_device(self, config):
# Find input device index
info = self.interface.get_host_api_info_by_index(0)
self.num_devices = info.get("deviceCount")
# If a setting is found, use it. Otherwise, try and find Stereo Mix
if config.has_option("Audio", "InputIndex"):
input_device_index = int(config["Audio"]["InputIndex"])
else:
input_device_index = self.get_stereo_mix_index()
config["Audio"]["InputIndex"] = str(input_device_index)
with open("config.ini", "w") as cfg:
config.write(cfg)
if input_device_index is None:
raise OSError("No Input channel found. Disabling Sound Integration.")
self.stream = self.interface.open(
format=FORMAT,
channels=CHANNELS,
rate=RATE,
input=True,
frames_per_buffer=CHUNK,
input_device_index=input_device_index,
)
self.initialized = True
def get_stereo_mix_index(self):
""" Naively get stereo mix, as it's probably the best input """
device_index = None
for i in range(self.num_devices):
if (
"stereo mix"
in self.interface.get_device_info_by_host_api_device_index(0, i)[
"name"
].lower()
):
device_index = self.interface.get_device_info_by_host_api_device_index(
0, i
)["index"]
return device_index
def get_device_names(self):
""" Get names of all audio devices"""
devices = {}
for i in range(self.num_devices):
info = self.interface.get_device_info_by_host_api_device_index(0, i)
devices[info["index"]] = info["name"]
return devices
def get_music_color(self, initial_color, alpha=0.99):
""" Calculate the RMS power of the waveform, and return that as the initial_color with the calculated brightness
"""
data = self.stream.read(CHUNK)
frame_rms = audioop.rms(data, 2)
level = min(frame_rms / (2.0 ** 16) * SCALE, 1.0)
level = level ** EXPONENT
level = int(level * 65535)
self.window.rotate(1) # FILO Queue
# window = deque([a*x for x in window]) # exp decay
self.window[0] = level
brightness = ceil(sum(self.window) / N_POINTS)
return initial_color[0], initial_color[1], brightness, initial_color[3]
================================================
FILE: lifx_control_panel/utilities/color_thread.py
================================================
# -*- coding: utf-8 -*-
"""Multi-Threaded Color Changer
Contains several basic "Color-Following" functions, as well as custom Stop/Start threads for these effects.
"""
import logging
import threading
from functools import lru_cache
from typing import List, Tuple
import time
import math
import mss
import numexpr as ne
import numpy as np
# from lib.color_functions import dominant_color
from PIL import Image
from lifxlan import utils
from .utils import str2list, Color
from ..ui.settings import config
from lifx_control_panel.utilities.utils import hsv_to_rgb
@lru_cache(maxsize=32)
def get_monitor_bounds(func):
""" Returns the rectangular coordinates of the desired Avg. Screen area. Can pass a function to find the result
procedurally """
return func() or config["AverageColor"]["DefaultMonitor"]
def get_screen_as_image():
"""Grabs the entire primary screen as an image"""
with mss.mss() as sct:
monitor = sct.monitors[0]
# Capture a bbox using percent values
left = monitor["left"] # + monitor["width"] * 5 // 100 # 5% from the left
top = monitor["top"] # + monitor["height"] * 5 // 100 # 5% from the top
right = monitor["left"] + monitor["width"] # left + 400 # 400px width
lower = monitor["top"] + monitor["height"] # top + 400 # 400px height
bbox = (left, top, right, lower)
sct_img = sct.grab(bbox)
return Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX")
def get_rect_as_image(bounds: Tuple[int, int, int, int]):
""" Grabs a rectangular area of the primary screen as an image """
with mss.mss() as sct:
monitor = {
"left": bounds[0],
"top": bounds[1],
"width": bounds[2],
"height": bounds[3],
}
sct_img = sct.grab(monitor)
return Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX")
def normalize_rectangles(rects: List[Tuple[int, int, int, int]]):
""" Normalize the rectangles to the monitor size """
x_min = min(rect[0] for rect in rects)
y_min = min(rect[1] for rect in rects)
return [
(-x_min + left, -y_min + top, -x_min + right, -y_min + bottom,)
for left, top, right, bottom in rects
]
class ColorCycle:
def __init__(self):
self.initial_color = Color(255, 0, 0, 0)
self.last_change = time.time()
self.pos = 0
self.cycle_color = hsv_to_rgb(self.pos, 1, 1)
def get_color(self, *args, **kwargs):
if time.time() - self.last_change > 0.1:
self.pos = (self.pos + 1) % 360
self.cycle_color = hsv_to_rgb(self.pos, 1, self.initial_color[2] / 65535)
self.last_change = time.time()
return list(
utils.RGBtoHSBK(self.cycle_color, temperature=self.initial_color[3])
)
def __call__(self, initial_color):
self.initial_color = initial_color
return self.get_color()
def __name__(self):
return "ColorCycle"
def avg_screen_color(initial_color, func_bounds=lambda: None):
""" Capture an image of the monitor defined by func_bounds, then get the average color of the image in HSBK """
monitor = get_monitor_bounds(func_bounds)
if "full" in monitor:
screenshot = get_screen_as_image()
else:
screenshot = get_rect_as_image(str2list(monitor, int))
# Resizing the image to 1x1 pixel will give us the average for the whole image (via HAMMING interpolation)
color = screenshot.resize((1, 1), Image.HAMMING).getpixel((0, 0))
return list(utils.RGBtoHSBK(color, temperature=initial_color[3]))
def dominant_screen_color(initial_color, func_bounds=lambda: None):
"""
Gets the dominant color of the screen defined by func_bounds
https://stackoverflow.com/questions/50899692/most-dominant-color-in-rgb-image-opencv-numpy-python
"""
monitor = get_monitor_bounds(func_bounds)
if "full" in monitor:
screenshot = get_screen_as_image()
else:
screenshot = get_rect_as_image(str2list(monitor, int))
downscale_width, downscale_height = screenshot.width // 4, screenshot.height // 4
screenshot = screenshot.resize((downscale_width, downscale_height), Image.HAMMING)
a = np.array(screenshot)
a2D = a.reshape(-1, a.shape[-1])
col_range = (256, 256, 256) # generically : a2D.max(0)+1
eval_params = {
"a0": a2D[:, 0],
"a1": a2D[:, 1],
"a2": a2D[:, 2],
"s0": col_range[0],
"s1": col_range[1],
}
a1D = ne.evaluate("a0*s0*s1+a1*s0+a2", eval_params)
color = np.unravel_index(np.bincount(a1D).argmax(), col_range)
return list(utils.RGBtoHSBK(color, temperature=initial_color[3]))
class ColorThread(threading.Thread):
""" A Simple Thread which runs when the _stop event isn't set """
def __init__(self, *args, **kwargs):
super().__init__(*args, daemon=True, **kwargs)
self._stop = threading.Event()
def stop(self):
""" Stop thread by setting event """
self._stop.set()
def stopped(self):
""" Check if thread has been stopped """
return self._stop.isSet()
class ColorThreadRunner:
""" Manages an asynchronous color-change with a Device. Can be run continuously, stopped and started. """
def __init__(self, bulb, color_function, parent, continuous=True, **kwargs):
self.bulb = bulb
self.color_function = color_function
self.kwargs = kwargs
self.parent = parent # couple to parent frame
self.logger = logging.getLogger(
parent.logger.name + f".Thread({color_function.__name__})"
)
self.prev_color = parent.get_color_values_hsbk()
self.continuous = continuous
self.thread = ColorThread(target=self.match_color, args=(self.bulb,))
try:
label = self.bulb.get_label()
except: # pylint: disable=bare-except
# If anything goes wrong in getting the label just set it to ERR; we really don't care except for logging.
label = ""
self.logger.info(
"Initialized Thread: Bulb: %s // Continuous: %s", label, self.continuous
)
def match_color(self, bulb):
""" ColorThread target which calls the 'change_color' function on the bulb. """
self.logger.debug("Starting color match.")
self.prev_color = (
self.parent.get_color_values_hsbk()
) # coupling to LightFrame from gui.py here
while not self.thread.stopped():
try:
color = list(
self.color_function(initial_color=self.prev_color, **self.kwargs)
)
color[2] = min(color[2] + self.get_brightness_offset(), 65535)
bulb.set_color(
color, duration=self.get_duration() * 1000, rapid=self.continuous
)
self.prev_color = color
except OSError:
# This is dirty, but we really don't care, just keep going
self.logger.info("Hit an os error")
continue
if not self.continuous:
self.stop()
self.logger.debug("Color match finished.")
def start(self):
""" Start the match_color thread"""
if self.thread.stopped():
self.thread = ColorThread(target=self.match_color, args=(self.bulb,))
self.thread.setDaemon(True)
try:
self.thread.start()
self.logger.debug("Thread started.")
except RuntimeError:
self.logger.error("Tried to start ColorThread again.")
def stop(self):
""" Stop the match_color thread"""
self.thread.stop()
@staticmethod
def get_duration():
""" Read the transition duration from the config file. """
return float(config["AverageColor"]["duration"])
@staticmethod
def get_brightness_offset():
""" Read the brightness offset from the config file. """
return int(config["AverageColor"]["brightnessoffset"])
def install_thread_excepthook():
"""
Workaround for sys.excepthook thread bug
(https://sourceforge.net/tracker/?func=detail&atid=105470&aid=1230540&group_label=5470).
Call once from __main__ before creating any threads.
If using psyco, call psycho.cannotcompile(threading.Thread.run)
since this replaces a new-style class method.
"""
import sys
run_old = threading.Thread.run
def run(*args, **kwargs):
""" Monkey-patch for the run function that installs local excepthook """
try:
run_old(*args, **kwargs)
except (KeyboardInterrupt, SystemExit):
raise
except: # pylint: disable=bare-except
sys.excepthook(*sys.exc_info())
threading.Thread.run = run
install_thread_excepthook()
================================================
FILE: lifx_control_panel/utilities/keypress.py
================================================
# -*- coding: utf-8 -*-
"""Keyboard shortcut interface
Contains single class for interfacing with user IO and binding to functions
"""
import logging
import keyboard
class KeybindManager:
""" Interface with Mouse/Keyboard and register functions to keyboard shortcuts. """
def __init__(self, master, sticky=False):
self.logger = logging.getLogger(master.logger.name + ".Keystroke_Watcher")
self.keys_held = set()
self.sticky = sticky
self.hooks = {}
keyboard.on_press(lambda e: self.keys_held.add(e.name))
keyboard.on_release(lambda e: self.keys_held.discard(e.name))
@property
def key_combo_code(self) -> str:
""" Converts the keys currently being held into a string representing the combination """
return "+".join(self.keys_held)
def register_function(self, key_combo, function):
""" Register function callback to key_combo """
cb = keyboard.add_hotkey(key_combo, function)
self.hooks[key_combo] = cb
self.logger.info(
"Registered function <%s> to keycombo <%s>.",
function.__name__,
key_combo.lower(),
)
def unregister_function(self, key_combo):
""" Stop tracking function at key_combo """
keyboard.remove_hotkey(key_combo)
self.logger.info(
"Unregistered function at keycombo <%s>", key_combo.lower(),
)
def _on_key_down(self, event: keyboard.KeyboardEvent):
""" Simply adds the key to keys held. """
try:
self.keys_held.add(event.name)
except Exception as exc:
self.logger.error("Error in _on_key_down, %s", exc)
return True
def _on_key_up(self, event: keyboard.KeyboardEvent):
""" If a function for the given key_combo is found, call it """
if not self.sticky and event.name in self.keys_held:
self.keys_held.discard(event.name)
def shutdown(self):
""" Stop following keyboard events. """
keyboard.unhook_all()
def restart(self):
""" Clear keys held and rehook keyboard. """
self.keys_held = set()
for keycombo, cb in self.hooks.items():
keyboard.register_hotkey(keycombo, cb)
================================================
FILE: lifx_control_panel/utilities/utils.py
================================================
# -*- coding: utf-8 -*-
"""General utility classes and functions."""
import os
import sys
from functools import lru_cache
from math import log, floor
from typing import Union, Tuple, List
import mss
class Color:
""" Container class for a single color vector in HSBK color-space. """
__slots__ = ["hue", "saturation", "brightness", "kelvin"]
def __init__(self, hue: int, saturation: int, brightness: int, kelvin: int):
self.hue = hue
self.saturation = saturation
self.brightness = brightness
self.kelvin = kelvin
def __getitem__(self, item) -> int:
return self.__getattribute__(self.__slots__[item])
def __len__(self) -> int:
return 4
def __setitem__(self, key, value):
self.__setattr__(self.__slots__[key], value)
def __str__(self) -> str:
return f"[{self.hue}, {self.saturation}, {self.brightness}, {self.kelvin}]"
def __repr__(self) -> str:
return [self.hue, self.saturation, self.brightness, self.kelvin].__repr__()
def __eq__(self, other) -> bool:
return (
self.hue == other.hue
and self.brightness == other.brightness
and self.saturation == other.saturation
and self.kelvin == other.kelvin
)
def __add__(self, other):
return Color(
self.hue + other[0],
self.saturation + other[1],
self.brightness + other[2],
self.kelvin + other[3],
)
def __sub__(self, other):
return self.__add__([-v for v in other])
def __iter__(self):
return iter([self.hue, self.saturation, self.brightness, self.kelvin])
# Derived types
TypeRGB = Union[Tuple[int, int, int], Color]
TypeHSBK = Union[Tuple[int, int, int, int], Color]
def hsbk_to_rgb(hsvk: TypeHSBK) -> TypeRGB:
""" Convert Tuple in HSBK color-space to RGB space.
Converted from PHP https://gist.github.com/joshrp/5200913 """
# pylint: disable=invalid-name
iH, iS, iV, iK = hsvk
dS = (100 * iS / 65535) / 100.0 # Saturation: 0.0-1.0
dV = (100 * iV / 65535) / 100.0 # Lightness: 0.0-1.0
dC = dV * dS # Chroma: 0.0-1.0
dH = (360 * iH / 65535) / 60.0 # H-prime: 0.0-6.0
dT = dH # Temp variable
while dT >= 2.0: # php modulus does not work with float
dT -= 2.0
dX = dC * (1 - abs(dT - 1))
dHf = floor(dH)
if dHf == 0:
dR = dC
dG = dX
dB = 0.0
elif dHf == 1:
dR = dX
dG = dC
dB = 0.0
elif dHf == 2:
dR = 0.0
dG = dC
dB = dX
elif dHf == 3:
dR = 0.0
dG = dX
dB = dC
elif dHf == 4:
dR = dX
dG = 0.0
dB = dC
elif dHf == 5:
dR = dC
dG = 0.0
dB = dX
else:
dR = 0.0
dG = 0.0
dB = 0.0
dM = dV - dC
dR += dM
dG += dM
dB += dM
# Finally, factor in Kelvin
# Adopted from:
# https://github.com/tort32/LightServer/blob/master/src/main/java/com/github/tort32/api/nodemcu/protocol/RawColor.java#L125
rgb_hsb = int(dR * 255), int(dG * 255), int(dB * 255)
rgb_k = kelvin_to_rgb(iK)
a = iS / 65535.0
b = (1.0 - a) / 255
x = int(rgb_hsb[0] * (a + rgb_k[0] * b))
y = int(rgb_hsb[1] * (a + rgb_k[1] * b))
z = int(rgb_hsb[2] * (a + rgb_k[2] * b))
return x, y, z
def hsv_to_rgb(h: float, s: float = 1, v: float = 1) -> TypeRGB:
""" Convert a Hue-angle to an RGB value for display. """
# pylint: disable=invalid-name
h = float(h)
s = float(s)
v = float(v)
h60 = h / 60.0
h60f = floor(h60)
hi = int(h60f) % 6
f = h60 - h60f
p = v * (1 - s)
q = v * (1 - f * s)
t = v * (1 - (1 - f) * s)
r, g, b = 0, 0, 0
if hi == 0:
r, g, b = v, t, p
elif hi == 1:
r, g, b = q, v, p
elif hi == 2:
r, g, b = p, v, t
elif hi == 3:
r, g, b = p, q, v
elif hi == 4:
r, g, b = t, p, v
elif hi == 5:
r, g, b = v, p, q
r, g, b = int(r * 255), int(g * 255), int(b * 255)
return r, g, b
def kelvin_to_rgb(temperature: int) -> TypeRGB:
""" Convert a Kelvin (K) color-temperature to an RGB value for display."""
# pylint: disable=invalid-name
temperature /= 100
if temperature <= 66:
red = 255
green = temperature
green = 99.4708025861 * log(green + 0.0000000001) - 161.1195681661
else:
red = temperature - 60
red = 329.698727466 * (red ** -0.1332047592)
red = max(red, 0)
red = min(red, 255)
green = temperature - 60
green = 288.1221695283 * (green ** -0.0755148492)
green = max(green, 0)
green = min(green, 255)
# calc blue
if temperature >= 66:
blue = 255
elif temperature <= 19:
blue = 0
else:
blue = temperature - 10
blue = 138.5177312231 * log(blue) - 305.0447927307
blue = max(blue, 0)
blue = min(blue, 255)
return int(red), int(green), int(blue)
def tuple2hex(tuple_: TypeRGB) -> str:
""" Takes a color in tuple form and converts it to hex. """
return "#%02x%02x%02x" % tuple_
def str2list(string: str, type_func) -> List:
""" Takes a Python list-formatted string and returns a list of elements of type type_func """
return list(map(type_func, string.strip("()[]").split(",")))
def str2tuple(string: str, type_func) -> Tuple:
""" Takes a Python list-formatted string and returns a tuple of type type_func """
return tuple(map(type_func, string.strip("()[]").split(",")))
# Multi monitor methods
@lru_cache(maxsize=None)
def get_primary_monitor() -> Tuple[int, ...]:
""" Return the system's default primary monitor rectangle bounds. """
return [rect for rect in get_display_rects() if rect[:2] == (0, 0)][
0
] # primary monitor has top left as 0, 0
def resource_path(relative_path) -> Union[int, bytes]:
""" Get absolute path to resource, works for dev and for PyInstaller """
try:
# PyInstaller creates a temp folder and stores path in _MEIPASS
base_path = sys._MEIPASS # pylint: disable=protected-access,no-member
except Exception: # pylint: disable=broad-except
base_path = os.path.abspath("../")
return os.path.join(base_path, relative_path)
def get_display_rects():
""" Return a list of tuples of monitor rectangles. """
with mss.mss() as sct:
return [tuple(m.values()) for m in sct.monitors]
================================================
FILE: requirements-dev.txt
================================================
pre-commit
black
coverage
================================================
FILE: requirements.txt
================================================
keyboard
mouse
Pillow
git+https://github.com/samclane/lifxlan@master#egg=lifxlan
numexpr
numpy
mss
pystray
pywin32
# pyaudio
================================================
FILE: setup.cfg
================================================
# Inside of setup.cfg
[metadata]
description-file = README.md
================================================
FILE: setup.py
================================================
from setuptools import setup, find_packages
from lifx_control_panel._constants import VERSION
with open("README.md", "r") as f:
long_description = f.read()
setup(
name="lifx_control_panel",
version=str(VERSION),
description="An open source application for controlling your LIFX brand lights",
url="http://github.com/samclane/LIFX-Control-Panel",
author="Sawyer McLane",
author_email="samclane@gmail.com",
license="MIT",
packages=find_packages(),
zip_safe=False,
scripts=["lifx_control_panel/__main__.pyw"],
include_package_data=True,
keywords=["lifx", "iot", "smartbulb", "smartlight", "lan", "application"],
install_requires=[
"keyboard",
"mouse",
"pyaudio",
"Pillow",
"lifxlan",
"numexpr",
"numpy",
"mss",
"pystray",
],
download_url=(
("https://github.com/samclane/LIFX-Control-Panel/archive/" + str(VERSION))
+ ".tar.gz"
),
long_description_content_type="text/markdown",
long_description=long_description,
classifiers=[
# How mature is this project? Common values are
# 3 - Alpha
# 4 - Beta
# 5 - Production/Stable
"Development Status :: 5 - Production/Stable",
# Indicate who your project is intended for
"Intended Audience :: End Users/Desktop",
"Natural Language :: English",
"Operating System :: Microsoft :: Windows :: Windows 10",
"Operating System :: Microsoft :: Windows :: Windows 8.1",
"Operating System :: Microsoft :: Windows :: Windows 8",
"Operating System :: Microsoft :: Windows :: Windows 7",
"Topic :: Home Automation",
# Pick your license as you wish (should match "license" above)
"License :: OSI Approved :: MIT License",
# Specify the Python versions you support here. In particular, ensure
# that you indicate whether you support Python 2, Python 3 or both.
"Programming Language :: Python :: 3.8",
],
)