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)

<img align="right" width="120" height="120" title="LIFX-Control-Panel Logo" src="./res/lifx-animated-logo.gif">
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
<p align="center">
<img src="./res/screenshot.png" alt="Screenshot" width="306" height=731>
</p>
#### 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!
<a href='https://ko-fi.com/J3J8LZKP' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://az743702.vo.msecnd.net/cdn/kofi3.png?v=0' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a>
[](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('<Button-1>', 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('<Button-1>', 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('<Unmap>', 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('<Unmap>') # 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('<Unmap>', 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="<LABEL_ERR>",
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 = "<No Group Found>"
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("<Configure>", lambda _: self._draw_gradient(val))
self.bind("<ButtonPress-1>", self._on_click)
# self.bind('<ButtonRelease-1>', self._on_release)
self.bind("<B1-Motion>", 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("<<HueChanged>>")
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("<Return>", self.ok)
self.bind("<Escape>", 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("<B1-Motion>", lambda e, s=self: s._select(e.y))
list_box.bind("<Button-1>", lambda e, s=self: s._select(e.y))
list_box.bind("<Leave>", lambda e: "break")
list_box.bind("<B2-Motion>", lambda e, s=self: s._b2motion(e.x, e.y))
list_box.bind("<Button-2>", 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("<FocusIn>", self.on_keybind_keys_click)
self.keybind_keys_select.bind(
"<FocusOut>", 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 = "<LABEL-ERR>"
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",
],
)
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
SYMBOL INDEX (280 symbols across 13 files)
FILE: lifx_control_panel/frames.py
class LightFrame (line 49) | class LightFrame(ttk.Labelframe): # pylint: disable=too-many-ancestors
method __init__ (line 79) | def __init__(self, master, target: lifxlan.Device):
method _get_light_info (line 137) | def _get_light_info(self, target: lifxlan.Device) -> Tuple[int, Color]:
method _setup_screen_region_select (line 164) | def _setup_screen_region_select(self):
method _grid_horiz_coordinate_box (line 196) | def _grid_horiz_coordinate_box(self, text: str, row, arg2):
method _setup_special_functions (line 205) | def _setup_special_functions(self):
method _setup_color_dropdowns (line 293) | def _setup_color_dropdowns(self):
method setup_color_controls (line 333) | def setup_color_controls(self, init_color: Color):
method setup_power_controls (line 453) | def setup_power_controls(self, bulb_power: int):
method _setup_logger (line 480) | def _setup_logger(self):
method restart (line 492) | def restart(self):
method get_label (line 497) | def get_label(self):
method trigger_icon_update (line 501) | def trigger_icon_update(self, *_, **__):
method get_color_values_hsbk (line 505) | def get_color_values_hsbk(self):
method stop_threads (line 509) | def stop_threads(self):
method update_power (line 518) | def update_power(self):
method update_color_from_ui (line 523) | def update_color_from_ui(self, *_, **__):
method set_color (line 528) | def set_color(self, color, rapid=False):
method update_label (line 547) | def update_label(self, key: int):
method update_display (line 562) | def update_display(self, key: int):
method get_color_from_palette (line 593) | def get_color_from_palette(self):
method update_status_from_bulb (line 604) | def update_status_from_bulb(self, run_once=False):
method eyedropper (line 644) | def eyedropper(self, *_, **__):
method change_preset_dropdown (line 669) | def change_preset_dropdown(self, *_, **__):
method change_user_dropdown (line 678) | def change_user_dropdown(self, *_, **__):
method update_user_dropdown (line 687) | def update_user_dropdown(self):
method get_monitor_bounds (line 697) | def get_monitor_bounds(self):
method save_monitor_bounds (line 704) | def save_monitor_bounds(self):
class GroupFrame (line 712) | class GroupFrame(LightFrame):
method _get_light_info (line 713) | def _get_light_info(self, target: lifxlan.Group) -> Tuple[int, Color]:
method update_status_from_bulb (line 753) | def update_status_from_bulb(self, run_once=False):
class MultiZoneFrame (line 757) | class MultiZoneFrame(LightFrame):
FILE: lifx_control_panel/test/dummy_devices.py
class DummyDevice (line 28) | class DummyDevice:
method __init__ (line 29) | def __init__(self, label="No label"):
method is_light (line 86) | def is_light(self):
method set_label (line 89) | def set_label(self, val: str):
method set_power (line 92) | def set_power(self, val: bool, rapid: bool = False):
method get_mac_address (line 96) | def get_mac_address(self):
method get_ip_addr (line 99) | def get_ip_addr(self):
method get_service (line 102) | def get_service(self):
method get_port (line 105) | def get_port(self):
method get_label (line 108) | def get_label(self):
method get_power (line 111) | def get_power(self):
method get_host_firmware_tuple (line 114) | def get_host_firmware_tuple(self):
method get_host_firmware_build_timestamp (line 117) | def get_host_firmware_build_timestamp(self):
method get_host_firmware_version (line 120) | def get_host_firmware_version(self):
method get_wifi_info_tuple (line 123) | def get_wifi_info_tuple(self):
method get_wifi_signal_mw (line 126) | def get_wifi_signal_mw(self):
method get_wifi_tx_bytes (line 129) | def get_wifi_tx_bytes(self):
method get_wifi_rx_bytes (line 132) | def get_wifi_rx_bytes(self):
method get_wifi_firmware_tuple (line 135) | def get_wifi_firmware_tuple(self):
method get_wifi_firmware_build_timestamp (line 138) | def get_wifi_firmware_build_timestamp(self):
method get_wifi_firmware_version (line 141) | def get_wifi_firmware_version(self):
method get_version_tuple (line 144) | def get_version_tuple(self):
method get_location (line 147) | def get_location(self):
method get_location_tuple (line 150) | def get_location_tuple(self):
method get_location_label (line 153) | def get_location_label(self):
method get_location_updated_at (line 156) | def get_location_updated_at(self):
method get_group (line 159) | def get_group(self):
method get_group_tuple (line 162) | def get_group_tuple(self):
method get_group_label (line 165) | def get_group_label(self):
method get_group_updated_a (line 168) | def get_group_updated_a(self):
method get_vendor (line 171) | def get_vendor(self):
method get_product (line 174) | def get_product(self):
method get_version (line 177) | def get_version(self):
method get_info_tuple (line 180) | def get_info_tuple(self):
method get_time (line 183) | def get_time(self):
method get_uptime (line 186) | def get_uptime(self):
method get_downtime (line 189) | def get_downtime(self): # no way to make this work. Shouldn't need it
method supports_color (line 192) | def supports_color(self):
method supports_temperature (line 195) | def supports_temperature(self):
method supports_multizone (line 198) | def supports_multizone(self):
method supports_infrared (line 201) | def supports_infrared(self):
class DummyBulb (line 205) | class DummyBulb(DummyDevice):
method __init__ (line 206) | def __init__(self, color=None, label="N/A"):
method power_level (line 213) | def power_level(self):
method power_level (line 217) | def power_level(self, val):
method set_power (line 220) | def set_power(self, val: int, duration: int = 0, rapid: bool = False):
method set_color (line 223) | def set_color(self, val: DummyColor, duration: int = 0, rapid: bool = ...
method set_waveform (line 227) | def set_waveform(self, is_transient, color, period, cycles, duty_cycle...
method get_power (line 230) | def get_power(self):
method get_color (line 233) | def get_color(self):
method get_infared (line 236) | def get_infared(self):
method set_infared (line 239) | def set_infared(self, val: int):
method set_hue (line 242) | def set_hue(self, hue, duration=0, rapid=False):
method set_brightness (line 245) | def set_brightness(self, brightness, duration=0, rapid=False):
method set_saturation (line 248) | def set_saturation(self, saturation, duration=0, rapid=False):
method set_colortemp (line 251) | def set_colortemp(self, kelvin, duration=0, rapid=False):
class MultiZoneDummy (line 255) | class MultiZoneDummy(DummyBulb):
method __init__ (line 256) | def __init__(self, color=DummyColor(0, 0, 0, 1500), label="N/A"):
method get_color_zones (line 261) | def get_color_zones(self, start=0, end=0):
method set_zone_color (line 264) | def set_zone_color(self, start, end, color, duration=0, rapid=False, a...
method set_zone_colors (line 267) | def set_zone_colors(self, colors, duration=0, rapid=False):
class TileDummy (line 271) | class TileDummy(DummyBulb):
class TileChainDummy (line 275) | class TileChainDummy(DummyBulb):
method __init__ (line 276) | def __init__(self, color=DummyColor(0, 0, 0, 1500), label="N/A", x=1, ...
method get_tile_info (line 283) | def get_tile_info(self, refresh_cache=False):
method get_tile_count (line 289) | def get_tile_count(self, refresh_cache=False):
method get_tile_colors (line 295) | def get_tile_colors(self, start_index, tile_count=0, x=0, y=0, width=0):
method set_tile_colors (line 301) | def set_tile_colors(
method get_tilechain_colors (line 317) | def get_tilechain_colors(self):
method set_tilechain_colors (line 320) | def set_tilechain_colors(self, tilechain_colors, duration=0, rapid=Fal...
method project_matrix (line 324) | def project_matrix(self, hsvk_matrix, duration, rapid):
method get_canvas_dimensions (line 327) | def get_canvas_dimensions(self, refresh_cache):
method recenter_coordinates (line 330) | def recenter_coordinates(self):
method set_tile_coordinates (line 333) | def set_tile_coordinates(self, tile_index, x, y):
method get_tile_map (line 336) | def get_tile_map(self, refresh_cache):
class LifxLANDummy (line 340) | class LifxLANDummy:
method __init__ (line 341) | def __init__(self, verbose=False):
method add_dummy_light (line 345) | def add_dummy_light(self, light: DummyBulb):
method get_lights (line 349) | def get_lights(self):
method get_color_lights (line 352) | def get_color_lights(self):
method get_infrared_lights (line 355) | def get_infrared_lights(self):
method get_multizone_lights (line 360) | def get_multizone_lights(self):
method get_tilechain_lights (line 365) | def get_tilechain_lights(self):
method get_device_by_name (line 370) | def get_device_by_name(self, name: str):
method get_devices_by_names (line 373) | def get_devices_by_names(self, names: Iterable[str]):
method get_devices_by_group (line 378) | def get_devices_by_group(self, group_id):
method get_devices_by_location (line 383) | def get_devices_by_location(self, location: str):
method set_power_all_lights (line 392) | def set_power_all_lights(self, power, duration=0, rapid=False):
method set_color_all_lights (line 396) | def set_color_all_lights(self, color, duration=0, rapid=False):
method set_waveform_all_lights (line 400) | def set_waveform_all_lights(
method get_power_all_lights (line 408) | def get_power_all_lights(self):
method get_color_all_lights (line 411) | def get_color_all_lights(self):
class DummyGroup (line 415) | class DummyGroup:
method __init__ (line 416) | def __init__(self, devices: list, label: str = "N/A"):
method __iter__ (line 420) | def __iter__(self):
method add_device (line 423) | def add_device(self, device: DummyDevice):
method remove_device (line 426) | def remove_device(self, device: DummyDevice):
method remove_device_by_name (line 429) | def remove_device_by_name(self, device_name: str):
method get_device_list (line 435) | def get_device_list(self):
method set_power (line 438) | def set_power(self, power, duration=0, rapid=False):
method set_color (line 442) | def set_color(self, color, duration=0, rapid=False):
method set_hue (line 446) | def set_hue(self, hue, duration=0, rapid=False):
method set_brightness (line 450) | def set_brightness(self, brightness, duration=0, rapid=False):
method set_saturation (line 454) | def set_saturation(self, saturation, duration=0, rapid=False):
method set_colortemp (line 458) | def set_colortemp(self, kelvin, duration=0, rapid=False):
method set_infrared (line 462) | def set_infrared(self, infrared, duration=0, rapid=False):
method set_zone_color (line 466) | def set_zone_color(self, start, end, color, duration=0, rapid=False, a...
method set_zone_colors (line 470) | def set_zone_colors(self, colors, duration=0, rapid=False):
function main (line 475) | def main():
FILE: lifx_control_panel/test/dummy_tests.py
class TestLAN (line 7) | class TestLAN(unittest.TestCase):
method setUp (line 8) | def setUp(self):
method test_add_lights (line 12) | def test_add_lights(self):
method test_set_color_all_lights (line 18) | def test_set_color_all_lights(self):
method test_set_power_all_lights (line 24) | def test_set_power_all_lights(self):
class TestDevice (line 31) | class TestDevice(unittest.TestCase):
method setUp (line 32) | def setUp(self):
method test_set_label (line 35) | def test_set_label(self):
class TestBulb (line 44) | class TestBulb(unittest.TestCase):
method setUp (line 45) | def setUp(self):
method test_set_label (line 48) | def test_set_label(self):
method test_power_duration (line 56) | def test_power_duration(self):
method test_color_duration (line 66) | def test_color_duration(self):
FILE: lifx_control_panel/test/functional_test.py
class TestFunctions (line 12) | class TestFunctions(unittest.TestCase):
method setUp (line 13) | def setUp(self):
method _cmp_color (line 16) | def _cmp_color(self, c, h, s, b, k):
method test_color (line 22) | def test_color(self):
method test_conversion (line 44) | def test_conversion(self):
method test_str_conversion (line 52) | def test_str_conversion(self):
FILE: lifx_control_panel/ui/colorscale.py
class ColorScale (line 8) | class ColorScale(tk.Canvas):
method __init__ (line 13) | def __init__(
method _draw_gradient (line 67) | def _draw_gradient(self, val):
method _on_click (line 128) | def _on_click(self, event):
method update_slider_value (line 135) | def update_slider_value(self, width, x_coord):
method _on_move (line 144) | def _on_move(self, event):
method _update_val (line 152) | def _update_val(self, *_):
method get (line 158) | def get(self):
method set (line 164) | def set(self, val):
FILE: lifx_control_panel/ui/icon_list.py
class BulbIconListSettings (line 13) | class BulbIconListSettings:
method __post_init__ (line 23) | def __post_init__(self):
class BulbIconList (line 31) | class BulbIconList(tkinter.Frame): # pylint: disable=too-many-instance-...
method __init__ (line 34) | def __init__(self, *args, is_group: bool = False, **kwargs):
method current_icon (line 78) | def current_icon(self):
method icon_path (line 82) | def icon_path(self):
method icon_paths (line 91) | def icon_paths(self) -> Dict[type, Union[int, bytes]]:
method draw_bulb_icon (line 99) | def draw_bulb_icon(self, bulb, label):
method update_icon (line 130) | def update_icon(self, bulb: lifxlan.Device):
method set_selected_bulb (line 188) | def set_selected_bulb(self, light_name):
method clear_selected (line 211) | def clear_selected(self):
FILE: lifx_control_panel/ui/settings.py
class Dialog (line 56) | class Dialog(Toplevel):
method __init__ (line 59) | def __init__(self, parent, title=None):
method body (line 79) | def body(self, master):
method buttonbox (line 82) | def buttonbox(self):
method ok (line 96) | def ok(self, _=None): # pylint: disable=invalid-name
method cancel (line 106) | def cancel(self, _=None):
method validate (line 113) | def validate(self): # pylint: disable=no-self-use
method apply (line 117) | def apply(self):
class MultiListbox (line 121) | class MultiListbox(Frame): # pylint: disable=too-many-ancestors
method __init__ (line 125) | def __init__(self, master, lists):
method _select (line 154) | def _select(self, y): # pylint: disable=invalid-name
method _button2 (line 161) | def _button2(self, x, y): # pylint: disable=invalid-name
method _b2motion (line 166) | def _b2motion(self, x, y): # pylint: disable=invalid-name
method _scroll (line 171) | def _scroll(self, *args):
method curselection (line 176) | def curselection(self):
method delete (line 180) | def delete(self, first, last=None):
method get (line 185) | def get(self, first, last=None):
method index (line 192) | def index(self, index):
method insert (line 196) | def insert(self, index, *elements):
method size (line 202) | def size(self):
method see (line 206) | def see(self, index):
method selection_anchor (line 211) | def selection_anchor(self, index):
method selection_clear (line 215) | def selection_clear(self, first, last=None):
method selection_includes (line 220) | def selection_includes(self, index):
method selection_set (line 224) | def selection_set(self, first, last=None):
class SettingsDisplay (line 230) | class SettingsDisplay(Dialog):
method body (line 233) | def body(self, master):
method validate (line 375) | def validate(self) -> int:
method get_color (line 389) | def get_color(self):
method register_keybinding (line 397) | def register_keybinding(self, bulb: str, keys: str, color: str):
method on_keybind_keys_click (line 414) | def on_keybind_keys_click(self, event):
method delete_keybind (line 428) | def delete_keybind(self):
FILE: lifx_control_panel/ui/splashscreen.py
class Splash (line 9) | class Splash:
method __init__ (line 12) | def __init__(self, root, file):
method __enter__ (line 20) | def __enter__(self):
method __exit__ (line 50) | def __exit__(self, exc_type, exc_val, exc_tb):
FILE: lifx_control_panel/utilities/async_bulb_interface.py
class AsyncBulbInterface (line 11) | class AsyncBulbInterface(threading.Thread):
method __init__ (line 14) | def __init__(self, event, heartbeat_ms):
method set_device_list (line 29) | def set_device_list(
method query_device (line 59) | def query_device(self, target):
method run (line 73) | def run(self):
FILE: lifx_control_panel/utilities/audio.py
class AudioInterface (line 29) | class AudioInterface:
method __init__ (line 33) | def __init__(self):
method init_audio (line 40) | def init_audio(self, config):
method init_configured_device (line 53) | def init_configured_device(self, config):
method get_stereo_mix_index (line 77) | def get_stereo_mix_index(self):
method get_device_names (line 92) | def get_device_names(self):
method get_music_color (line 100) | def get_music_color(self, initial_color, alpha=0.99):
FILE: lifx_control_panel/utilities/color_thread.py
function get_monitor_bounds (line 28) | def get_monitor_bounds(func):
function get_screen_as_image (line 34) | def get_screen_as_image():
function get_rect_as_image (line 49) | def get_rect_as_image(bounds: Tuple[int, int, int, int]):
function normalize_rectangles (line 62) | def normalize_rectangles(rects: List[Tuple[int, int, int, int]]):
class ColorCycle (line 72) | class ColorCycle:
method __init__ (line 73) | def __init__(self):
method get_color (line 79) | def get_color(self, *args, **kwargs):
method __call__ (line 88) | def __call__(self, initial_color):
method __name__ (line 92) | def __name__(self):
function avg_screen_color (line 96) | def avg_screen_color(initial_color, func_bounds=lambda: None):
function dominant_screen_color (line 108) | def dominant_screen_color(initial_color, func_bounds=lambda: None):
class ColorThread (line 138) | class ColorThread(threading.Thread):
method __init__ (line 141) | def __init__(self, *args, **kwargs):
method stop (line 145) | def stop(self):
method stopped (line 149) | def stopped(self):
class ColorThreadRunner (line 154) | class ColorThreadRunner:
method __init__ (line 157) | def __init__(self, bulb, color_function, parent, continuous=True, **kw...
method match_color (line 177) | def match_color(self, bulb):
method start (line 201) | def start(self):
method stop (line 212) | def stop(self):
method get_duration (line 217) | def get_duration():
method get_brightness_offset (line 222) | def get_brightness_offset():
function install_thread_excepthook (line 227) | def install_thread_excepthook():
FILE: lifx_control_panel/utilities/keypress.py
class KeybindManager (line 12) | class KeybindManager:
method __init__ (line 15) | def __init__(self, master, sticky=False):
method key_combo_code (line 24) | def key_combo_code(self) -> str:
method register_function (line 28) | def register_function(self, key_combo, function):
method unregister_function (line 38) | def unregister_function(self, key_combo):
method _on_key_down (line 45) | def _on_key_down(self, event: keyboard.KeyboardEvent):
method _on_key_up (line 53) | def _on_key_up(self, event: keyboard.KeyboardEvent):
method shutdown (line 58) | def shutdown(self):
method restart (line 62) | def restart(self):
FILE: lifx_control_panel/utilities/utils.py
class Color (line 12) | class Color:
method __init__ (line 17) | def __init__(self, hue: int, saturation: int, brightness: int, kelvin:...
method __getitem__ (line 23) | def __getitem__(self, item) -> int:
method __len__ (line 26) | def __len__(self) -> int:
method __setitem__ (line 29) | def __setitem__(self, key, value):
method __str__ (line 32) | def __str__(self) -> str:
method __repr__ (line 35) | def __repr__(self) -> str:
method __eq__ (line 38) | def __eq__(self, other) -> bool:
method __add__ (line 46) | def __add__(self, other):
method __sub__ (line 54) | def __sub__(self, other):
method __iter__ (line 57) | def __iter__(self):
function hsbk_to_rgb (line 66) | def hsbk_to_rgb(hsvk: TypeHSBK) -> TypeRGB:
function hsv_to_rgb (line 129) | def hsv_to_rgb(h: float, s: float = 1, v: float = 1) -> TypeRGB:
function kelvin_to_rgb (line 159) | def kelvin_to_rgb(temperature: int) -> TypeRGB:
function tuple2hex (line 189) | def tuple2hex(tuple_: TypeRGB) -> str:
function str2list (line 194) | def str2list(string: str, type_func) -> List:
function str2tuple (line 199) | def str2tuple(string: str, type_func) -> Tuple:
function get_primary_monitor (line 206) | def get_primary_monitor() -> Tuple[int, ...]:
function resource_path (line 213) | def resource_path(relative_path) -> Union[int, bytes]:
function get_display_rects (line 224) | def get_display_rects():
Condensed preview — 37 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (150K chars).
[
{
"path": ".github/FUNDING.yml",
"chars": 585,
"preview": "# These are supported funding model platforms\n\ngithub: [samclane]\npatreon: # Replace with a single Patreon username\nopen"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.md",
"chars": 911,
"preview": "---\nname: Bug report\nabout: Create a report to help us improve\n\n---\n\n**Describe the bug**\nA clear and concise descriptio"
},
{
"path": ".github/ISSUE_TEMPLATE/feature_request.md",
"chars": 723,
"preview": "---\nname: Feature request\nabout: Suggest an idea for this project\n\n---\n\n**Is your feature request related to a problem? "
},
{
"path": ".github/stale.yml",
"chars": 902,
"preview": "# Number of days of inactivity before an issue becomes stale\ndaysUntilStale: 60\n# Number of days of inactivity before a "
},
{
"path": ".github/workflows/main.yml",
"chars": 2756,
"preview": "name: Smoke Build And Test\n\non:\n push:\n branches:\n - master\n pull_request:\n branches:\n - master\n\njobs:"
},
{
"path": ".gitignore",
"chars": 2152,
"preview": "# Created by .ignore support plugin (hsz.mobi)\n### Python template\n# Byte-compiled / optimized / DLL files\n__pycache__/\n"
},
{
"path": ".pre-commit-config.yaml",
"chars": 123,
"preview": "repos:\n- repo: https://github.com/ambv/black\n rev: stable\n hooks:\n - id: black\n language_version: python"
},
{
"path": "CODE_OF_CONDUCT.md",
"chars": 3215,
"preview": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nIn the interest of fostering an open and welcoming environment, w"
},
{
"path": "LICENSE.txt",
"chars": 1070,
"preview": "MIT License\n\nCopyright (c) 2018 Sawyer McLane\n\nPermission is hereby granted, free of charge, to any person obtaining a c"
},
{
"path": "MANIFEST.in",
"chars": 42,
"preview": "recursive-include lifx_control_panel/res *"
},
{
"path": "README.md",
"chars": 4964,
"preview": "## Development is now being continued on the new [Mantle](https://github.com/samclane/mantle) project\n\n# LIFX-Control-Pa"
},
{
"path": "build_all.bat",
"chars": 98,
"preview": "cd .\\lifx_control_panel\nset PYTHONOPTIMIZE=1 && pyinstaller --onefile --noupx build_all.spec\ncd .."
},
{
"path": "codecov.yml",
"chars": 11,
"preview": "codecov.yml"
},
{
"path": "default.ini",
"chars": 181,
"preview": "[AppSettings]\nstart_minimized = False\n\n[AverageColor]\ndefaultmonitor = get_primary_monitor()\nduration = 0.07\nbrightnesso"
},
{
"path": "lifx_control_panel/__init__.py",
"chars": 248,
"preview": "import os\nimport sys\n\nRED = [0, 65535, 65535, 3500] # Fixes RED from appearing BLACK\nHEARTBEAT_RATE_MS = 3000 # 3 seco"
},
{
"path": "lifx_control_panel/__main__.pyw",
"chars": 16486,
"preview": "# -*- coding: utf-8 -*-\n\"\"\"Main lifx_control_panel GUI control\n\nThis module contains several ugly God-classes that contr"
},
{
"path": "lifx_control_panel/_constants.py",
"chars": 103,
"preview": "VERSION = \"2.3.0\"\nBUILD_DATE = \"2022-12-12T06:22:59.747968\"\nAUTHOR = \"Sawyer McLane\"\nDEBUGGING = False\n"
},
{
"path": "lifx_control_panel/frames.py",
"chars": 28311,
"preview": "import logging\nimport tkinter\nfrom tkinter import ttk, font as font, messagebox, _setit\nfrom typing import Union, List, "
},
{
"path": "lifx_control_panel/test/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "lifx_control_panel/test/dummy_devices.py",
"chars": 14910,
"preview": "\"\"\" This is not a standard test file (yet), but used to simulate a multi-device environment. \"\"\"\n\nimport logging\nimport "
},
{
"path": "lifx_control_panel/test/dummy_tests.py",
"chars": 2817,
"preview": "import unittest\n\nfrom test.dummy_devices import *\nfrom utilities.utils import Color\n\n\nclass TestLAN(unittest.TestCase):\n"
},
{
"path": "lifx_control_panel/test/functional_test.py",
"chars": 1526,
"preview": "import unittest\nfrom utilities.utils import (\n Color,\n hsbk_to_rgb,\n hsv_to_rgb,\n tuple2hex,\n str2list,\n "
},
{
"path": "lifx_control_panel/ui/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "lifx_control_panel/ui/colorscale.py",
"chars": 5918,
"preview": "import logging\nimport tkinter as tk\nfrom typing import List\n\nfrom ..utilities.utils import tuple2hex, hsv_to_rgb, kelvin"
},
{
"path": "lifx_control_panel/ui/icon_list.py",
"chars": 9169,
"preview": "from __future__ import annotations\nfrom dataclasses import dataclass, field\n\nimport tkinter\nfrom typing import Dict, Uni"
},
{
"path": "lifx_control_panel/ui/settings.py",
"chars": 16019,
"preview": "# -*- coding: utf-8 -*-\n\"\"\"UI Logic and Interface Elements for Settings\n\nThis module contains several ugly God-classes t"
},
{
"path": "lifx_control_panel/ui/splashscreen.py",
"chars": 2034,
"preview": "# -*- coding: utf-8 -*-\n\"\"\"Splash-screen class\n\nDisplays lifx_control_panel's icon while GUI loads\n\"\"\"\nfrom tkinter impo"
},
{
"path": "lifx_control_panel/utilities/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "lifx_control_panel/utilities/async_bulb_interface.py",
"chars": 2846,
"preview": "# -*- coding: utf-8 -*-\nimport concurrent.futures\nimport logging\nimport queue\nimport threading\nfrom typing import List, "
},
{
"path": "lifx_control_panel/utilities/audio.py",
"chars": 4171,
"preview": "# -*- coding: utf-8 -*-\n\"\"\"Audio Processing Tools\n\nTools for real-time audio processing and color-following. For co-use "
},
{
"path": "lifx_control_panel/utilities/color_thread.py",
"chars": 8914,
"preview": "# -*- coding: utf-8 -*-\n\"\"\"Multi-Threaded Color Changer\n\nContains several basic \"Color-Following\" functions, as well as "
},
{
"path": "lifx_control_panel/utilities/keypress.py",
"chars": 2256,
"preview": "# -*- coding: utf-8 -*-\n\"\"\"Keyboard shortcut interface\n\nContains single class for interfacing with user IO and binding t"
},
{
"path": "lifx_control_panel/utilities/utils.py",
"chars": 6512,
"preview": "# -*- coding: utf-8 -*-\n\"\"\"General utility classes and functions.\"\"\"\nimport os\nimport sys\nfrom functools import lru_cach"
},
{
"path": "requirements-dev.txt",
"chars": 26,
"preview": "pre-commit\nblack\ncoverage\n"
},
{
"path": "requirements.txt",
"chars": 124,
"preview": "keyboard\nmouse\nPillow\ngit+https://github.com/samclane/lifxlan@master#egg=lifxlan\nnumexpr\nnumpy\nmss\npystray\npywin32\n# pya"
},
{
"path": "setup.cfg",
"chars": 61,
"preview": "# Inside of setup.cfg\n[metadata]\ndescription-file = README.md"
},
{
"path": "setup.py",
"chars": 2055,
"preview": "from setuptools import setup, find_packages\n\nfrom lifx_control_panel._constants import VERSION\n\nwith open(\"README.md\", \""
}
]
About this extraction
This page contains the full source code of the samclane/LIFX-Control-Panel GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 37 files (138.9 KB), approximately 34.3k tokens, and a symbol index with 280 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.