Full Code of samclane/LIFX-Control-Panel for AI

master 23bae89e8e6e cached
37 files
138.9 KB
34.3k tokens
280 symbols
1 requests
Download .txt
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

[![codecov](https://codecov.io/gh/samclane/LIFX-Control-Panel/branch/master/graph/badge.svg?token=GLAxucmOo6)](https://codecov.io/gh/samclane/LIFX-Control-Panel)
![Smoke Build And Test](https://github.com/samclane/LIFX-Control-Panel/actions/workflows/main.yml/badge.svg?event=push&branch=master)

<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):

[![Avg Test Youtube](https://img.youtube.com/vi/C-jZISM9MC0/0.jpg)](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>

[![paypal](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](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",
    ],
)
Download .txt
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
Download .txt
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.

Copied to clipboard!