[
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: [samclane]\npatreon: # Replace with a single Patreon username\nopen_collective: # Replace with a single Open Collective username\nko_fi: sawyermclane\ntidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel\ncommunity_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry\nliberapay: # Replace with a single Liberapay username\nissuehunt: # Replace with a single IssueHunt username\notechie: # Replace with a single Otechie username\ncustom: ['https://www.paypal.me/sawyermclane']\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is. Please include any error messages you see. \n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Desktop (please complete the following information):**\n - Lifx-Control-Panel Version: [Found in the \"About\" menu. e.g. \"1.2.0\"]\n - OS: [e.g. Windows 7, Windows 10, Plan 9]\n\n**Please attach your logfile (lifx-control-panel.log)**\nAttempt to reproduce the problem, then attach your `lifx_ctrl.log` file. This will give us the most information about what went wrong.\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Does an existing product have this feature?**\nIf so, please provide the source. Providing a live example will make capturing the desired behavior much easier. \n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/stale.yml",
    "content": "# Number of days of inactivity before an issue becomes stale\ndaysUntilStale: 60\n# Number of days of inactivity before a stale issue is closed\ndaysUntilClose: 7\n# Issues with these labels will never be considered stale\nexemptLabels:\n  - pinned\n  - security\n  - help wanted\n# Label to use when marking an issue as stale\nstaleLabel: wontfix\n# Comment to post when marking an issue as stale. Set to `false` to disable\nmarkComment: >\n  This issue has been automatically marked as stale because it has not had\n  recent activity. It will be closed if no further activity occurs. Thank you\n  for your contributions.\n# Comment to post when closing a stale issue. Set to `false` to disable\ncloseComment: >\n  This issue has been automatically marked as closed because it has not had\n  any further activity since being marked `stale`. Please contact the repository\n  owner if you think this is in error. Thank you."
  },
  {
    "path": ".github/workflows/main.yml",
    "content": "name: Smoke Build And Test\n\non:\n  push:\n    branches:\n      - master\n  pull_request:\n    branches:\n      - master\n\njobs:\n  smoke-build:\n    runs-on: windows-latest\n    steps:\n      - uses: actions/checkout@v2\n      - name: Set up Python 3.8\n        uses: actions/setup-python@v1\n        with:\n          python-version: '3.8.x' # Semantic version range syntax or exact version of a Python version\n          architecture: 'x64'\n      - name: Cache pip\n        uses: actions/cache@v1\n        with:\n          path: ~/.cache/pip # This path is specific to Ubuntu\n          # Look to see if there is a cache hit for the corresponding requirements file\n          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}\n          restore-keys: |\n            ${{ runner.os }}-pip-\n            ${{ runner.os }}-\n      - name: Install swig\n        env:\n          ACTIONS_ALLOW_UNSECURE_COMMANDS: true\n        run: |\n          (New-Object System.Net.WebClient).DownloadFile(\"http://prdownloads.sourceforge.net/swig/swigwin-4.0.1.zip\",\"swigwin-4.0.1.zip\");\n          Expand-Archive .\\swigwin-4.0.1.zip .;\n          echo \"::add-path::./swigwin-4.0.1\"\n      - name: Check swig\n        run: swig -version\n      - name: Install python and deps\n        run: |\n          python -m pip install --upgrade pip\n          pip install -r requirements.txt\n          pip install pyinstaller==4.8\n      - name: Build Project\n        run: |\n          cd ./lifx_control_panel\n          set PYTHONOPTIMIZE=1 && pyinstaller --onefile --noupx build_all.spec\n          cd ..\n  \n  test:\n    runs-on: windows-latest\n    steps:\n      - uses: actions/checkout@v2\n      - name: Set up Python 3.8\n        uses: actions/setup-python@v1\n        with:\n          python-version: '3.8.x' # Semantic version range syntax or exact version of a Python version\n          architecture: 'x64'\n      - name: Cache pip\n        uses: actions/cache@v1\n        with:\n          path: ~/.cache/pip # This path is specific to Ubuntu\n          # Look to see if there is a cache hit for the corresponding requirements file\n          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}\n          restore-keys: |\n            ${{ runner.os }}-pip-\n            ${{ runner.os }}-\n      - name: Test Project\n        run: |\n          pip3 install --user -r requirements.txt\n          pip3 install --user -r requirements-dev.txt\n          cd ./lifx_control_panel\n          set PYTHONPATH=.\n          coverage run -m unittest discover test -p \"*test*.py\"\n          coverage report\n          coverage xml -o coverage.xml\n          cd ..\n      - name: Upload Coverage to Codecov\n        uses: codecov/codecov-action@v2\n        with:\n          files: ./lifx_control_panel/coverage.xml\n          flags: unittests"
  },
  {
    "path": ".gitignore",
    "content": "# Created by .ignore support plugin (hsz.mobi)\n### Python template\n# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\npip-wheel-metadata/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.py,cover\n.hypothesis/\n.pytest_cache/\ncover/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\ndb.sqlite3-journal\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\n.pybuilder/\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# IPython\nprofile_default/\nipython_config.py\n\n# pyenv\n#   For a library or package, you might want to ignore these files since the code is\n#   intended to run in multiple environments; otherwise, check them in:\n# .python-version\n\n# pipenv\n#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.\n#   However, in case of collaboration, if having platform-specific dependencies or dependencies\n#   having no cross-platform support, pipenv may install dependencies that don't work, or not\n#   install all needed dependencies.\n#Pipfile.lock\n\n# PEP 582; used by e.g. github.com/David-OConnor/pyflow\n__pypackages__/\n\n# Celery stuff\ncelerybeat-schedule\ncelerybeat.pid\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.env\n.venv\nenv/\nvenv/\nvenv*/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Pyre type checker\n.pyre/\n\n# pytype static type analyzer\n.pytype/\n\n# Cython debug symbols\ncython_debug/\n\n# IDE\n.idea/\n\nlifxlan/"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "repos:\n-   repo: https://github.com/ambv/black\n    rev: stable\n    hooks:\n    - id: black\n      language_version: python3.8"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nIn 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.\n\n## Our Standards\n\nExamples of behavior that contributes to creating a positive environment include:\n\n* Using welcoming and inclusive language\n* Being respectful of differing viewpoints and experiences\n* Gracefully accepting constructive criticism\n* Focusing on what is best for the community\n* Showing empathy towards other community members\n\nExamples of unacceptable behavior by participants include:\n\n* The use of sexualized language or imagery and unwelcome sexual attention or advances\n* Trolling, insulting/derogatory comments, and personal or political attacks\n* Public or private harassment\n* Publishing others' private information, such as a physical or electronic address, without explicit permission\n* Other conduct which could reasonably be considered inappropriate in a professional setting\n\n## Our Responsibilities\n\nProject 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.\n\nProject 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.\n\n## Scope\n\nThis 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.\n\n## Enforcement\n\nInstances 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.\n\nProject 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.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]\n\n[homepage]: http://contributor-covenant.org\n[version]: http://contributor-covenant.org/version/1/4/\n"
  },
  {
    "path": "LICENSE.txt",
    "content": "MIT License\n\nCopyright (c) 2018 Sawyer McLane\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "MANIFEST.in",
    "content": "recursive-include lifx_control_panel/res *"
  },
  {
    "path": "README.md",
    "content": "## Development is now being continued on the new [Mantle](https://github.com/samclane/mantle) project\n\n# LIFX-Control-Panel\n\n[![codecov](https://codecov.io/gh/samclane/LIFX-Control-Panel/branch/master/graph/badge.svg?token=GLAxucmOo6)](https://codecov.io/gh/samclane/LIFX-Control-Panel)\n![Smoke Build And Test](https://github.com/samclane/LIFX-Control-Panel/actions/workflows/main.yml/badge.svg?event=push&branch=master)\n\n<img align=\"right\" width=\"120\" height=\"120\" title=\"LIFX-Control-Panel Logo\" src=\"./res/lifx-animated-logo.gif\">\n     \nLIFX-Control-Panel is an open source application for controlling your LIFX brand lights. It integrates simple features, \nsuch as monitoring and changing bulb color, with more advanced ones, like:\n \n * Average Screen Color\n * Color Eyedropper\n * Custom color palette\n * Keybindings\n\n<p align=\"center\">\n  <img src=\"./res/screenshot.png\" alt=\"Screenshot\" width=\"306\" height=731>\n</p>\n\n#### Color Averaging Demo (Click for Video):\n\n[![Avg Test Youtube](https://img.youtube.com/vi/C-jZISM9MC0/0.jpg)](https://youtu.be/C-jZISM9MC0)\n\nThe application uses a fork of [mclarkk's](https://github.com/mclarkk)'s [lifxlan](https://github.com/mclarkk/lifxlan) module to\ndiscover and send commands to the lights.\n\n[The fork can be found here.](https://github.com/samclane/lifxlan)\n\n# Quick Start\n\nThere are ~~2~~ **3** ways to install:\n\n1. Go over to [releases](https://github.com/samclane/LIFX-Control-Panel/releases) and download the latest `.exe` file.\n\n2. Run `pip install lifx-control-panel`. To start run `python -m lifx_control_panel`.\n\nStarting the program takes a moment, as it first must scan your LAN for any LIFX devices.\n\n# Running the source code\n\nYou can now install through PyPI, by running `pip install lifx-control-panel`. This will automatically install dependencies.\n\nTo manually install the dependencies, run `pip install -r requirements.txt`. \n\nDue to some initial PyCharm cruft, the environment paths are a bit messed up. \n- The main script path is:\n  - `..\\LIFX-Control-Panel\\lifx_control_panel\\__main__.pyw`\n- The Working Directory is:\n  - `..\\LIFX-Control-Panel\\lifx_control_panel`\n- Additionally, the `Add content roots to PYTHONPATH` and `Add source roots to PYTHONPATH` boxes are checked\n  - I haven't been able to reproduce this in VSCode, yet.\n# Building\n\nLIFX-Control-Panel uses PyInstaller. After downloading the repository, open a command window in the `LIFX-Control-Panel`\ndirectory, and run `pyinstaller __main__.pyw`. This should initialize the necessary file structure to build the project.\n\nAs 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`\n\nTo build the project, simply open a terminal in the same folder and run `build_all.bat` in the command prompt. It will\ncall `pyinstaller` on `build_all.spec`. This should generate `.exe` files in the `/dist`\nfolder of the project for each of the 3 specs:\n\n- `main`\n  - This is the file that is used to build the main binary. The console, as well as verbose logging methods, are disabled.\n- `debug`\n  - This spec file enables the console to run in the background, as well as verbose logging.\n- `demo`\n  - The demo mode simulates adding several \"dummy\" lights to the LAN, allowing the software to be demonstrated on networks\n    that do not have any LIFX devices on them.\n\nIf you need help using PyInstaller, more instructions are located [here](https://pythonhosted.org/PyInstaller/usage.html).\n\n# Testing progress\n\nI have currently only tested on the following operating systems:\n\n- Windows 10\n\nand on the following LIFX devices:\n\n- LIFX A19 Firmware v2.76\n- LIFX A13 Firmware v2.76\n- LIFX Z Firmware v1.22\n- LIFX Mini White Firmware v3.41\n- LIFX Beam\n\nI've tried to test on the following operating systems:\n- MacOS X\n- Fedora Linux\n\nHowever, the biggest hurdle seems to be the `tk` GUI library, which is not supported on MacOS X, and requires\nextra library installations on Linux.\n\n# Feedback\n\nIf 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).\n\nIf you enjoy LIFX-Control-Panel, please Like and leave a review on [AlternativeTo](https://alternativeto.net/software/lifx-control-panel/).\n\n### NEW\n\n[Join our Discord Server](https://discord.gg/Wse9jX94Vq)\n\n# Donate\n\nLIFX-Control-Panel will always be free and open source. However, if you appreciate the work I'm doing and would like to\ncontribute financially, you can donate below. Thanks for your support!\n\n<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>\n\n[![paypal](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://www.paypal.me/sawyermclane)\n"
  },
  {
    "path": "build_all.bat",
    "content": "cd .\\lifx_control_panel\nset PYTHONOPTIMIZE=1 && pyinstaller --onefile --noupx build_all.spec\ncd .."
  },
  {
    "path": "codecov.yml",
    "content": "codecov.yml"
  },
  {
    "path": "default.ini",
    "content": "[AppSettings]\nstart_minimized = False\n\n[AverageColor]\ndefaultmonitor = get_primary_monitor()\nduration = 0.07\nbrightnessoffset = 0\n\n[PresetColors]\n\n[Keybinds]\n\n[Audio]\ninputindex = 0"
  },
  {
    "path": "lifx_control_panel/__init__.py",
    "content": "import os\nimport sys\n\nRED = [0, 65535, 65535, 3500]  # Fixes RED from appearing BLACK\nHEARTBEAT_RATE_MS = 3000  # 3 seconds\nFRAME_PERIOD_MS = 1500  # 1.5 seconds\nLOGFILE = \"lifx-control-panel.log\"\nAPPLICATION_PATH = os.path.dirname(sys.executable)\n"
  },
  {
    "path": "lifx_control_panel/__main__.pyw",
    "content": "# -*- coding: utf-8 -*-\n\"\"\"Main lifx_control_panel GUI control\n\nThis module contains several ugly God-classes that control the GUI functions and reactions.\n\nNotes\n-----\n    This is the \"main\" function of the app, and can be run simply with 'python main.pyw'\n\"\"\"\nimport logging\nimport os\nimport sys\nimport threading\nimport tkinter\nimport tkinter.colorchooser\nimport traceback\nfrom collections import OrderedDict\nfrom logging.handlers import RotatingFileHandler\nfrom PIL import Image\nfrom tkinter import messagebox, ttk\nfrom typing import List, Dict, Union, Optional\n\nimport pystray\nimport lifxlan\nif os.name == 'nt':\n    import pystray._win32\n\nfrom lifx_control_panel import HEARTBEAT_RATE_MS, FRAME_PERIOD_MS, LOGFILE\nfrom lifx_control_panel._constants import BUILD_DATE, AUTHOR, DEBUGGING, VERSION\nfrom lifx_control_panel.frames import LightFrame, MultiZoneFrame, GroupFrame\nfrom lifx_control_panel.ui import settings\nfrom lifx_control_panel.ui.icon_list import BulbIconList\nfrom lifx_control_panel.ui.settings import config\nfrom lifx_control_panel.ui.splashscreen import Splash\nfrom lifx_control_panel.utilities import audio\nfrom lifx_control_panel.utilities.async_bulb_interface import AsyncBulbInterface\nfrom lifx_control_panel.utilities.keypress import KeybindManager\nfrom lifx_control_panel.utilities.utils import (resource_path,\n                                                Color,\n                                                str2tuple)\n\n# determine if application is a script file or frozen exe\nAPPLICATION_PATH = os.path.dirname(__file__)\n\nLOGFILE = os.path.join(APPLICATION_PATH, LOGFILE)\n\nSPLASH_FILE = resource_path('res/splash_vector.png')\n\n\nclass LifxFrame(ttk.Frame):  # pylint: disable=too-many-ancestors\n    \"\"\" Parent frame of application. Holds icons for each Device/Group. \"\"\"\n    bulb_interface: AsyncBulbInterface\n    current_lightframe: LightFrame\n\n    def __init__(self, master: tkinter.Tk, lifx_instance: lifxlan.LifxLAN, bulb_interface: AsyncBulbInterface):\n        # We take a lifx instance, so we can inject our own for testing.\n\n        # Start showing splash_screen while processing\n        self.splashscreen = Splash(master, SPLASH_FILE)\n        self.splashscreen.__enter__()\n\n        # Setup frame and grid\n        ttk.Frame.__init__(self, master, padding=\"3 3 12 12\")\n        self.master: tkinter.Tk = master\n        self.master.protocol(\"WM_DELETE_WINDOW\", self.on_closing)\n        self.grid(column=0, row=0, sticky=(tkinter.N, tkinter.W, tkinter.E, tkinter.S))\n        self.columnconfigure(0, weight=1)\n        self.rowconfigure(0, weight=1)\n        self.lifx: lifxlan.LifxLAN = lifx_instance\n        self.bulb_interface: AsyncBulbInterface = bulb_interface\n        self.audio_interface = audio.AudioInterface()\n        self.audio_interface.init_audio(config)\n\n        # Setup logger\n        master_logger: str = master.logger.name if hasattr(master, 'logger') else \"root\"\n        self.logger = logging.getLogger(master_logger + '.' + self.__class__.__name__)\n        self.logger.info('Root logger initialized: %s', self.logger.name)\n        self.logger.info('Binary Version: %s', VERSION)\n        self.logger.info('Build time: %s', BUILD_DATE)\n\n        # Setup menu\n        self.menubar = tkinter.Menu(master)\n        file_menu = tkinter.Menu(self.menubar, tearoff=0)\n        file_menu.add_command(label=\"Rescan\", command=self.scan_for_lights)\n        file_menu.add_command(label=\"Settings\", command=self.show_settings)\n        file_menu.add_separator()\n        file_menu.add_command(label=\"Exit\", command=self.on_closing)\n        self.menubar.add_cascade(label=\"File\", menu=file_menu)\n        self.menubar.add_command(label=\"About\", command=self.show_about)\n        self.master.config(menu=self.menubar)\n\n        # Initialize LIFX objects\n        self.tk_light_name = tkinter.StringVar(self)\n        self.device_map: Dict[str, Union[lifxlan.Device, lifxlan.Group]] = OrderedDict()  # LifxLight objects\n        self.frame_map: Dict[str, LightFrame] = {}  # corresponding LightFrame GUI\n        self.current_lightframe: Optional[LightFrame] = None  # currently selected and visible LightFrame\n        self.current_light: Optional[lifxlan.Light]\n        self.bulb_icons = BulbIconList(self)\n        self.group_icons = BulbIconList(self, is_group=True)\n\n        self.scan_for_lights()\n\n        if any(self.device_map):\n            self.tk_light_name.set(next(iter(self.device_map.keys())))\n            self.current_light = self.device_map[self.tk_light_name.get()]\n        else:\n            messagebox.showwarning(\"No lights found.\", \"No LIFX devices were found on your LAN. Try using File->Rescan\"\n                                                       \" to search again.\")\n\n        self.bulb_icons.grid(row=1, column=1, sticky='w')\n        self.bulb_icons.canvas.bind('<Button-1>', self.on_bulb_canvas_click)\n        # Keep light-name in sync with drop-down selection\n        self.tk_light_name.trace('w', self.bulb_changed)\n\n        self.group_icons.grid(row=2, column=1, sticky='w')\n        self.group_icons.canvas.bind('<Button-1>', self.on_bulb_canvas_click)\n\n        # Setup tray icon\n        def lambda_quit(self_):\n            \"\"\" Build an anonymous function call w/ correct 'self' scope\"\"\"\n            return lambda *_, **__: self_.on_closing()\n\n        def lambda_adjust(self_):\n            return lambda *_, **__: self_.master.deiconify()\n\n        def run_tray_icon():\n            \"\"\" Allow SysTrayIcon in a separate thread \"\"\"\n            image = Image.open(resource_path('res/icon_vector.ico'))\n\n            icon = pystray.Icon(\"LIFX Control Panel\", image, menu=pystray.Menu(\n                pystray.MenuItem('Open',\n                                 lambda_adjust(self),\n                                 default=True),\n                pystray.MenuItem('Quit',\n                                 lambda_quit(self)),\n            ))\n            icon.run()\n\n        self.systray_thread = threading.Thread(target=run_tray_icon, daemon=True)\n        self.systray_thread.start()\n        self.master.bind('<Unmap>', lambda *_, **__: self.master.withdraw())  # Minimize to taskbar\n\n        # Setup keybinding listener\n        self.key_listener = KeybindManager(self)\n        for keypress, function in dict(config['Keybinds']).items():\n            light, color = function.split(':')\n            color = Color(*globals()[color]) if color in globals().keys() else str2tuple(\n                color, int)\n            self.save_keybind(light, keypress, color)\n\n        # Stop splashscreen and start main function\n        self.splashscreen.__exit__(None, None, None)\n\n        # Start icon callback\n        self.after(FRAME_PERIOD_MS, self.update_icons)\n\n        # Minimize if in config\n        if config.getboolean(\"AppSettings\", \"start_minimized\"):\n            self.master.withdraw()\n\n    def scan_for_lights(self):\n        \"\"\" Communicating with the interface Thread, attempt to find any new devices \"\"\"\n        # Stop and restart the bulb interface\n        stop_event: threading.Event = self.bulb_interface.stopped\n        if not stop_event.is_set():\n            stop_event.set()\n        device_list: List[Union[lifxlan.Group, lifxlan.Light, lifxlan.MultiZoneLight]] = self.lifx.get_devices()\n        if self.bulb_interface:\n            del self.bulb_interface\n        self.bulb_interface = AsyncBulbInterface(stop_event, HEARTBEAT_RATE_MS)\n        self.bulb_interface.set_device_list(device_list)\n        self.bulb_interface.daemon = True\n        stop_event.clear()\n        self.bulb_interface.start()\n\n        light: lifxlan.Device\n        for light in self.bulb_interface.device_list:\n            try:\n                product: str = lifxlan.product_map[light.get_product()]\n                label: str = light.get_label()\n                # light.get_color()\n                self.device_map[label] = light\n                self.logger.info('Light found: %s: \"%s\"', product, label)\n                if label not in self.bulb_icons.bulb_dict:\n                    self.bulb_icons.draw_bulb_icon(light, label)\n                if label not in self.frame_map:\n                    if light.supports_multizone():\n                        self.frame_map[label] = MultiZoneFrame(self, light)\n                    else:\n                        self.frame_map[label] = LightFrame(self, light)\n                    self.current_lightframe = self.frame_map[label]\n                    try:\n                        self.bulb_icons.set_selected_bulb(label)\n                    except KeyError:\n                        self.group_icons.set_selected_bulb(label)\n                    self.logger.info(\"Building new frame: %s\", self.frame_map[label].get_label())\n                group_label = light.get_group_label()\n                if group_label not in self.device_map.keys():\n                    self.build_group_frame(group_label)\n            except lifxlan.WorkflowException as exc:\n                self.logger.warning(\"Error when communicating with LIFX device: %s\", exc)\n            except KeyError as exc:\n                self.logger.warning(\"Unknown device: %s: %s\",\n                                    light.get_product(), light.get_label())\n\n    def build_group_frame(self, group_label):\n        self.device_map[group_label] = self.lifx.get_devices_by_group(group_label)\n        self.device_map[group_label].get_label = lambda: group_label  # pylint: disable=cell-var-from-loop\n        # Giving an attribute here is a bit dirty, but whatever\n        self.device_map[group_label].label = group_label\n        self.group_icons.draw_bulb_icon(None, group_label)\n        self.logger.info(\"Group found: %s\", group_label)\n        self.frame_map[group_label] = GroupFrame(self, self.device_map[group_label])\n        self.logger.info(\"Building new frame: %s\", self.frame_map[group_label].get_label())\n\n    def bulb_changed(self, *_, **__):\n        \"\"\" Change current display frame when bulb icon is clicked. \"\"\"\n        self.master.unbind('<Unmap>')  # unregister unmap so grid_remove doesn't trip it\n        new_light_label = self.tk_light_name.get()\n        self.current_light = self.device_map[new_light_label]\n        # loop below removes all other frames; not just the current one (this fixes sync bugs for some reason)\n        for frame in self.frame_map.values():\n            frame.grid_remove()\n        self.frame_map[new_light_label].grid()  # should bring to front\n        self.logger.info(\n            \"Brought existing frame to front: %s\", self.frame_map[new_light_label].get_label())\n        self.current_lightframe = self.frame_map[new_light_label]\n        self.current_lightframe.restart()\n        if self.current_lightframe.get_label() != self.tk_light_name.get():\n            self.logger.error(\"Mismatch between LightFrame (%s) and Dropdown (%s)\", self.current_lightframe.get_label(),\n                              self.tk_light_name.get())\n        self.master.bind('<Unmap>', lambda *_, **__: self.master.withdraw())  # reregister callback\n\n    def on_bulb_canvas_click(self, event):\n        \"\"\" Called whenever somebody clicks on one of the Device/Group icons. Switches LightFrame being shown. \"\"\"\n        canvas = event.widget\n        # Convert to Canvas coords as we are using a Scrollbar, so Frame coords doesn't always match up.\n        x_canvas = canvas.canvasx(event.x)\n        y_canvas = canvas.canvasy(event.y)\n        item = canvas.find_closest(x_canvas, y_canvas)\n        light_name = canvas.gettags(item)[0]\n        self.tk_light_name.set(light_name)\n        if not canvas.master.is_group:  # BulbIconList\n            self.bulb_icons.set_selected_bulb(light_name)\n            if self.group_icons.current_icon:\n                self.group_icons.clear_selected()\n        else:\n            self.group_icons.set_selected_bulb(light_name)\n            if self.bulb_icons.current_icon:\n                self.bulb_icons.clear_selected()\n\n    def update_icons(self):\n        \"\"\" If the window isn't minimized, redraw icons to reflect their current power/color state. \"\"\"\n        if self.master.winfo_viewable():\n            for frame in self.frame_map.values():\n                if not isinstance(frame, GroupFrame) and frame.icon_update_flag:\n                    self.bulb_icons.update_icon(frame.target)\n                    frame.icon_update_flag = False\n        self.after(FRAME_PERIOD_MS, self.update_icons)\n\n    def save_keybind(self, light, keypress, color):\n        \"\"\" Builds a new anonymous function changing light to color when keypress is entered. \"\"\"\n\n        def lambda_factory(self, light, color):\n            \"\"\" https://stackoverflow.com/questions/938429/scope-of-lambda-functions-and-their-parameters \"\"\"\n            return lambda *_, **__: self.device_map[light].set_color(color,\n                                                                     duration=float(config[\"AverageColor\"][\"duration\"]))\n\n        func = lambda_factory(self, light, color)\n        self.key_listener.register_function(keypress, func)\n\n    def delete_keybind(self, keycombo):\n        \"\"\" Deletes anonymous function from key_listener. Don't know why this is needed. \"\"\"\n        self.key_listener.unregister_function(keycombo)\n\n    def show_settings(self):\n        \"\"\" Show the settings dialog box over the master window. \"\"\"\n        self.key_listener.shutdown()\n        settings.SettingsDisplay(self, \"Settings\")\n        self.current_lightframe.update_user_dropdown()\n        self.audio_interface.init_audio(config)\n        for frame in self.frame_map.values():\n            frame.music_button.config(state=\"normal\" if self.audio_interface.initialized else \"disabled\")\n        self.key_listener.restart()\n\n    @staticmethod\n    def show_about():\n        \"\"\" Show the about info-box above the master window. \"\"\"\n        messagebox.showinfo(\"About\", f\"lifx_control_panel\\n\"\n                                     f\"Version {VERSION}\\n\"\n                                     f\"{AUTHOR}, {BUILD_DATE}\\n\"\n                                     f\"Bulb Icons by Quixote\\n\"\n                                     f\"Please consider donating at ko-fi.com/sawyermclane\")\n\n    def on_closing(self):\n        \"\"\" Should always be called before the application exits. Shuts down all threads and closes the program. \"\"\"\n        self.logger.info('Shutting down.\\n')\n        self.master.destroy()\n        self.bulb_interface.stopped.set()\n        sys.exit(0)\n\n\ndef main():\n    \"\"\" Start the GUI, bulb_interface, loggers, exception handling, and finally run the app \"\"\"\n    root = None\n    try:\n        root = tkinter.Tk()\n        root.title(\"lifx_control_panel\")\n        root.resizable(False, False)\n\n        # Setup main_icon\n        root.iconbitmap(resource_path('res/icon_vector.ico'))\n\n        root.logger = logging.getLogger('root')\n        root.logger.setLevel(logging.DEBUG)\n        file_handler = RotatingFileHandler(LOGFILE, maxBytes=5 * 1024 * 1024, backupCount=1)\n        formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')\n        file_handler.setFormatter(formatter)\n        root.logger.addHandler(file_handler)\n        stream_handler = logging.StreamHandler()\n        stream_handler.setLevel(logging.DEBUG)\n        stream_handler.setFormatter(formatter)\n        root.logger.addHandler(stream_handler)\n        root.logger.info('Logger initialized.')\n\n        def custom_handler(type_, value, trace_back):\n            \"\"\" A custom exception handler that logs exceptions in the root window's logger. \"\"\"\n            root.logger.exception(\n                \"Uncaught exception: %s:%s:%s\", repr(type_), str(value), repr(trace_back))\n\n        sys.excepthook = custom_handler\n\n        lifxlan.light_products.append(38)  # TODO Hotfix for missing LIFX Beam\n\n        LifxFrame(root, lifxlan.LifxLAN(verbose=DEBUGGING), AsyncBulbInterface(threading.Event(), HEARTBEAT_RATE_MS))\n\n        # Run main app\n        root.mainloop()\n\n    except Exception as exc:  # pylint: disable=broad-except\n        if root and hasattr(root, \"logger\"):\n            root.logger.exception(exc)\n        else:\n            logging.exception(exc)\n        messagebox.showerror(\"Unhandled Exception\", f'Unhandled runtime exception: {traceback.format_exc()}\\n\\n'\n                                                    f'Please report this at:'\n                                                    f' https://github.com/samclane/lifx_control_panel/issues'\n                             )\n        os._exit(1)  # pylint: disable=protected-access\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "lifx_control_panel/_constants.py",
    "content": "VERSION = \"2.3.0\"\nBUILD_DATE = \"2022-12-12T06:22:59.747968\"\nAUTHOR = \"Sawyer McLane\"\nDEBUGGING = False\n"
  },
  {
    "path": "lifx_control_panel/frames.py",
    "content": "import logging\nimport tkinter\nfrom tkinter import ttk, font as font, messagebox, _setit\nfrom typing import Union, List, Tuple, Dict, Mapping\n\nimport mouse\nimport lifxlan\nimport win32api\n\nfrom lifxlan import (\n    ORANGE,\n    YELLOW,\n    GREEN,\n    CYAN,\n    BLUE,\n    PURPLE,\n    PINK,\n    WHITE,\n    COLD_WHITE,\n    WARM_WHITE,\n    GOLD,\n)\n\nfrom lifx_control_panel import RED, FRAME_PERIOD_MS\nfrom lifx_control_panel.ui.colorscale import ColorScale\nfrom lifx_control_panel.ui.settings import config\nfrom lifx_control_panel.utilities import color_thread\nfrom lifx_control_panel.utilities.color_thread import (\n    get_screen_as_image,\n    normalize_rectangles,\n)\nfrom lifx_control_panel.utilities.utils import (\n    Color,\n    tuple2hex,\n    hsbk_to_rgb,\n    hsv_to_rgb,\n    kelvin_to_rgb,\n    get_primary_monitor,\n    str2list,\n    str2tuple,\n    get_display_rects,\n)\n\nMAX_KELVIN_DEFAULT = 9000\n\nMIN_KELVIN_DEFAULT = 1500\n\n\nclass LightFrame(ttk.Labelframe):  # pylint: disable=too-many-ancestors\n    \"\"\" Holds control and state information about a single device. \"\"\"\n\n    label: str\n    target: Union[lifxlan.Group, lifxlan.Device]\n    ###\n    screen_region_lf: ttk.LabelFrame\n    screen_region_entries: Dict[str, tkinter.Entry]\n    avg_screen_btn: tkinter.Button\n    dominant_screen_btn: tkinter.Button\n    music_button: tkinter.Button\n    preset_colors_lf: ttk.LabelFrame\n    color_var: tkinter.StringVar\n    default_colors: Mapping[str, Color]\n    preset_dropdown: tkinter.OptionMenu\n    tk_user_def_color_var: tkinter.StringVar\n    user_dropdown: tkinter.OptionMenu\n    current_color: tkinter.Canvas\n    hsbk: Tuple[tkinter.IntVar, tkinter.IntVar, tkinter.IntVar, tkinter.IntVar]\n    hsbk_labels: Tuple[tkinter.Label, tkinter.Label, tkinter.Label, tkinter.Label]\n    hsbk_scale: Tuple[ColorScale, ColorScale, ColorScale, ColorScale]\n    hsbk_display: Tuple[tkinter.Canvas, tkinter.Canvas, tkinter.Canvas, tkinter.Canvas]\n    threads: Dict[str, color_thread.ColorThreadRunner]\n    tk_power_var: tkinter.BooleanVar\n    option_on: tkinter.Radiobutton\n    option_off: tkinter.Radiobutton\n    logger: logging.Logger\n    min_kelvin: int = MIN_KELVIN_DEFAULT\n    max_kelvin: int = MAX_KELVIN_DEFAULT\n\n    def __init__(self, master, target: lifxlan.Device):\n        super().__init__(\n            master,\n            padding=\"3 3 12 12\",\n            labelwidget=tkinter.Label(\n                master,\n                text=\"<LABEL_ERR>\",\n                font=font.Font(size=12),\n                fg=\"#0046d5\",\n                relief=tkinter.RIDGE,\n            ),\n        )\n        self.icon_update_flag: bool = True\n        # Initialize LightFrames\n        bulb_power, init_color = self._get_light_info(target)\n\n        # Reconfigure label with correct name\n        self.configure(\n            labelwidget=tkinter.Label(\n                master,\n                text=self.label,\n                font=font.Font(size=12),\n                fg=\"#0046d5\",\n                relief=tkinter.RIDGE,\n            )\n        )\n        self.grid(column=1, row=0, sticky=(tkinter.N, tkinter.W, tkinter.E, tkinter.S))\n        self.columnconfigure(0, weight=1)\n        self.rowconfigure(0, weight=1)\n        self.target = target\n\n        # Setup logger\n        self._setup_logger()\n\n        # Initialize vars to hold on/off state\n        self.setup_power_controls(bulb_power)\n\n        # Initialize vars to hold and display bulb color\n        self.setup_color_controls(init_color)\n\n        # Add buttons for pre-made colors\n        self._setup_color_dropdowns()\n\n        # Add buttons for special routines\n        self.special_functions_lf = ttk.LabelFrame(\n            self, text=\"Special Functions\", padding=\"3 3 12 12\"\n        )\n        ####\n\n        self._setup_special_functions()\n\n        ####\n        # Add custom screen region (real ugly)\n        self._setup_screen_region_select()\n\n        # Start update loop\n        self.update_status_from_bulb()\n\n    def _get_light_info(self, target: lifxlan.Device) -> Tuple[int, Color]:\n        bulb_power: int = 0\n        init_color: Color = Color(*lifxlan.WARM_WHITE)\n        try:\n            self.label = target.get_label()\n            bulb_power = target.get_power()\n            if target.supports_multizone():\n                target: lifxlan.MultiZoneLight\n                init_color = Color(*target.get_color_zones()[0])\n            else:\n                target: lifxlan.Light\n                init_color = Color(*target.get_color())\n            self.min_kelvin = (\n                target.product_features.get(\"min_kelvin\") or MIN_KELVIN_DEFAULT\n            )\n            self.max_kelvin = (\n                target.product_features.get(\"max_kelvin\") or MAX_KELVIN_DEFAULT\n            )\n        except lifxlan.WorkflowException as exc:\n            messagebox.showerror(\n                f\"Error building {self.__class__.__name__}\",\n                f\"Error thrown when trying to get label from bulb:\\n{exc}\",\n            )\n            self.master.on_closing()\n            # TODO Let this fail safely and try again later\n        return bulb_power, init_color\n\n    def _setup_screen_region_select(self):\n        self.screen_region_lf = ttk.LabelFrame(\n            self, text=\"Screen Avg. Region\", padding=\"3 3 12 12\"\n        )\n        self.screen_region_entries = {\n            \"left\": tkinter.Entry(self.screen_region_lf, width=6),\n            \"width\": tkinter.Entry(self.screen_region_lf, width=6),\n            \"top\": tkinter.Entry(self.screen_region_lf, width=6),\n            \"height\": tkinter.Entry(self.screen_region_lf, width=6),\n        }\n        region = config[\"AverageColor\"][\n            self.label\n            if self.label in config[\"AverageColor\"].keys()\n            else \"defaultmonitor\"\n        ]\n        if region == \"full\":\n            region = [\"full\"] * 4\n        elif region[:19] == \"get_primary_monitor\":\n            region = get_primary_monitor()\n        else:\n            region = str2list(region, int)\n        self.screen_region_entries[\"left\"].insert(tkinter.END, region[0])\n        self.screen_region_entries[\"top\"].insert(tkinter.END, region[1])\n        self.screen_region_entries[\"width\"].insert(tkinter.END, region[2])\n        self.screen_region_entries[\"height\"].insert(tkinter.END, region[3])\n        self._grid_horiz_coordinate_box(\"left\", 7, \"width\")\n        self._grid_horiz_coordinate_box(\"top\", 8, \"height\")\n        tkinter.Button(\n            self.screen_region_lf, text=\"Save\", command=self.save_monitor_bounds\n        ).grid(row=9, column=1, sticky=\"w\")\n        self.screen_region_lf.grid(row=7, columnspan=4)\n\n    def _grid_horiz_coordinate_box(self, text: str, row, arg2):\n        tkinter.Label(self.screen_region_lf, text=text).grid(\n            row=row, column=0, sticky=\"e\"\n        )\n\n        self.screen_region_entries[text].grid(row=row, column=1, padx=(0, 10))\n        tkinter.Label(self.screen_region_lf, text=arg2).grid(row=row, column=2)\n        self.screen_region_entries[arg2].grid(row=row, column=3)\n\n    def _setup_special_functions(self):\n        # Color cycle\n        self.threads[\"cycle\"] = color_thread.ColorThreadRunner(\n            self.target, color_thread.ColorCycle(), self\n        )\n\n        def start_color_cycle():\n            self.color_cycle_btn.config(bg=\"Green\")\n            self.threads[\"cycle\"].start()\n\n        self.color_cycle_btn = tkinter.Button(\n            self.special_functions_lf, text=\"Color Cycle\", command=start_color_cycle,\n        )\n        self.color_cycle_btn.grid(row=9, column=0)\n        # Screen Avg.\n        self.threads[\"screen\"] = color_thread.ColorThreadRunner(\n            self.target,\n            color_thread.avg_screen_color,\n            self,\n            func_bounds=self.get_monitor_bounds,\n        )\n\n        def start_screen_avg():\n            \"\"\" Allow the screen avg. to be run in a separate thread. Also turns button green while running. \"\"\"\n            self.avg_screen_btn.config(bg=\"Green\")\n            self.threads[\"screen\"].start()\n\n        self.avg_screen_btn = tkinter.Button(\n            self.special_functions_lf,\n            text=\"Avg. Screen Color\",\n            command=start_screen_avg,\n        )\n        self.avg_screen_btn.grid(row=6, column=0)\n        tkinter.Button(\n            self.special_functions_lf,\n            text=\"Pick Color\",\n            command=self.get_color_from_palette,\n        ).grid(row=6, column=1)\n        # Screen Dominant\n        self.threads[\"dominant\"] = color_thread.ColorThreadRunner(\n            self.target,\n            color_thread.dominant_screen_color,\n            self,\n            func_bounds=self.get_monitor_bounds,\n        )\n\n        def start_screen_dominant():\n            self.dominant_screen_btn.config(bg=\"Green\")\n            self.threads[\"dominant\"].start()\n\n        self.dominant_screen_btn = tkinter.Button(\n            self.special_functions_lf,\n            text=\"Dominant Screen Color\",\n            command=start_screen_dominant,\n        )\n        self.dominant_screen_btn.grid(row=7, column=0)\n        # Audio\n        self.threads[\"audio\"] = color_thread.ColorThreadRunner(\n            self.target, self.master.audio_interface.get_music_color, self\n        )\n\n        def start_audio():\n            \"\"\" Allow the audio to be run in a separate thread. Also turns button green while running. \"\"\"\n            self.music_button.config(bg=\"Green\")\n            self.threads[\"audio\"].start()\n\n        self.music_button = tkinter.Button(\n            self.special_functions_lf,\n            text=\"Music Color\",\n            command=start_audio,\n            state=\"disabled\"\n            if not self.master.audio_interface.initialized\n            else \"normal\",\n        )\n        self.music_button.grid(row=8, column=0)\n        self.threads[\"eyedropper\"] = color_thread.ColorThreadRunner(\n            self.target, self.eyedropper, self, continuous=False\n        )\n        tkinter.Button(\n            self.special_functions_lf,\n            text=\"Color Eyedropper\",\n            command=self.threads[\"eyedropper\"].start,\n        ).grid(row=7, column=1)\n        tkinter.Button(\n            self.special_functions_lf, text=\"Stop effects\", command=self.stop_threads\n        ).grid(row=8, column=1)\n        self.special_functions_lf.grid(row=6, columnspan=4)\n\n    def _setup_color_dropdowns(self):\n        self.preset_colors_lf = ttk.LabelFrame(\n            self, text=\"Preset Colors\", padding=\"3 3 12 12\"\n        )\n        self.color_var = tkinter.StringVar(self, value=\"Presets\")\n        self.default_colors = {\n            \"RED\": RED,\n            \"ORANGE\": ORANGE,\n            \"YELLOW\": YELLOW,\n            \"GREEN\": GREEN,\n            \"CYAN\": CYAN,\n            \"BLUE\": BLUE,\n            \"PURPLE\": PURPLE,\n            \"PINK\": PINK,\n            \"WHITE\": WHITE,\n            \"COLD_WHITE\": COLD_WHITE,\n            \"WARM_WHITE\": WARM_WHITE,\n            \"GOLD\": GOLD,\n        }\n        self.preset_dropdown = tkinter.OptionMenu(\n            self.preset_colors_lf, self.color_var, *self.default_colors\n        )\n        self.preset_dropdown.grid(row=0, column=0)\n        self.preset_dropdown.configure(width=13)\n        self.color_var.trace(\"w\", self.change_preset_dropdown)\n        self.tk_user_def_color_var = tkinter.StringVar(self, value=\"User Presets\")\n        self.user_dropdown = tkinter.OptionMenu(\n            self.preset_colors_lf,\n            self.tk_user_def_color_var,\n            *(\n                [*config[\"PresetColors\"].keys()]\n                if any(config[\"PresetColors\"].keys())\n                else [None]\n            ),\n        )\n        self.user_dropdown.grid(row=0, column=1)\n        self.user_dropdown.config(width=13)\n        self.tk_user_def_color_var.trace(\"w\", self.change_user_dropdown)\n        self.preset_colors_lf.grid(row=5, columnspan=4)\n\n    def setup_color_controls(self, init_color: Color):\n        self.logger.info(\"Initial light color HSBK: %s\", init_color)\n        self.current_color = tkinter.Canvas(\n            self,\n            background=tuple2hex(hsbk_to_rgb(init_color)),\n            width=40,\n            height=20,\n            borderwidth=3,\n            relief=tkinter.GROOVE,\n        )\n        self.current_color.grid(row=0, column=2)\n        self.hsbk = (\n            tkinter.IntVar(self, init_color.hue, \"Hue\"),\n            tkinter.IntVar(self, init_color.saturation, \"Saturation\"),\n            tkinter.IntVar(self, init_color.brightness, \"Brightness\"),\n            tkinter.IntVar(self, init_color.kelvin, \"Kelvin\"),\n        )\n        for i in self.hsbk:\n            i.trace(\"w\", self.trigger_icon_update)\n        self.hsbk_labels: Tuple[\n            tkinter.Label, tkinter.Label, tkinter.Label, tkinter.Label\n        ] = (\n            tkinter.Label(self, text=f\"{360 * (self.hsbk[0].get() / 65535):.3g}\"),\n            tkinter.Label(\n                self, text=str(f\"{100 * self.hsbk[1].get() / 65535:.3g}\") + \"%\"\n            ),\n            tkinter.Label(\n                self, text=str(f\"{100 * self.hsbk[2].get() / 65535:.3g}\") + \"%\"\n            ),\n            tkinter.Label(self, text=str(self.hsbk[3].get()) + \" K\"),\n        )\n        self.hsbk_scale: Tuple[ColorScale, ColorScale, ColorScale, ColorScale] = (\n            ColorScale(\n                self,\n                to=65535.0,\n                variable=self.hsbk[0],\n                command=self.update_color_from_ui,\n            ),\n            ColorScale(\n                self,\n                from_=0,\n                to=65535,\n                variable=self.hsbk[1],\n                command=self.update_color_from_ui,\n                gradient=\"wb\",\n            ),\n            ColorScale(\n                self,\n                from_=0,\n                to=65535,\n                variable=self.hsbk[2],\n                command=self.update_color_from_ui,\n                gradient=\"bw\",\n            ),\n            ColorScale(\n                self,\n                from_=self.min_kelvin,\n                to=self.max_kelvin,\n                variable=self.hsbk[3],\n                command=self.update_color_from_ui,\n                gradient=\"kelvin\",\n            ),\n        )\n        relief = tkinter.GROOVE\n        self.hsbk_display: Tuple[\n            tkinter.Canvas, tkinter.Canvas, tkinter.Canvas, tkinter.Canvas\n        ] = (\n            tkinter.Canvas(\n                self,\n                background=tuple2hex(hsv_to_rgb(360 * (init_color.hue / 65535))),\n                width=20,\n                height=20,\n                borderwidth=3,\n                relief=relief,\n            ),\n            tkinter.Canvas(\n                self,\n                background=tuple2hex(\n                    (\n                        int(255 * (init_color.saturation / 65535)),\n                        int(255 * (init_color.saturation / 65535)),\n                        int(255 * (init_color.saturation / 65535)),\n                    )\n                ),\n                width=20,\n                height=20,\n                borderwidth=3,\n                relief=relief,\n            ),\n            tkinter.Canvas(\n                self,\n                background=tuple2hex(\n                    (\n                        int(255 * (init_color.brightness / 65535)),\n                        int(255 * (init_color.brightness / 65535)),\n                        int(255 * (init_color.brightness / 65535)),\n                    )\n                ),\n                width=20,\n                height=20,\n                borderwidth=3,\n                relief=relief,\n            ),\n            tkinter.Canvas(\n                self,\n                background=tuple2hex(kelvin_to_rgb(init_color.kelvin)),\n                width=20,\n                height=20,\n                borderwidth=3,\n                relief=relief,\n            ),\n        )\n        scale: ColorScale\n        for key, scale in enumerate(self.hsbk_scale):\n            tkinter.Label(self, text=self.hsbk[key]).grid(row=key + 1, column=0)\n            scale.grid(row=key + 1, column=1)\n            self.hsbk_labels[key].grid(row=key + 1, column=2)\n            self.hsbk_display[key].grid(row=key + 1, column=3)\n        self.threads: Dict[str, color_thread.ColorThreadRunner] = {}\n\n    def setup_power_controls(self, bulb_power: int):\n        self.tk_power_var = tkinter.BooleanVar(self)\n        self.tk_power_var.set(bool(bulb_power))\n        self.option_on = tkinter.Radiobutton(\n            self,\n            text=\"On\",\n            variable=self.tk_power_var,\n            value=65535,\n            command=self.update_power,\n        )\n        self.option_off = tkinter.Radiobutton(\n            self,\n            text=\"Off\",\n            variable=self.tk_power_var,\n            value=0,\n            command=self.update_power,\n        )\n        if self.tk_power_var.get() == 0:\n            # Light is off\n            self.option_off.select()\n            self.option_on.selection_clear()\n        else:\n            self.option_on.select()\n            self.option_off.selection_clear()\n        self.option_on.grid(row=0, column=0)\n        self.option_off.grid(row=0, column=1)\n\n    def _setup_logger(self):\n        self.logger = logging.getLogger(\n            self.master.logger.name + \".\" + self.__class__.__name__ + f\"({self.label})\"\n        )\n        self.logger.setLevel(logging.DEBUG)\n        self.logger.info(\n            \"%s logger initialized: %s // Device: %s\",\n            self.__class__.__name__,\n            self.logger.name,\n            self.label,\n        )\n\n    def restart(self):\n        \"\"\" Get updated information for the bulb when clicked. \"\"\"\n        self.update_status_from_bulb()\n        self.logger.info(\"Light frame Restarted.\")\n\n    def get_label(self):\n        \"\"\" Getter method for the label attribute. Often is monkey-patched. \"\"\"\n        return self.label\n\n    def trigger_icon_update(self, *_, **__):\n        \"\"\" Just sets a flag for now. Could be more advanced in the future. \"\"\"\n        self.icon_update_flag = True\n\n    def get_color_values_hsbk(self):\n        \"\"\" Get color values entered into GUI\"\"\"\n        return Color(*tuple(v.get() for v in self.hsbk))\n\n    def stop_threads(self):\n        \"\"\" Stop all ColorRunner threads \"\"\"\n        self.music_button.config(bg=\"SystemButtonFace\")\n        self.avg_screen_btn.config(bg=\"SystemButtonFace\")\n        self.dominant_screen_btn.config(bg=\"SystemButtonFace\")\n        self.color_cycle_btn.config(bg=\"SystemButtonFace\")\n        for thread in self.threads.values():\n            thread.stop()\n\n    def update_power(self):\n        \"\"\" Send new power state to bulb when UI is changed. \"\"\"\n        self.stop_threads()\n        self.target.set_power(self.tk_power_var.get())\n\n    def update_color_from_ui(self, *_, **__):\n        \"\"\" Send new color state to bulb when UI is changed. \"\"\"\n        self.stop_threads()\n        self.set_color(self.get_color_values_hsbk(), rapid=True)\n\n    def set_color(self, color, rapid=False):\n        \"\"\" Should be called whenever the bulb wants to change color. Sends bulb command and updates UI accordingly. \"\"\"\n        self.stop_threads()\n        try:\n            self.target.set_color(\n                color,\n                duration=0\n                if rapid\n                else float(config[\"AverageColor\"][\"duration\"]) * 1000,\n                rapid=rapid,\n            )\n        except lifxlan.WorkflowException as exc:\n            if not rapid:\n                raise exc\n        if not rapid:\n            self.logger.debug(\n                \"Color changed to HSBK: %s\", color\n            )  # Don't pollute log with rapid color changes\n\n    def update_label(self, key: int):\n        \"\"\" Update scale labels, formatted accordingly. \"\"\"\n        return [\n            self.hsbk_labels[0].config(\n                text=str(f\"{360 * (self.hsbk[0].get() / 65535):.3g}\")\n            ),\n            self.hsbk_labels[1].config(\n                text=str(f\"{100 * (self.hsbk[1].get() / 65535):.3g}\") + \"%\"\n            ),\n            self.hsbk_labels[2].config(\n                text=str(f\"{100 * (self.hsbk[2].get() / 65535):.3g}\") + \"%\"\n            ),\n            self.hsbk_labels[3].config(text=str(self.hsbk[3].get()) + \" K\"),\n        ][key]\n\n    def update_display(self, key: int):\n        \"\"\" Update color swatches to match current device state \"\"\"\n        h, s, b, k = self.get_color_values_hsbk()  # pylint: disable=invalid-name\n        if key == 0:\n            self.hsbk_display[0].config(\n                background=tuple2hex(hsv_to_rgb(360 * (h / 65535)))\n            )\n        elif key == 1:\n            s = 65535 - s  # pylint: disable=invalid-name\n            self.hsbk_display[1].config(\n                background=tuple2hex(\n                    (\n                        int(255 * (s / 65535)),\n                        int(255 * (s / 65535)),\n                        int(255 * (s / 65535)),\n                    )\n                )\n            )\n        elif key == 2:\n            self.hsbk_display[2].config(\n                background=tuple2hex(\n                    (\n                        int(255 * (b / 65535)),\n                        int(255 * (b / 65535)),\n                        int(255 * (b / 65535)),\n                    )\n                )\n            )\n        elif key == 3:\n            self.hsbk_display[3].config(background=tuple2hex(kelvin_to_rgb(k)))\n\n    def get_color_from_palette(self):\n        \"\"\" Asks users for color selection using standard color palette dialog. \"\"\"\n        color = tkinter.colorchooser.askcolor(\n            initialcolor=hsbk_to_rgb(self.get_color_values_hsbk())\n        )[0]\n        if color:\n            # RGBtoHBSK sometimes returns >65535, so we have to truncate\n            hsbk = [min(c, 65535) for c in lifxlan.RGBtoHSBK(color, self.hsbk[3].get())]\n            self.set_color(hsbk)\n            self.logger.info(\"Color set to HSBK %s from palette.\", hsbk)\n\n    def update_status_from_bulb(self, run_once=False):\n        \"\"\"\n        Periodically update status from the bulb to keep UI in sync.\n        run_once - Don't call `after` statement at end. Keeps a million workers from being instanced.\n        \"\"\"\n        require_icon_update = False\n        power_queue = self.master.bulb_interface.power_queue\n        if (\n            self.label in power_queue\n            and not self.master.bulb_interface.power_queue[self.label].empty()\n        ):\n            power = self.master.bulb_interface.power_queue[self.label].get()\n            require_icon_update = True\n            self.tk_power_var.set(power)\n            if self.tk_power_var.get() == 0:\n                # Light is off\n                self.option_off.select()\n                self.option_on.selection_clear()\n            else:\n                self.option_on.select()\n                self.option_off.selection_clear()\n\n        color_queue = self.master.bulb_interface.color_queue\n        if (\n            self.label in color_queue\n            and not self.master.bulb_interface.color_queue[self.label].empty()\n        ):\n            hsbk = self.master.bulb_interface.color_queue[self.label].get()\n            require_icon_update = True\n            for key, _ in enumerate(self.hsbk):\n                self.hsbk[key].set(hsbk[key])\n                self.update_label(key)\n                self.update_display(key)\n            self.current_color.config(background=tuple2hex(hsbk_to_rgb(hsbk)))\n\n        if require_icon_update:\n            self.trigger_icon_update()\n        if not run_once:\n            self.after(FRAME_PERIOD_MS, self.update_status_from_bulb)\n\n    def eyedropper(self, *_, **__):\n        \"\"\" Allows user to select a color pixel from the screen. \"\"\"\n        self.master.master.withdraw()  # Hide window\n        state_left = win32api.GetKeyState(\n            0x01\n        )  # Left button down = 0 or 1. tkinter.Button up = -127 or -128\n        while True:\n            action = win32api.GetKeyState(0x01)\n            if action != state_left:  # tkinter.Button state changed\n                state_left = action\n                if action >= 0:\n                    break\n            lifxlan.sleep(0.001)\n        # tkinter.Button state changed\n        screen_img = get_screen_as_image()\n        cursor_pos = mouse.get_position()\n        # Convert display coords to image coords\n        cursor_pos = normalize_rectangles(\n            get_display_rects() + [(cursor_pos[0], cursor_pos[1], 0, 0)]\n        )[-1][:2]\n        color = screen_img.getpixel(cursor_pos)\n        self.master.master.deiconify()  # Reshow window\n        self.logger.info(\"Eyedropper color found RGB %s\", color)\n        return lifxlan.RGBtoHSBK(color, temperature=self.get_color_values_hsbk().kelvin)\n\n    def change_preset_dropdown(self, *_, **__):\n        \"\"\" Change device color to selected preset option. \"\"\"\n        color = Color(*globals()[self.color_var.get()])\n        self.preset_dropdown.config(\n            bg=tuple2hex(hsbk_to_rgb(color)),\n            activebackground=tuple2hex(hsbk_to_rgb(color)),\n        )\n        self.set_color(color, False)\n\n    def change_user_dropdown(self, *_, **__):\n        \"\"\" Change device color to selected user-defined option. \"\"\"\n        color = str2tuple(config[\"PresetColors\"][self.tk_user_def_color_var.get()], int)\n        self.user_dropdown.config(\n            bg=tuple2hex(hsbk_to_rgb(color)),\n            activebackground=tuple2hex(hsbk_to_rgb(color)),\n        )\n        self.set_color(color, rapid=False)\n\n    def update_user_dropdown(self):\n        \"\"\" Add newly defined color to the user color dropdown menu. \"\"\"\n        # self.tk_user_def_color_var.set('')\n        self.user_dropdown[\"menu\"].delete(0, \"end\")\n\n        for choice in config[\"PresetColors\"]:\n            self.user_dropdown[\"menu\"].add_command(\n                label=choice, command=_setit(self.tk_user_def_color_var, choice)\n            )\n\n    def get_monitor_bounds(self):\n        \"\"\" Return the 4 rectangle coordinates from the entry boxes in the UI \"\"\"\n        return (\n            f\"[{self.screen_region_entries['left'].get()}, {self.screen_region_entries['top'].get()}, \"\n            f\"{self.screen_region_entries['width'].get()}, {self.screen_region_entries['height'].get()}]\"\n        )\n\n    def save_monitor_bounds(self):\n        \"\"\" Write monitor bounds entered into the UI into the config file. \"\"\"\n        config[\"AverageColor\"][self.label] = self.get_monitor_bounds()\n        # Write to config file\n        with open(\"config.ini\", \"w\", encoding=\"utf-8\") as cfg:\n            config.write(cfg)\n\n\nclass GroupFrame(LightFrame):\n    def _get_light_info(self, target: lifxlan.Group) -> Tuple[int, Color]:\n        bulb_power: int = 0\n        init_color: Color = Color(*lifxlan.WARM_WHITE)\n        try:\n            devices: List[\n                Union[lifxlan.Group, lifxlan.Light, lifxlan.MultiZoneLight]\n            ] = target.get_device_list()\n            if not devices:\n                logging.error(\"No devices found in group list\")\n                self.label = \"<No Group Found>\"\n                self.min_kelvin, self.max_kelvin = 0, 99999  # arbitrary range\n                return 0, Color(0, 0, 0, 0)\n\n            self.label = devices[0].get_group_label()\n            bulb_power = devices[0].get_power()\n            # Find an init_color- ensure device has color attribute, otherwise fallback\n            color_devices: List[\n                Union[lifxlan.Group, lifxlan.Light, lifxlan.MultiZoneLight]\n            ] = list(filter(lambda d: d.supports_color(), devices))\n            if color_devices and hasattr(color_devices[0], \"get_color\"):\n                init_color = Color(*color_devices[0].get_color())\n            self.min_kelvin = min(\n                device.product_features.get(\"min_kelvin\") or MIN_KELVIN_DEFAULT\n                for device in target.get_device_list()\n            )\n\n            self.max_kelvin = max(\n                device.product_features.get(\"max_kelvin\") or MAX_KELVIN_DEFAULT\n                for device in target.get_device_list()\n            )\n\n        except lifxlan.WorkflowException as exc:\n            messagebox.showerror(\n                f\"Error building {self.__class__.__name__}\",\n                f\"Error thrown when trying to get label from bulb:\\n{exc}\",\n            )\n            self.master.on_closing()\n            # TODO Let this fail safely and try again later\n        return bulb_power, init_color\n\n    def update_status_from_bulb(self, run_once=False):\n        return\n\n\nclass MultiZoneFrame(LightFrame):\n    pass\n"
  },
  {
    "path": "lifx_control_panel/test/__init__.py",
    "content": ""
  },
  {
    "path": "lifx_control_panel/test/dummy_devices.py",
    "content": "\"\"\" This is not a standard test file (yet), but used to simulate a multi-device environment. \"\"\"\n\nimport logging\nimport os\nimport time\nimport traceback\nfrom tkinter import *\nfrom tkinter import messagebox\nfrom typing import Iterable\n\nfrom lifxlan import Group\n\nfrom utilities.utils import Color as DummyColor\nfrom utilities.utils import resource_path\n\nLOGFILE = \"lifx-control-panel.log\"\n\n# determine if application is a script file or frozen exe\nif getattr(sys, \"frozen\", False):\n    application_path = os.path.dirname(sys.executable)\nelif __file__:\n    application_path = os.path.dirname(__file__)\n\nLOGFILE = os.path.join(application_path, LOGFILE)\n\n\n# Dummy classes\nclass DummyDevice:\n    def __init__(self, label=\"No label\"):\n        self.label = label\n        self.power = False\n        self.mac_addr = \"00:16:3e:2a:8d:00\"\n        self.ip_addr = \"63.218.207.187\"\n        self.build_timestamp = 1521690429000000000\n        self.version = 2.75\n        self.wifi_signal_mw = 32.0\n        self.wifi_tx_bytes = 0\n        self.wifi_rx_bytes = 0\n        self.wifi_build_timestamp = 0\n        self.wifi_version = 0.0\n        self.vendor = 1\n        self.product = 0x65\n        self.location_label = \"My Home\"\n        self.location_tuple = (\n            3,\n            238,\n            83,\n            151,\n            159,\n            43,\n            44,\n            177,\n            180,\n            149,\n            11,\n            191,\n            243,\n            219,\n            79,\n            115,\n        )\n        self.location_updated_at = 1516997252637000000\n        self.group_label = \"Room 2\"\n        self.group_tuple = (\n            50,\n            71,\n            100,\n            191,\n            135,\n            165,\n            21,\n            163,\n            195,\n            54,\n            66,\n            226,\n            0,\n            175,\n            217,\n            223,\n        )\n        self.group_updated_at = 1516997252642000000\n\n        self._start_time = time.time()\n\n    def is_light(self):\n        return True\n\n    def set_label(self, val: str):\n        self.label = val\n\n    def set_power(self, val: bool, rapid: bool = False):\n        self.power = val\n        return self.get_power()\n\n    def get_mac_address(self):\n        return self.mac_addr\n\n    def get_ip_addr(self):\n        return self.ip_addr\n\n    def get_service(self):\n        return 1  # returns in, 1 = UDP\n\n    def get_port(self):\n        return 56700\n\n    def get_label(self):\n        return self.label\n\n    def get_power(self):\n        return self.power\n\n    def get_host_firmware_tuple(self):\n        return self.build_timestamp, self.version\n\n    def get_host_firmware_build_timestamp(self):\n        return self.build_timestamp\n\n    def get_host_firmware_version(self):\n        return self.version\n\n    def get_wifi_info_tuple(self):\n        return self.wifi_signal_mw, self.wifi_tx_bytes, self.wifi_rx_bytes\n\n    def get_wifi_signal_mw(self):\n        return self.wifi_signal_mw\n\n    def get_wifi_tx_bytes(self):\n        return self.wifi_tx_bytes\n\n    def get_wifi_rx_bytes(self):\n        return self.wifi_rx_bytes\n\n    def get_wifi_firmware_tuple(self):\n        return self.wifi_build_timestamp, self.wifi_version\n\n    def get_wifi_firmware_build_timestamp(self):\n        return self.wifi_build_timestamp\n\n    def get_wifi_firmware_version(self):\n        return self.wifi_version\n\n    def get_version_tuple(self):\n        return self.vendor, self.product, self.version\n\n    def get_location(self):\n        return self.location_label\n\n    def get_location_tuple(self):\n        return self.location_tuple, self.location_label, self.location_updated_at\n\n    def get_location_label(self):\n        return self.location_label\n\n    def get_location_updated_at(self):\n        return self.location_updated_at\n\n    def get_group(self):\n        return self.group_label\n\n    def get_group_tuple(self):\n        return self.group_tuple, self.group_label, self.group_updated_at\n\n    def get_group_label(self):\n        return self.group_label\n\n    def get_group_updated_a(self):\n        return self.group_updated_at\n\n    def get_vendor(self):\n        return self.vendor\n\n    def get_product(self):\n        return self.product\n\n    def get_version(self):\n        return self.version\n\n    def get_info_tuple(self):\n        return time.time(), self.get_uptime(), self.get_downtime()\n\n    def get_time(self):\n        return time.time()\n\n    def get_uptime(self):\n        return time.time() - self._start_time\n\n    def get_downtime(self):  # no way to make this work. Shouldn't need it\n        return 0\n\n    def supports_color(self):\n        return True\n\n    def supports_temperature(self):\n        return True\n\n    def supports_multizone(self):\n        return True\n\n    def supports_infrared(self):\n        return True\n\n\nclass DummyBulb(DummyDevice):\n    def __init__(self, color=None, label=\"N/A\"):\n        super().__init__(label)\n        self.color = color or DummyColor(6097, 46851, 38791, 3014,)\n        self.power: int = 0\n\n    # Official api\n    @property\n    def power_level(self):\n        return self.power\n\n    @power_level.setter\n    def power_level(self, val):\n        self.power = val\n\n    def set_power(self, val: int, duration: int = 0, rapid: bool = False):\n        self.power = val\n\n    def set_color(self, val: DummyColor, duration: int = 0, rapid: bool = False):\n        self.color = val\n        return self.get_color()\n\n    def set_waveform(self, is_transient, color, period, cycles, duty_cycle, waveform):\n        pass\n\n    def get_power(self):\n        return self.power\n\n    def get_color(self):\n        return self.color\n\n    def get_infared(self):\n        return self.infared_brightness\n\n    def set_infared(self, val: int):\n        self.infared_brightness = val\n\n    def set_hue(self, hue, duration=0, rapid=False):\n        self.color.hue = hue\n\n    def set_brightness(self, brightness, duration=0, rapid=False):\n        self.color.brightness = brightness\n\n    def set_saturation(self, saturation, duration=0, rapid=False):\n        self.color.saturation = saturation\n\n    def set_colortemp(self, kelvin, duration=0, rapid=False):\n        self.color.kelvin = kelvin\n\n\nclass MultiZoneDummy(DummyBulb):\n    def __init__(self, color=DummyColor(0, 0, 0, 1500), label=\"N/A\"):\n        super().__init__(color, label)\n\n    # Multizone API\n\n    def get_color_zones(self, start=0, end=0):\n        pass\n\n    def set_zone_color(self, start, end, color, duration=0, rapid=False, apply=1):\n        pass\n\n    def set_zone_colors(self, colors, duration=0, rapid=False):\n        pass\n\n\nclass TileDummy(DummyBulb):\n    pass\n\n\nclass TileChainDummy(DummyBulb):\n    def __init__(self, color=DummyColor(0, 0, 0, 1500), label=\"N/A\", x=1, y=1):\n        super().__init__(color, label)\n        self.tiles = []\n        self.cache = []\n        self.x = x\n        self.y = y\n\n    def get_tile_info(self, refresh_cache=False):\n        if refresh_cache:\n            self.cache = self.tiles\n            return self.tiles\n        return self.cache\n\n    def get_tile_count(self, refresh_cache=False):\n        if refresh_cache:\n            self.cache = self.tiles\n            return len(self.tiles)\n        return len(self.cache)\n\n    def get_tile_colors(self, start_index, tile_count=0, x=0, y=0, width=0):\n        return [\n            tile.get_color()\n            for tile in self.tiles[start_index : start_index + tile_count]\n        ]\n\n    def set_tile_colors(\n        self,\n        start_index,\n        colors,\n        duration=0,\n        tile_count=0,\n        x=0,\n        y=0,\n        width=0,\n        rapid=False,\n    ):\n        for index, tile in enumerate(\n            self.tiles[start_index : start_index + tile_count]\n        ):\n            tile.set_color(colors[index])\n\n    def get_tilechain_colors(self):\n        return [tile.get_color() for tile in self.tiles]\n\n    def set_tilechain_colors(self, tilechain_colors, duration=0, rapid=False):\n        for index, tile in enumerate(self.tiles):\n            tile.set_color(tilechain_colors[index])\n\n    def project_matrix(self, hsvk_matrix, duration, rapid):\n        pass\n\n    def get_canvas_dimensions(self, refresh_cache):\n        return self.x, self.y\n\n    def recenter_coordinates(self):\n        pass\n\n    def set_tile_coordinates(self, tile_index, x, y):\n        pass\n\n    def get_tile_map(self, refresh_cache):\n        pass\n\n\nclass LifxLANDummy:\n    def __init__(self, verbose=False):\n        self.devices = {}\n\n    # Non-offical api to manipulate for testing\n    def add_dummy_light(self, light: DummyBulb):\n        self.devices[light.get_label()] = light\n\n    # Official api\n    def get_lights(self):\n        return tuple(self.devices.values())\n\n    def get_color_lights(self):\n        return tuple(light for light in self.devices.values() if light.supports_color())\n\n    def get_infrared_lights(self):\n        return tuple(\n            light for light in self.devices.values() if light.supports_infrared()\n        )\n\n    def get_multizone_lights(self):\n        return tuple(\n            light for light in self.devices.values() if light.supports_multizone()\n        )\n\n    def get_tilechain_lights(self):\n        return tuple(\n            light for light in self.devices.values() if type(light) is TileChainDummy\n        )\n\n    def get_device_by_name(self, name: str):\n        return self.devices[name]\n\n    def get_devices_by_names(self, names: Iterable[str]):\n        return Group(\n            [light for light in self.devices.values() if light.get_label() in names]\n        )\n\n    def get_devices_by_group(self, group_id):\n        return Group(\n            [light for light in self.devices.values() if light.get_group() == group_id]\n        )\n\n    def get_devices_by_location(self, location: str):\n        return Group(\n            [\n                light\n                for light in self.devices.values()\n                if light.get_location() == location\n            ]\n        )\n\n    def set_power_all_lights(self, power, duration=0, rapid=False):\n        for light in self.devices:\n            light.set_power(power)\n\n    def set_color_all_lights(self, color, duration=0, rapid=False):\n        for light in self.devices:\n            light.set_color(color)\n\n    def set_waveform_all_lights(\n        self, is_transient, color, period, cycles, duty_cycle, wavform, rapid=False\n    ):\n        for light in self.devices:\n            light.set_waveform(\n                is_transient, color, period, cycles, duty_cycle, wavform, rapid\n            )\n\n    def get_power_all_lights(self):\n        return dict([((light, light.get_power()) for light in self.devices)])\n\n    def get_color_all_lights(self):\n        return dict([((light, light.get_color()) for light in self.devices)])\n\n\nclass DummyGroup:\n    def __init__(self, devices: list, label: str = \"N/A\"):\n        self.devices = devices\n        self.label = label\n\n    def __iter__(self):\n        return iter(self.devices)\n\n    def add_device(self, device: DummyDevice):\n        self.devices.append(device)\n\n    def remove_device(self, device: DummyDevice):\n        self.devices.remove(device)\n\n    def remove_device_by_name(self, device_name: str):\n        for index, device in enumerate(self.devices):\n            if device.get_label() == device_name:\n                del self.devices[index]\n                break\n\n    def get_device_list(self):\n        return self.devices\n\n    def set_power(self, power, duration=0, rapid=False):\n        for device in self.devices:\n            device.set_power(power, duration, rapid)\n\n    def set_color(self, color, duration=0, rapid=False):\n        for device in self.devices:\n            device.set_color(color, duration, rapid)\n\n    def set_hue(self, hue, duration=0, rapid=False):\n        for device in self.devices:\n            device.set_hue(hue, duration, rapid)\n\n    def set_brightness(self, brightness, duration=0, rapid=False):\n        for device in self.devices:\n            device.set_hue(brightness, duration, rapid)\n\n    def set_saturation(self, saturation, duration=0, rapid=False):\n        for device in self.devices:\n            device.set_hue(saturation, duration, rapid)\n\n    def set_colortemp(self, kelvin, duration=0, rapid=False):\n        for device in self.devices:\n            device.set_hue(kelvin, duration, rapid)\n\n    def set_infrared(self, infrared, duration=0, rapid=False):\n        for device in self.devices:\n            device.set_hue(infrared, duration, rapid)\n\n    def set_zone_color(self, start, end, color, duration=0, rapid=False, apply=1):\n        for device in self.devices[start:end]:\n            device.set_color(color, duration, rapid)\n\n    def set_zone_colors(self, colors, duration=0, rapid=False):\n        for index, device in enumerate(self.devices):\n            device.set_color(colors[index], duration, rapid)\n\n\ndef main():\n    from lifxlan import LifxLAN\n    from ..__main__ import LifxFrame\n\n    # Build mixed list of fake and real lights\n    lifx = LifxLANDummy()\n    lifx.add_dummy_light(DummyBulb(label=\"A Light\"))\n    lifx.add_dummy_light(DummyBulb(label=\"B Light\"))\n    lifx.add_dummy_light(DummyBulb(label=\"C Light\"))\n    lifx.add_dummy_light(DummyBulb(label=\"D Light\"))\n    lifx.add_dummy_light(DummyBulb(label=\"E Light\"))\n    lifx.add_dummy_light(DummyBulb(label=\"F Light\"))\n    lifx.add_dummy_light(DummyBulb(label=\"G Light\"))\n    lifx.add_dummy_light(DummyBulb(label=\"H Light\"))\n    lifx.add_dummy_light(DummyBulb(label=\"I Light\"))\n    lifx.add_dummy_light(DummyBulb(label=\"J Light\"))\n    for light in LifxLAN().get_lights():\n        lifx.add_dummy_light(light)\n\n    root = Tk()\n    root.title(\"lifx_control_panel\")\n\n    # Setup main_icon\n    root.iconbitmap(resource_path(\"res/icon_vector.ico\"))\n\n    root.logger = logging.getLogger(\"root\")\n    root.logger.setLevel(logging.DEBUG)\n    fh = logging.FileHandler(LOGFILE, mode=\"w\")\n    formatter = logging.Formatter(\n        \"%(asctime)s - %(name)s - %(levelname)s - %(message)s\"\n    )\n    fh.setFormatter(formatter)\n    sh = logging.StreamHandler()\n    sh.setLevel(logging.DEBUG)\n    sh.setFormatter(formatter)\n    root.logger.addHandler(sh)\n    root.logger.info(\"Logger initialized.\")\n\n    mainframe = LifxFrame(root, lifx)\n\n    # Setup exception logging\n    logger = mainframe.logger\n\n    def myHandler(type, value, tb):\n        logger.exception(\n            \"Uncaught exception: {}:{}:{}\".format(repr(type), str(value), repr(tb))\n        )\n\n    # sys.excepthook = myHandler  # dont' want to log exceptions when we're testing\n\n    # Run main app\n    root.mainloop()\n\n\nif __name__ == \"__main__\":\n    try:\n        main()\n    except Exception as e:\n        messagebox.showerror(\n            \"Unhandled Exception\",\n            \"Unhandled runtime exception: {}\\n\\n\"\n            \"Please report this at: {}\".format(\n                traceback.format_exc(),\n                r\"https://github.com/samclane/LIFX-Control-Panel/issues\",\n            ),\n        )\n        raise e\n"
  },
  {
    "path": "lifx_control_panel/test/dummy_tests.py",
    "content": "import unittest\n\nfrom test.dummy_devices import *\nfrom utilities.utils import Color\n\n\nclass TestLAN(unittest.TestCase):\n    def setUp(self):\n        self.lifx = LifxLANDummy()\n        self.light_labels = [\"Bedroom Lamp\", \"Patio-Lights\", \"Andy's Room\"]\n\n    def test_add_lights(self):\n        for label in self.light_labels:\n            self.lifx.add_dummy_light(DummyBulb(label=label))\n        for label in self.light_labels:\n            self.assertIn(label, self.lifx.devices.keys())\n\n    def test_set_color_all_lights(self):\n        color = Color(1, 2, 3, 3501)\n        self.lifx.set_color_all_lights(color)\n        for device in self.lifx.get_devices_by_names(self.light_labels).devices:\n            self.assertEqual(color, device.get_color())\n\n    def test_set_power_all_lights(self):\n        power = 1\n        self.lifx.set_power_all_lights(power)\n        for device in self.lifx.get_devices_by_names(self.light_labels).devices:\n            self.assertEqual(power, device.get_power())\n\n\nclass TestDevice(unittest.TestCase):\n    def setUp(self):\n        self.device = DummyDevice(\"TestDevice\")\n\n    def test_set_label(self):\n        current = self.device.get_label()\n        label = \"TestDevice\"\n        self.device.set_label(label)\n        self.assertEqual(label, self.device.get_label())\n        self.device.set_label(current)\n        self.assertEqual(current, self.device.get_label())\n\n\nclass TestBulb(unittest.TestCase):\n    def setUp(self):\n        self.bulb = DummyBulb(label=\"TestBulb\")\n\n    def test_set_label(self):\n        current = self.bulb.get_label()\n        label = \"TestBulb\"\n        self.bulb.set_label(label)\n        self.assertEqual(label, self.bulb.get_label())\n        self.bulb.set_label(current)\n        self.assertEqual(current, self.bulb.get_label())\n\n    def test_power_duration(self):\n        self.skipTest(\"DummyDevice duration not implemented\")\n        self.bulb.set_power(False)\n        self.assertEqual(self.bulb.get_power(), False, \"Bulb init off\")\n        duration = 3\n        self.bulb.set_power(True, duration)\n        self.assertEqual(self.bulb.get_power(), True, \"Duration on\")\n        time.sleep(duration + 1)\n        self.assertEqual(self.bulb.get_power(), False, \"Reset to off\")\n\n    def test_color_duration(self):\n        self.skipTest(\"DummyDevice duration not implemented\")\n        color_a = Color(1, 2, 3, 3501)\n        color_b = Color(4, 5, 6, 6311)\n        self.bulb.set_color(color_a)\n        self.assertEqual(self.bulb.get_color(), color_a, \"bulb init color\")\n        duration = 2\n        self.bulb.set_color(color_b, duration)\n        self.assertEqual(self.bulb.get_color(), color_b, \"bulb change color\")\n        time.sleep(duration + 1)\n        self.assertEqual(self.bulb.get_color(), color_a, \"bulb reset color\")\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "lifx_control_panel/test/functional_test.py",
    "content": "import unittest\nfrom utilities.utils import (\n    Color,\n    hsbk_to_rgb,\n    hsv_to_rgb,\n    tuple2hex,\n    str2list,\n    str2tuple,\n)\n\n\nclass TestFunctions(unittest.TestCase):\n    def setUp(self):\n        pass\n\n    def _cmp_color(self, c, h, s, b, k):\n        self.assertEqual(c.hue, h)\n        self.assertEqual(c.saturation, s)\n        self.assertEqual(c.brightness, b)\n        self.assertEqual(c.kelvin, k)\n\n    def test_color(self):\n        c1 = Color(0, 0, 0, 0)\n        self._cmp_color(c1, 0, 0, 0, 0)\n        for v in c1:\n            self.assertEqual(v, 0)\n\n        c2 = Color(65535, 65535, 65535, 9000)\n        self._cmp_color(c2, 65535, 65535, 65535, 9000)\n\n        c3 = c1 + c2\n        self._cmp_color(c3, 65535, 65535, 65535, 9000)\n\n        self.assertEqual(c3 - c2, c1)\n\n        self.assertEqual(str(c1), \"[0, 0, 0, 0]\")\n\n        c3[0] = 12345\n        self._cmp_color(c3, 12345, 65535, 65535, 9000)\n\n        for i, v in enumerate(c3):\n            self.assertEqual(v, c3[i])\n\n    def test_conversion(self):\n        c1 = Color(0, 0, 0, 0)\n        rgb1 = hsbk_to_rgb(c1)\n        self.assertEqual(rgb1, (0, 0, 0))\n\n        hsv1 = hsv_to_rgb(*(0, 0, 0))\n        self.assertEqual(hsv1, rgb1)\n\n    def test_str_conversion(self):\n        rgb1 = (1, 2, 3)\n        self.assertEqual(tuple2hex(rgb1), \"#010203\")\n\n        strlist_int = \"[1, 2, 3]\"\n        self.assertEqual(str2list(strlist_int, int), [1, 2, 3])\n        self.assertEqual(str2tuple(strlist_int, int), (1, 2, 3))\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "lifx_control_panel/ui/__init__.py",
    "content": ""
  },
  {
    "path": "lifx_control_panel/ui/colorscale.py",
    "content": "import logging\nimport tkinter as tk\nfrom typing import List\n\nfrom ..utilities.utils import tuple2hex, hsv_to_rgb, kelvin_to_rgb\n\n\nclass ColorScale(tk.Canvas):\n    \"\"\"\n    A canvas that displays a color scale.\n    \"\"\"\n\n    def __init__(\n        self,\n        parent,\n        val=0,\n        height=13,\n        width=100,\n        variable=None,\n        from_=0,\n        to=360,\n        command=None,\n        gradient=\"hue\",\n        **kwargs,\n    ):\n        \"\"\"\n        Create a ColorScale.\n        Keyword arguments:\n            * parent: parent window\n            * val: initially selected value\n            * height: canvas length in y direction\n            * width: canvas length in x direction\n            * variable: IntVar linked to the alpha value\n            * from_: The minimum value the slider can take on\n            * to: The maximum value of the slider\n            * command: A function callback, invoked every time the slider is moved\n            * gradient: The type of background coloration\n            * **kwargs: Any other keyword argument accepted by a tkinter Canvas\n        \"\"\"\n        tk.Canvas.__init__(self, parent, width=width, height=height, **kwargs)\n        self.parent = parent\n        self.max = to\n        self.min = from_\n        self.range = self.max - self.min\n        self._variable = variable\n        self.command = command\n        self.color_grad = gradient\n        self.logger = logging.getLogger(self.parent.__class__.__name__ + \".ColorScale\")\n        if variable is not None:\n            try:\n                val = int(variable.get())\n            except Exception as e:\n                self.logger.exception(e)\n        else:\n            self._variable = tk.IntVar(self)\n        val = max(min(self.max, val), self.min)\n        self._variable.set(val)\n        self._variable.trace(\"w\", self._update_val)\n\n        self.gradient = tk.PhotoImage(master=self, width=int(width), height=int(height))\n\n        self.bind(\"<Configure>\", lambda _: self._draw_gradient(val))\n        self.bind(\"<ButtonPress-1>\", self._on_click)\n        # self.bind('<ButtonRelease-1>', self._on_release)\n        self.bind(\"<B1-Motion>\", self._on_move)\n\n    def _draw_gradient(self, val):\n        \"\"\"Draw the gradient and put the cursor on val.\"\"\"\n        self.delete(\"gradient\")\n        self.delete(\"cursor\")\n        del self.gradient\n        width = self.winfo_width()\n        height = self.winfo_height()\n\n        self.gradient = tk.PhotoImage(master=self, width=width, height=height)\n\n        line: List[str] = []\n\n        def gradfunc(x_coord):\n            return line.append(tuple2hex((0, 0, 0)))\n\n        if self.color_grad == \"bw\":\n\n            def gradfunc(x_coord):\n                line.append(tuple2hex((int(float(x_coord) / width * 255),) * 3))\n\n        elif self.color_grad == \"wb\":\n\n            def gradfunc(x_coord):\n                line.append(tuple2hex((int((1 - (float(x_coord) / width)) * 255),) * 3))\n\n        elif self.color_grad == \"kelvin\":\n\n            def gradfunc(x_coord):\n                line.append(\n                    tuple2hex(\n                        kelvin_to_rgb(\n                            int(((float(x_coord) / width) * self.range) + self.min)\n                        )\n                    )\n                )\n\n        elif self.color_grad == \"hue\":\n\n            def gradfunc(x_coord):\n                line.append(tuple2hex(hsv_to_rgb(float(x_coord) / width * 360)))\n\n        else:\n            raise ValueError(f\"gradient value {self.color_grad} not recognized\")\n\n        for x_coord in range(width):\n            gradfunc(x_coord)\n        line: str = \"{\" + \" \".join(line) + \"}\"\n        self.gradient.put(\" \".join([line for _ in range(height)]))\n        self.create_image(0, 0, anchor=\"nw\", tags=\"gradient\", image=self.gradient)\n        self.lower(\"gradient\")\n\n        x_start: float = self.min\n        try:\n            x_start = (val - self.min) / float(self.range) * width\n        except ZeroDivisionError:\n            x_start = self.min\n        self.create_line(\n            x_start, 0, x_start, height, width=4, fill=\"white\", tags=\"cursor\"\n        )\n        self.create_line(x_start, 0, x_start, height, width=2, tags=\"cursor\")\n\n    def _on_click(self, event):\n        \"\"\"Move selection cursor on click.\"\"\"\n        x_coord = event.x\n        if x_coord >= 0:\n            width = self.winfo_width()\n            self.update_slider_value(width, x_coord)\n\n    def update_slider_value(self, width, x_coord):\n        \"\"\"Update the slider value based on slider x coordinate.\"\"\"\n        height = self.winfo_height()\n        for x_start in self.find_withtag(\"cursor\"):\n            self.coords(x_start, x_coord, 0, x_coord, height)\n        self._variable.set(round((float(self.range) * x_coord) / width + self.min, 2))\n        if self.command is not None:\n            self.command()\n\n    def _on_move(self, event):\n        \"\"\"Make selection cursor follow the cursor.\"\"\"\n        x_coord = event.x\n        if x_coord >= 0:\n            width = self.winfo_width()\n            x_coord = min(max(abs(x_coord), 0), width)\n            self.update_slider_value(width, x_coord)\n\n    def _update_val(self, *_):\n        val = int(self._variable.get())\n        val = min(max(val, self.min), self.max)\n        self.set(val)\n        self.event_generate(\"<<HueChanged>>\")\n\n    def get(self):\n        \"\"\"Return val of color under cursor.\"\"\"\n        coords = self.coords(\"cursor\")\n        width = self.winfo_width()\n        return round(self.range * coords[0] / width, 2)\n\n    def set(self, val):\n        \"\"\"Set cursor position on the color corresponding to the value\"\"\"\n        width = self.winfo_width()\n        try:\n            x_coord = (val - self.min) / float(self.range) * width\n        except ZeroDivisionError:\n            return\n        for x_start in self.find_withtag(\"cursor\"):\n            self.coords(x_start, x_coord, 0, x_coord, self.winfo_height())\n        self._variable.set(val)\n"
  },
  {
    "path": "lifx_control_panel/ui/icon_list.py",
    "content": "from __future__ import annotations\nfrom dataclasses import dataclass, field\n\nimport tkinter\nfrom typing import Dict, Union\nfrom PIL import Image as pImage\n\nimport lifxlan\nfrom ..utilities import utils\n\n\n@dataclass\nclass BulbIconListSettings:\n    \"\"\" Encapsulates all constants for the bulb icon list \"\"\"\n\n    window_width: int\n    icon_width: int\n    icon_height: int\n    icon_padding: int\n    highlight_saturation: int\n    color_code: dict[str, int] = field(default_factory=dict)\n\n    def __post_init__(self):\n        self.window_width = max(0, self.window_width)\n        self.icon_width = max(0, self.icon_width)\n        self.icon_height = max(0, self.icon_height)\n        self.icon_padding = max(0, self.icon_padding)\n        self.highlight_saturation = min(max(0, self.highlight_saturation), 255)\n\n\nclass BulbIconList(tkinter.Frame):  # pylint: disable=too-many-instance-attributes\n    \"\"\" Holds the dynamic icons for each Device and Group \"\"\"\n\n    def __init__(self, *args, is_group: bool = False, **kwargs):\n        # Parameters\n        self.is_group = is_group\n\n        # Constants\n        self.settings = BulbIconListSettings(\n            window_width=285,\n            icon_width=50,\n            icon_height=75,\n            icon_padding=5,\n            highlight_saturation=95,\n            color_code={\"BULB_TOP\": 11, \"BACKGROUND\": 15},\n        )\n\n        # Initialization\n        super().__init__(\n            *args,\n            width=self.settings.window_width,\n            height=self.settings.icon_height,\n            **kwargs\n        )\n        self.scroll_x = 0\n        self.scroll_y = 0\n        self.bulb_dict: dict[str, tuple[tkinter.PhotoImage, int, int]] = {}\n        self.canvas = tkinter.Canvas(\n            self,\n            width=self.settings.window_width,\n            height=self.settings.icon_height,\n            scrollregion=(0, 0, self.scroll_x, self.scroll_y),\n        )\n        h_scroll = tkinter.Scrollbar(self, orient=tkinter.HORIZONTAL)\n        h_scroll.pack(side=tkinter.BOTTOM, fill=tkinter.X)\n        h_scroll.config(command=self.canvas.xview)\n        self.canvas.config(\n            width=self.settings.window_width, height=self.settings.icon_height\n        )\n        self.canvas.config(xscrollcommand=h_scroll.set)\n        self.canvas.pack(side=tkinter.LEFT, expand=True, fill=tkinter.BOTH)\n        self.current_icon_width = 0\n        path = self.icon_path()\n        self.original_icon = pImage.open(path).load()\n        self._current_icon = None\n\n    @property\n    def current_icon(self):\n        \"\"\" Returns the name of the currently selected Device/Group \"\"\"\n        return self._current_icon\n\n    def icon_path(self):\n        \"\"\" Returns the correct icon path for single Device or Group \"\"\"\n        return (\n            utils.resource_path(\"res/group.png\")\n            if self.is_group\n            else utils.resource_path(\"res/lightbulb.png\")\n        )\n\n    @property\n    def icon_paths(self) -> Dict[type, Union[int, bytes]]:\n        \"\"\" Returns a dictionary of the icon paths for each device type \"\"\"\n        return {\n            lifxlan.Group: utils.resource_path(\"res/group.png\"),\n            lifxlan.Light: utils.resource_path(\"res/lightbulb.png\"),\n            lifxlan.MultiZoneLight: utils.resource_path(\"res/multizone.png\"),\n        }\n\n    def draw_bulb_icon(self, bulb, label):\n        \"\"\" Given a bulb and a name, add the icon to the end of the row. \"\"\"\n        # Make room on canvas\n        self.scroll_x += self.settings.icon_width\n        self.canvas.configure(scrollregion=(0, 0, self.scroll_x, self.scroll_y))\n        # Build icon\n        path = self.icon_path()\n        sprite = tkinter.PhotoImage(file=path, master=self.master)\n        image = self.canvas.create_image(\n            (\n                self.current_icon_width\n                + self.settings.icon_width\n                - self.settings.icon_padding,\n                self.settings.icon_height / 2 + 2 * self.settings.icon_padding,\n            ),\n            image=sprite,\n            anchor=tkinter.SE,\n            tags=[label],\n        )\n        text = self.canvas.create_text(\n            self.current_icon_width + self.settings.icon_padding / 2,\n            self.settings.icon_height / 2 + 2 * self.settings.icon_padding,\n            text=label[:8],\n            anchor=tkinter.NW,\n            tags=[label],\n        )\n        self.bulb_dict[label] = (sprite, image, text)\n        self.update_icon(bulb)\n        # update sizing info\n        self.current_icon_width += self.settings.icon_width\n\n    def update_icon(self, bulb: lifxlan.Device):\n        \"\"\" If changes have been detected in the interface, update the bulb state. \"\"\"\n        if self.is_group:\n            return\n        try:\n            # this is ugly, but only way to update icon accurately\n            bulb_color = self.master.bulb_interface.color_cache[bulb.label]\n            bulb_power = self.master.bulb_interface.power_cache[bulb.label]\n            bulb_brightness = bulb_color[2]\n            sprite, image, _ = self.bulb_dict[bulb.label]\n        except TypeError:\n            # First run will give us None; Is immediately corrected on next pass\n            return\n        # Calculate what number, 0-11, corresponds to current brightness\n        brightness_scale = (int((bulb_brightness / 65535) * 10) * (bulb_power > 0)) - 1\n        color_string = \"\"\n        for y in range(sprite.height()):  # pylint: disable=invalid-name\n            color_string += \"{\"\n            for x in range(sprite.width()):  # pylint: disable=invalid-name\n                # If the tick is < brightness, color it. Otherwise, set it back to the default color\n                icon_rgb = self.original_icon[x, y][:3]\n                if (\n                    all(\n                        (\n                            v <= brightness_scale\n                            or v == self.settings.color_code[\"BULB_TOP\"]\n                        )\n                        for v in icon_rgb\n                    )\n                    and self.original_icon[x, y][3] == 255\n                ):\n                    bulb_color = (\n                        bulb_color[0],\n                        bulb_color[1],\n                        bulb_color[2],\n                        bulb_color[3],\n                    )\n                    color = utils.hsbk_to_rgb(bulb_color)\n                elif (\n                    all(\n                        v\n                        in (\n                            self.settings.color_code[\"BACKGROUND\"],\n                            self.settings.highlight_saturation,\n                        )\n                        for v in icon_rgb\n                    )\n                    and self.original_icon[x, y][3] == 255\n                ):\n                    color = sprite.get(x, y)[:3]\n                else:\n                    color = icon_rgb\n                color_string += utils.tuple2hex(color) + \" \"\n            color_string += \"} \"\n        # Write the final colorstring to the sprite, then update the GUI\n        sprite.put(color_string, (0, 0, sprite.height(), sprite.width()))\n        self.canvas.itemconfig(image, image=sprite)\n\n    def set_selected_bulb(self, light_name):\n        \"\"\" Highlight the newly selected bulb icon when changed. \"\"\"\n        if self._current_icon:\n            self.clear_selected()\n        sprite, image, _ = self.bulb_dict[light_name]\n        color_string = \"\"\n        for y in range(sprite.height()):  # pylint: disable=invalid-name\n            color_string += \"{\"\n            for x in range(sprite.width()):  # pylint: disable=invalid-name\n                icon_rgb = sprite.get(x, y)[:3]\n                if (\n                    all(v == self.settings.color_code[\"BACKGROUND\"] for v in icon_rgb)\n                    and self.original_icon[x, y][3] == 255\n                ):\n                    color = (self.settings.highlight_saturation,) * 3\n                else:\n                    color = icon_rgb\n                color_string += utils.tuple2hex(color) + \" \"\n            color_string += \"} \"\n        sprite.put(color_string, (0, 0, sprite.height(), sprite.width()))\n        self.canvas.itemconfig(image, image=sprite)\n        self._current_icon = light_name\n\n    def clear_selected(self):\n        \"\"\" Reset background to original state (from highlighted). \"\"\"\n        sprite, image, _ = self.bulb_dict[self._current_icon]\n        color_string = \"\"\n        for y in range(sprite.height()):  # pylint: disable=invalid-name\n            color_string += \"{\"\n            for x in range(sprite.width()):  # pylint: disable=invalid-name\n                icon_rgb = sprite.get(x, y)[:3]\n                if (\n                    all(v == self.settings.highlight_saturation for v in icon_rgb)\n                    and self.original_icon[x, y][3] == 255\n                ):\n                    color = (self.settings.color_code[\"BACKGROUND\"],) * 3\n                else:\n                    color = icon_rgb\n                color_string += utils.tuple2hex(color) + \" \"\n            color_string += \"} \"\n        sprite.put(color_string, (0, 0, sprite.height(), sprite.width()))\n        self.canvas.itemconfig(image, image=sprite)\n        self._current_icon = None\n"
  },
  {
    "path": "lifx_control_panel/ui/settings.py",
    "content": "# -*- coding: utf-8 -*-\n\"\"\"UI Logic and Interface Elements for Settings\n\nThis module contains several ugly God-classes that control the settings GUI functions and reactions.\n\nNotes\n-----\n    Uses a really funky design pattern for a dialog that I copied from an old project. It's bad and I should probably\n    ript it out\n\"\"\"\nimport configparser\nimport logging\nfrom tkinter import ttk\nfrom tkinter import (\n    Toplevel,\n    Frame,\n    Button,\n    ACTIVE,\n    LEFT,\n    YES,\n    Label,\n    Listbox,\n    FLAT,\n    X,\n    BOTH,\n    RAISED,\n    FALSE,\n    VERTICAL,\n    Y,\n    Scrollbar,\n    END,\n    BooleanVar,\n    Checkbutton,\n    StringVar,\n    OptionMenu,\n    Scale,\n    HORIZONTAL,\n    Entry,\n)\nfrom tkinter.colorchooser import askcolor\n\nimport mss\nfrom lifxlan.utils import RGBtoHSBK\n\nfrom ..utilities.keypress import KeybindManager\nfrom ..utilities.utils import resource_path, str2list\n\nconfig = configparser.ConfigParser()  # pylint: disable=invalid-name\nconfig.read([resource_path(\"default.ini\"), \"config.ini\"])\n\n# Compare datetimes\nDATE_FORMAT = \"%Y-%m-%dT%H:%M:%S.%f\"\n\n\n# boilerplate code from http://effbot.org/tkinterbook/tkinter-dialog-windows.htm\nclass Dialog(Toplevel):\n    \"\"\" Template for dialogs that include an Ok and Cancel button, and return validated user input data. \"\"\"\n\n    def __init__(self, parent, title=None):\n        Toplevel.__init__(self, parent)\n        self.transient(parent)\n        if title:\n            self.title(title)\n        self.parent = parent\n        self.result = None\n        body = Frame(self)\n        self.initial_focus = self.body(body)\n        body.pack(padx=5, pady=5)\n        self.buttonbox()\n        self.grab_set()\n        if not self.initial_focus:\n            self.initial_focus = self\n        self.protocol(\"WM_DELETE_WINDOW\", self.cancel)\n        self.geometry(\"+%d+%d\" % (parent.winfo_rootx() + 50, parent.winfo_rooty() + 50))\n        self.initial_focus.focus_set()\n        self.wait_window(self)\n\n    # construction hooks\n    def body(self, master):\n        \"\"\"create dialog body.  return widget that should have initial focus. This method should be overridden\"\"\"\n\n    def buttonbox(self):\n        \"\"\" add standard button box. override if you don't want the standard buttons \"\"\"\n        box = Frame(self)\n        # pylint: disable=invalid-name\n        ok = Button(box, text=\"OK\", width=10, command=self.ok, default=ACTIVE)\n        ok.pack(side=LEFT, padx=5, pady=5)\n        cancel = Button(box, text=\"Cancel\", width=10, command=self.cancel)\n        cancel.pack(side=LEFT, padx=5, pady=5)\n\n        self.bind(\"<Return>\", self.ok)\n        self.bind(\"<Escape>\", self.cancel)\n\n        box.pack()\n\n    def ok(self, _=None):  # pylint: disable=invalid-name\n        \"\"\" Standard ok semantics \"\"\"\n        if not self.validate():\n            self.initial_focus.focus_set()  # put focus back\n            return\n        self.withdraw()\n        self.update_idletasks()\n        self.apply()\n        self.cancel()\n\n    def cancel(self, _=None):\n        \"\"\"put focus back to the parent window\"\"\"\n        self.parent.focus_set()\n        self.destroy()\n        return 0\n\n    # command hooks\n    def validate(self):  # pylint: disable=no-self-use\n        \"\"\" Override \"\"\"\n        return 1  # override\n\n    def apply(self):\n        \"\"\" Override \"\"\"\n\n\nclass MultiListbox(Frame):  # pylint: disable=too-many-ancestors\n    \"\"\" Shows information about items in a column-format\n    https://www.safaribooksonline.com/library/view/python-cookbook/0596001673/ch09s05.html \"\"\"\n\n    def __init__(self, master, lists):\n        Frame.__init__(self, master)\n        self.lists = []\n        for list_, widget in lists:\n            frame = Frame(self)\n            frame.pack(side=LEFT, expand=YES, fill=BOTH)\n            Label(frame, text=list_, borderwidth=1, relief=RAISED).pack(fill=X)\n            list_box = Listbox(\n                frame,\n                width=widget,\n                borderwidth=0,\n                selectborderwidth=0,\n                relief=FLAT,\n                exportselection=FALSE,\n            )\n            list_box.pack(expand=YES, fill=BOTH)\n            self.lists.append(list_box)\n            list_box.bind(\"<B1-Motion>\", lambda e, s=self: s._select(e.y))\n            list_box.bind(\"<Button-1>\", lambda e, s=self: s._select(e.y))\n            list_box.bind(\"<Leave>\", lambda e: \"break\")\n            list_box.bind(\"<B2-Motion>\", lambda e, s=self: s._b2motion(e.x, e.y))\n            list_box.bind(\"<Button-2>\", lambda e, s=self: s._button2(e.x, e.y))\n        frame = Frame(self)\n        frame.pack(side=LEFT, fill=Y)\n        Label(frame, borderwidth=1, relief=RAISED).pack(fill=X)\n        scroll = Scrollbar(frame, orient=VERTICAL, command=self._scroll)\n        scroll.pack(expand=YES, fill=Y)\n        self.lists[0][\"yscrollcommand\"] = scroll.set\n\n    def _select(self, y):  # pylint: disable=invalid-name\n        \"\"\" Select a row when clicked \"\"\"\n        row = self.lists[0].nearest(y)\n        self.selection_clear(0, END)\n        self.selection_set(row)\n        return \"break\"\n\n    def _button2(self, x, y):  # pylint: disable=invalid-name\n        for list_ in self.lists:\n            list_.scan_mark(x, y)\n        return \"break\"\n\n    def _b2motion(self, x, y):  # pylint: disable=invalid-name\n        for list_ in self.lists:\n            list_.scan_dragto(x, y)\n        return \"break\"\n\n    def _scroll(self, *args):\n        \"\"\" Move the list down \"\"\"\n        for list_ in self.lists:\n            list_.yview(*args)\n\n    def curselection(self):\n        \"\"\" Return currently selected list item \"\"\"\n        return self.lists[0].curselection()\n\n    def delete(self, first, last=None):\n        \"\"\" Remove an item from the list and GUI \"\"\"\n        for list_ in self.lists:\n            list_.delete(first, last)\n\n    def get(self, first, last=None):\n        \"\"\" Get specific item from the list \"\"\"\n        result = [list_.get(first, last) for list_ in self.lists]\n        if last:\n            return map(*([None] + result))\n        return result\n\n    def index(self, index):\n        \"\"\" Get index of item at index\"\"\"\n        self.lists[0].index(index)\n\n    def insert(self, index, *elements):\n        \"\"\" Insert element into list\"\"\"\n        for elm in elements:\n            for i, list_ in enumerate(self.lists):\n                list_.insert(index, elm[i])\n\n    def size(self):\n        \"\"\" Size of internal list at call time \"\"\"\n        return self.lists[0].size()\n\n    def see(self, index):\n        \"\"\" Wrapper for see function that calls on each list \"\"\"\n        for list_ in self.lists:\n            list_.see(index)\n\n    def selection_anchor(self, index):\n        for list_ in self.lists:\n            list_.selection_anchor(index)\n\n    def selection_clear(self, first, last=None):\n        \"\"\" Clear selection highlight \"\"\"\n        for list_ in self.lists:\n            list_.selection_clear(first, last)\n\n    def selection_includes(self, index):\n        \"\"\" Check if item at index is in user selection \"\"\"\n        return self.lists[0].selection_includes(index)\n\n    def selection_set(self, first, last=None):\n        \"\"\" Manually change the selection \"\"\"\n        for list_ in self.lists:\n            list_.selection_set(first, last)\n\n\nclass SettingsDisplay(Dialog):\n    \"\"\" Settings form User Interface\"\"\"\n\n    def body(self, master):\n        self.root_window = master.master.master  # This is really gross. I'm sorry.\n        self.logger = logging.getLogger(\n            self.root_window.logger.name + \".SettingsDisplay\"\n        )\n        self.key_listener = KeybindManager(self, sticky=True)\n        # Labels\n        Label(master, text=\"Start Minimized?: \").grid(row=0, column=0)\n        Label(master, text=\"Avg. Monitor Default: \").grid(row=1, column=0)\n        Label(master, text=\"Smooth Transition Time (sec): \").grid(row=2, column=0)\n        Label(master, text=\"Brightness Offset: \").grid(row=3, column=0)\n        Label(master, text=\"Add Preset Color: \").grid(row=4, column=0)\n        Label(master, text=\"Audio Input Source: \").grid(row=5, column=0)\n        Label(master, text=\"Add keyboard shortcut\").grid(row=6, column=0)\n\n        # Widgets\n        # Starting minimized\n        self.start_mini = BooleanVar(\n            master, value=config.getboolean(\"AppSettings\", \"start_minimized\")\n        )\n        self.start_mini_check = Checkbutton(master, variable=self.start_mini)\n\n        # Avg monitor color match\n        self.avg_monitor = StringVar(\n            master, value=config[\"AverageColor\"][\"DefaultMonitor\"]\n        )\n        with mss.mss() as sct:\n            options = [\n                \"full\",\n                \"get_primary_monitor\",\n                *[tuple(m.values()) for m in sct.monitors],\n            ]\n        # lst = get_display_rects()\n        # for i in range(1, len(lst) + 1):\n        #    els = [list(x) for x in itertools.combinations(lst, i)]\n        #    options.extend(els)\n        self.avg_monitor_dropdown = OptionMenu(master, self.avg_monitor, *options)\n\n        self.duration_scale = Scale(\n            master, from_=0, to_=2, resolution=1 / 15, orient=HORIZONTAL\n        )\n        self.duration_scale.set(float(config[\"AverageColor\"][\"Duration\"]))\n\n        self.brightness_offset = Scale(\n            master, from_=0, to_=65535, resolution=1, orient=HORIZONTAL\n        )\n        self.brightness_offset.set(int(config[\"AverageColor\"][\"brightnessoffset\"]))\n\n        # Custom preset color\n        self.preset_color_name = Entry(master)\n        self.preset_color_name.insert(END, \"Enter color name...\")\n        self.preset_color_button = Button(\n            master, text=\"Choose and add!\", command=self.get_color\n        )\n\n        # Audio dropdown\n        device_names = self.master.audio_interface.get_device_names()\n        try:\n            init_string = (\n                \" \"\n                + config[\"Audio\"][\"InputIndex\"]\n                + \" \"\n                + device_names[int(config[\"Audio\"][\"InputIndex\"])]\n            )\n        except ValueError:\n            init_string = \" None\"\n        self.audio_source = StringVar(\n            master, init_string\n        )  # AudioSource index is grabbed from [1], so add a space at [0]\n        as_choices = device_names.items()\n        self.as_dropdown = OptionMenu(master, self.audio_source, *as_choices)\n\n        # Add keybindings\n        light_names = list(self.root_window.device_map.keys())\n        self.keybind_bulb_selection = StringVar(master, value=light_names[0])\n        self.keybind_bulb_dropdown = OptionMenu(\n            master, self.keybind_bulb_selection, *light_names\n        )\n        self.keybind_keys_select = Entry(master)\n        self.keybind_keys_select.insert(END, \"Add key-combo...\")\n        self.keybind_keys_select.config(state=\"readonly\")\n        self.keybind_keys_select.bind(\"<FocusIn>\", self.on_keybind_keys_click)\n        self.keybind_keys_select.bind(\n            \"<FocusOut>\", lambda *_: self.keybind_keys_select.config(state=\"readonly\")\n        )\n        self.keybind_color_selection = StringVar(master, value=\"Color\")\n        self.keybind_color_dropdown = OptionMenu(\n            master,\n            self.keybind_color_selection,\n            *self.root_window.frame_map[\n                self.keybind_bulb_selection.get()\n            ].default_colors,\n            *(\n                [*config[\"PresetColors\"].keys()]\n                if any(config[\"PresetColors\"].keys())\n                else [None]\n            )\n        )\n        self.keybind_add_button = Button(\n            master,\n            text=\"Add keybind\",\n            command=lambda *_: self.register_keybinding(\n                self.keybind_bulb_selection.get(),\n                self.keybind_keys_select.get(),\n                self.keybind_color_selection.get(),\n            ),\n        )\n        self.keybind_delete_button = Button(\n            master, text=\"Delete keybind\", command=self.delete_keybind\n        )\n\n        # Insert\n        self.start_mini_check.grid(row=0, column=1)\n        ttk.Separator(master, orient=HORIZONTAL).grid(\n            row=0, sticky=\"esw\", columnspan=100\n        )\n        self.avg_monitor_dropdown.grid(row=1, column=1)\n        self.duration_scale.grid(row=2, column=1)\n        self.brightness_offset.grid(row=3, column=1)\n        ttk.Separator(master, orient=HORIZONTAL).grid(\n            row=3, sticky=\"esw\", columnspan=100\n        )\n        self.preset_color_name.grid(row=4, column=1)\n        self.preset_color_button.grid(row=4, column=2)\n        ttk.Separator(master, orient=HORIZONTAL).grid(\n            row=4, sticky=\"esw\", columnspan=100\n        )\n        self.as_dropdown.grid(row=5, column=1)\n        ttk.Separator(master, orient=HORIZONTAL).grid(\n            row=5, sticky=\"esw\", columnspan=100\n        )\n        self.keybind_bulb_dropdown.grid(row=6, column=1)\n        self.keybind_keys_select.grid(row=6, column=2)\n        self.keybind_color_dropdown.grid(row=6, column=3)\n        self.keybind_add_button.grid(row=6, column=4)\n        self.mlb = MultiListbox(master, ((\"Bulb\", 5), (\"Keybind\", 5), (\"Color\", 5)))\n        for keypress, fnx in dict(config[\"Keybinds\"]).items():\n            label, color = fnx.split(\":\")\n            self.mlb.insert(END, (label, keypress, color))\n        self.mlb.grid(row=7, columnspan=100, sticky=\"esw\")\n        self.keybind_delete_button.grid(row=8, column=0)\n\n    def validate(self) -> int:\n        config[\"AppSettings\"][\"start_minimized\"] = str(self.start_mini.get())\n        config[\"AverageColor\"][\"DefaultMonitor\"] = str(self.avg_monitor.get())\n        config[\"AverageColor\"][\"Duration\"] = str(self.duration_scale.get())\n        config[\"AverageColor\"][\"BrightnessOffset\"] = str(self.brightness_offset.get())\n        config[\"Audio\"][\"InputIndex\"] = str(self.audio_source.get()[1])\n        # Write to config file\n        with open(\"config.ini\", \"w\", encoding=\"utf-8\") as cfg:\n            config.write(cfg)\n\n        self.key_listener.shutdown()\n\n        return 1\n\n    def get_color(self):\n        \"\"\" Present user with color palette dialog and return color in HSBK \"\"\"\n        color = askcolor()[0]\n        if color:\n            # RGBtoHBSK sometimes returns >65535, so we have to clamp\n            hsbk = [min(c, 65535) for c in RGBtoHSBK(color)]\n            config[\"PresetColors\"][self.preset_color_name.get()] = str(hsbk)\n\n    def register_keybinding(self, bulb: str, keys: str, color: str):\n        \"\"\" Get the keybind from the input box and pass the color off to the root window. \"\"\"\n        try:\n            color = self.root_window.frame_map[\n                self.keybind_bulb_selection.get()\n            ].default_colors[color]\n        except KeyError:  # must be using a custom color\n            color = str2list(config[\"PresetColors\"][color], int)\n        self.root_window.save_keybind(bulb, keys, color)\n        config[\"Keybinds\"][str(keys)] = str(bulb + \":\" + str(color))\n        self.mlb.insert(END, (str(bulb), str(keys), str(color)))\n        self.keybind_keys_select.config(state=\"normal\")\n        self.keybind_keys_select.delete(0, \"end\")\n        self.keybind_keys_select.insert(END, \"Add key-combo...\")\n        self.keybind_keys_select.config(state=\"readonly\")\n        self.preset_color_name.focus_set()  # Set focus to a dummy widget to reset the Entry\n\n    def on_keybind_keys_click(self, event):\n        \"\"\" Call when cursor is in key-combo entry \"\"\"\n        self.update()\n        self.update_idletasks()\n        self.key_listener.restart()\n        self.keybind_keys_select.config(state=\"normal\")\n        self.update()\n        self.update_idletasks()\n        while self.focus_get() == self.keybind_keys_select:\n            self.keybind_keys_select.delete(0, \"end\")\n            self.keybind_keys_select.insert(END, self.key_listener.key_combo_code)\n            self.update()\n            self.update_idletasks()\n\n    def delete_keybind(self):\n        \"\"\" Delete keybind currently selected in the multi-list box. \"\"\"\n        _, keybind, _ = self.mlb.get(ACTIVE)\n        self.mlb.delete(ACTIVE)\n        self.root_window.delete_keybind(keybind)\n        config.remove_option(\"Keybinds\", keybind)\n"
  },
  {
    "path": "lifx_control_panel/ui/splashscreen.py",
    "content": "# -*- coding: utf-8 -*-\n\"\"\"Splash-screen class\n\nDisplays lifx_control_panel's icon while GUI loads\n\"\"\"\nfrom tkinter import Toplevel, Canvas, PhotoImage\n\n\nclass Splash:\n    \"\"\" From http://code.activestate.com/recipes/576936/ \"\"\"\n\n    def __init__(self, root, file):\n        self.__root = root\n        self.__file = file\n        # Save the variables for later cleanup.\n        self.__window = None\n        self.__canvas = None\n        self.__splash = None\n\n    def __enter__(self):\n        # Hide the root while it is built.\n        self.__root.withdraw()\n        # Create components of splash screen.\n        window = Toplevel(self.__root)\n        canvas = Canvas(window)\n        splash = PhotoImage(master=window, file=self.__file)\n        # Get the screen's width and height.\n        screen_width = window.winfo_screenwidth()\n        screen_height = window.winfo_screenheight()\n        # Get the images's width and height.\n        img_width = splash.width()\n        img_height = splash.height()\n        # Compute positioning for splash screen.\n        xpos = (screen_width - img_width) // 2\n        ypos = (screen_height - img_height) // 2\n        # Configure the window showing the logo.\n        window.overrideredirect(True)\n        window.geometry(\"+{}+{}\".format(xpos, ypos))\n        # Setup canvas on which image is drawn.\n        canvas.configure(width=img_width, height=img_height, highlightthickness=0)\n        canvas.grid()\n        # Show the splash screen on the monitor.\n        canvas.create_image(img_width // 2, img_height // 2, image=splash)\n        window.update()\n        # Save the variables for later cleanup.\n        self.__window = window\n        self.__canvas = canvas\n        self.__splash = splash\n\n    def __exit__(self, exc_type, exc_val, exc_tb):\n        # Free used resources in reverse order.\n        del self.__splash\n        self.__canvas.destroy()\n        self.__window.destroy()\n        # Give control back to the root program.\n        self.__root.update_idletasks()\n        self.__root.deiconify()\n"
  },
  {
    "path": "lifx_control_panel/utilities/__init__.py",
    "content": ""
  },
  {
    "path": "lifx_control_panel/utilities/async_bulb_interface.py",
    "content": "# -*- coding: utf-8 -*-\nimport concurrent.futures\nimport logging\nimport queue\nimport threading\nfrom typing import List, Union\n\nimport lifxlan\n\n\nclass AsyncBulbInterface(threading.Thread):\n    \"\"\" Asynchronous networking layer between LIFX devices and the GUI. \"\"\"\n\n    def __init__(self, event, heartbeat_ms):\n        threading.Thread.__init__(self)\n\n        self.stopped = event\n\n        self.hb_rate = heartbeat_ms\n\n        self.device_list = []\n        self.color_queue = {}\n        self.color_cache = {}\n        self.power_queue = {}\n        self.power_cache = {}\n\n        self.logger = logging.getLogger(\"root\")\n\n    def set_device_list(\n        self, device_list: List[lifxlan.Device],\n    ):\n        \"\"\" Set internet device list to passed list of LIFX devices. \"\"\"\n        for dev in device_list:\n            try:\n                label = dev.get_label()\n                self.color_queue[label] = queue.Queue()\n                try:\n                    if dev.supports_multizone():\n                        dev: lifxlan.MultiZoneLight\n                        color = dev.get_color_zones()[0]\n                    else:\n                        color = getattr(dev, \"color\", None)\n                except Exception as e:\n                    self.logger.error(e)\n                    color = None\n                self.color_cache[dev.label] = color\n                self.power_queue[dev.label] = queue.Queue()\n                try:\n                    self.power_cache[dev.label] = dev.power_level or dev.get_power()\n                except Exception as e:\n                    self.logger.error(e)\n                    self.power_cache[dev.label] = 0\n                self.device_list.append(dev)\n            except lifxlan.WorkflowException as exc:\n                self.logger.warning(\n                    \"Error when communicating with LIFX device: %s\", exc\n                )\n\n    def query_device(self, target):\n        \"\"\" Check if target has new state. If it does, push it to the queue and cache the value. \"\"\"\n        try:\n            pwr = target.get_power()\n            if pwr != self.power_cache[target.label]:\n                self.power_queue[target.label].put(pwr)\n                self.power_cache[target.label] = pwr\n            clr = target.get_color()\n            if clr != self.color_cache[target.label]:\n                self.color_queue[target.label].put(clr)\n                self.color_cache[target.label] = clr\n        except lifxlan.WorkflowException:\n            pass\n\n    def run(self):\n        \"\"\" Continuous loop that has a thread query each device every HEARTBEAT ms. \"\"\"\n        with concurrent.futures.ThreadPoolExecutor(\n            max_workers=max(1, len(self.device_list))\n        ) as executor:\n            while not self.stopped.wait(self.hb_rate / 1000):\n                executor.map(self.query_device, self.device_list)\n"
  },
  {
    "path": "lifx_control_panel/utilities/audio.py",
    "content": "# -*- coding: utf-8 -*-\n\"\"\"Audio Processing Tools\n\nTools for real-time audio processing and color-following. For co-use with color_threads.py\n\nNotes\n-----\n    Not really complete yet; still need to integrate with other screen averaging functions.\n\"\"\"\nimport audioop\nfrom collections import deque\nfrom math import ceil\nfrom tkinter import messagebox\n\nimport pyaudio\n\n# Audio processing constants\nCHUNK = 1024\nFORMAT = pyaudio.paInt16\nCHANNELS = 2\nRATE = 44100\n\n# RMS -> Brightness control constants\nSCALE = 8  # Change if too dim/bright\nEXPONENT = 2  # Change if too little/too much difference between loud and quiet sounds\nN_POINTS = 15  # Length of sliding average window for smoothing\n\n\nclass AudioInterface:\n    \"\"\" Instantiate a connection to audio device (selected in Settings). Also provides a color-following function for\n     music intensity. \"\"\"\n\n    def __init__(self):\n        self.interface = pyaudio.PyAudio()\n        self.num_devices = 0\n        self.stream = None\n        self.initialized = False\n        self.window = deque([0] * N_POINTS)\n\n    def init_audio(self, config):\n        \"\"\" Attempt to make a connection to the audio device given in config.ini or Stereo Mix. Will attempt\n         to automatically find a Stereo Mix \"\"\"\n        if self.initialized:\n            self.interface.close(self.stream)\n            self.num_devices = 0\n        try:\n            self.init_configured_device(config)\n        except (ValueError, OSError) as exc:\n            if self.initialized:  # only show error if main app has already started\n                messagebox.showerror(\"Invalid Sound Input\", exc)\n            self.initialized = False\n\n    def init_configured_device(self, config):\n        # Find input device index\n        info = self.interface.get_host_api_info_by_index(0)\n        self.num_devices = info.get(\"deviceCount\")\n        # If a setting is found, use it. Otherwise, try and find Stereo Mix\n        if config.has_option(\"Audio\", \"InputIndex\"):\n            input_device_index = int(config[\"Audio\"][\"InputIndex\"])\n        else:\n            input_device_index = self.get_stereo_mix_index()\n            config[\"Audio\"][\"InputIndex\"] = str(input_device_index)\n            with open(\"config.ini\", \"w\") as cfg:\n                config.write(cfg)\n        if input_device_index is None:\n            raise OSError(\"No Input channel found. Disabling Sound Integration.\")\n        self.stream = self.interface.open(\n            format=FORMAT,\n            channels=CHANNELS,\n            rate=RATE,\n            input=True,\n            frames_per_buffer=CHUNK,\n            input_device_index=input_device_index,\n        )\n        self.initialized = True\n\n    def get_stereo_mix_index(self):\n        \"\"\" Naively get stereo mix, as it's probably the best input \"\"\"\n        device_index = None\n        for i in range(self.num_devices):\n            if (\n                \"stereo mix\"\n                in self.interface.get_device_info_by_host_api_device_index(0, i)[\n                    \"name\"\n                ].lower()\n            ):\n                device_index = self.interface.get_device_info_by_host_api_device_index(\n                    0, i\n                )[\"index\"]\n        return device_index\n\n    def get_device_names(self):\n        \"\"\" Get names of all audio devices\"\"\"\n        devices = {}\n        for i in range(self.num_devices):\n            info = self.interface.get_device_info_by_host_api_device_index(0, i)\n            devices[info[\"index\"]] = info[\"name\"]\n        return devices\n\n    def get_music_color(self, initial_color, alpha=0.99):\n        \"\"\" Calculate the RMS power of the waveform, and return that as the initial_color with the calculated brightness\n        \"\"\"\n        data = self.stream.read(CHUNK)\n        frame_rms = audioop.rms(data, 2)\n        level = min(frame_rms / (2.0 ** 16) * SCALE, 1.0)\n        level = level ** EXPONENT\n        level = int(level * 65535)\n        self.window.rotate(1)  # FILO Queue\n        # window = deque([a*x for x in window])  # exp decay\n        self.window[0] = level\n        brightness = ceil(sum(self.window) / N_POINTS)\n        return initial_color[0], initial_color[1], brightness, initial_color[3]\n"
  },
  {
    "path": "lifx_control_panel/utilities/color_thread.py",
    "content": "# -*- coding: utf-8 -*-\n\"\"\"Multi-Threaded Color Changer\n\nContains several basic \"Color-Following\" functions, as well as custom Stop/Start threads for these effects.\n\"\"\"\nimport logging\nimport threading\nfrom functools import lru_cache\nfrom typing import List, Tuple\nimport time\nimport math\n\nimport mss\nimport numexpr as ne\nimport numpy as np\n\n# from lib.color_functions import dominant_color\nfrom PIL import Image\nfrom lifxlan import utils\n\nfrom .utils import str2list, Color\nfrom ..ui.settings import config\n\nfrom lifx_control_panel.utilities.utils import hsv_to_rgb\n\n\n@lru_cache(maxsize=32)\ndef get_monitor_bounds(func):\n    \"\"\" Returns the rectangular coordinates of the desired Avg. Screen area. Can pass a function to find the result\n    procedurally \"\"\"\n    return func() or config[\"AverageColor\"][\"DefaultMonitor\"]\n\n\ndef get_screen_as_image():\n    \"\"\"Grabs the entire primary screen as an image\"\"\"\n    with mss.mss() as sct:\n        monitor = sct.monitors[0]\n\n        # Capture a bbox using percent values\n        left = monitor[\"left\"]  # + monitor[\"width\"] * 5 // 100  # 5% from the left\n        top = monitor[\"top\"]  # + monitor[\"height\"] * 5 // 100  # 5% from the top\n        right = monitor[\"left\"] + monitor[\"width\"]  # left + 400  # 400px width\n        lower = monitor[\"top\"] + monitor[\"height\"]  # top + 400  # 400px height\n        bbox = (left, top, right, lower)\n        sct_img = sct.grab(bbox)\n        return Image.frombytes(\"RGB\", sct_img.size, sct_img.bgra, \"raw\", \"BGRX\")\n\n\ndef get_rect_as_image(bounds: Tuple[int, int, int, int]):\n    \"\"\" Grabs a rectangular area of the primary screen as an image \"\"\"\n    with mss.mss() as sct:\n        monitor = {\n            \"left\": bounds[0],\n            \"top\": bounds[1],\n            \"width\": bounds[2],\n            \"height\": bounds[3],\n        }\n        sct_img = sct.grab(monitor)\n        return Image.frombytes(\"RGB\", sct_img.size, sct_img.bgra, \"raw\", \"BGRX\")\n\n\ndef normalize_rectangles(rects: List[Tuple[int, int, int, int]]):\n    \"\"\" Normalize the rectangles to the monitor size \"\"\"\n    x_min = min(rect[0] for rect in rects)\n    y_min = min(rect[1] for rect in rects)\n    return [\n        (-x_min + left, -y_min + top, -x_min + right, -y_min + bottom,)\n        for left, top, right, bottom in rects\n    ]\n\n\nclass ColorCycle:\n    def __init__(self):\n        self.initial_color = Color(255, 0, 0, 0)\n        self.last_change = time.time()\n        self.pos = 0\n        self.cycle_color = hsv_to_rgb(self.pos, 1, 1)\n\n    def get_color(self, *args, **kwargs):\n        if time.time() - self.last_change > 0.1:\n            self.pos = (self.pos + 1) % 360\n            self.cycle_color = hsv_to_rgb(self.pos, 1, self.initial_color[2] / 65535)\n            self.last_change = time.time()\n        return list(\n            utils.RGBtoHSBK(self.cycle_color, temperature=self.initial_color[3])\n        )\n\n    def __call__(self, initial_color):\n        self.initial_color = initial_color\n        return self.get_color()\n\n    def __name__(self):\n        return \"ColorCycle\"\n\n\ndef avg_screen_color(initial_color, func_bounds=lambda: None):\n    \"\"\" Capture an image of the monitor defined by func_bounds, then get the average color of the image in HSBK \"\"\"\n    monitor = get_monitor_bounds(func_bounds)\n    if \"full\" in monitor:\n        screenshot = get_screen_as_image()\n    else:\n        screenshot = get_rect_as_image(str2list(monitor, int))\n    # Resizing the image to 1x1 pixel will give us the average for the whole image (via HAMMING interpolation)\n    color = screenshot.resize((1, 1), Image.HAMMING).getpixel((0, 0))\n    return list(utils.RGBtoHSBK(color, temperature=initial_color[3]))\n\n\ndef dominant_screen_color(initial_color, func_bounds=lambda: None):\n    \"\"\"\n    Gets the dominant color of the screen defined by func_bounds\n    https://stackoverflow.com/questions/50899692/most-dominant-color-in-rgb-image-opencv-numpy-python\n    \"\"\"\n    monitor = get_monitor_bounds(func_bounds)\n    if \"full\" in monitor:\n        screenshot = get_screen_as_image()\n    else:\n        screenshot = get_rect_as_image(str2list(monitor, int))\n\n    downscale_width, downscale_height = screenshot.width // 4, screenshot.height // 4\n    screenshot = screenshot.resize((downscale_width, downscale_height), Image.HAMMING)\n\n    a = np.array(screenshot)\n    a2D = a.reshape(-1, a.shape[-1])\n    col_range = (256, 256, 256)  # generically : a2D.max(0)+1\n    eval_params = {\n        \"a0\": a2D[:, 0],\n        \"a1\": a2D[:, 1],\n        \"a2\": a2D[:, 2],\n        \"s0\": col_range[0],\n        \"s1\": col_range[1],\n    }\n    a1D = ne.evaluate(\"a0*s0*s1+a1*s0+a2\", eval_params)\n    color = np.unravel_index(np.bincount(a1D).argmax(), col_range)\n\n    return list(utils.RGBtoHSBK(color, temperature=initial_color[3]))\n\n\nclass ColorThread(threading.Thread):\n    \"\"\" A Simple Thread which runs when the _stop event isn't set \"\"\"\n\n    def __init__(self, *args, **kwargs):\n        super().__init__(*args, daemon=True, **kwargs)\n        self._stop = threading.Event()\n\n    def stop(self):\n        \"\"\" Stop thread by setting event \"\"\"\n        self._stop.set()\n\n    def stopped(self):\n        \"\"\" Check if thread has been stopped \"\"\"\n        return self._stop.isSet()\n\n\nclass ColorThreadRunner:\n    \"\"\" Manages an asynchronous color-change with a Device. Can be run continuously, stopped and started. \"\"\"\n\n    def __init__(self, bulb, color_function, parent, continuous=True, **kwargs):\n        self.bulb = bulb\n        self.color_function = color_function\n        self.kwargs = kwargs\n        self.parent = parent  # couple to parent frame\n        self.logger = logging.getLogger(\n            parent.logger.name + f\".Thread({color_function.__name__})\"\n        )\n        self.prev_color = parent.get_color_values_hsbk()\n        self.continuous = continuous\n        self.thread = ColorThread(target=self.match_color, args=(self.bulb,))\n        try:\n            label = self.bulb.get_label()\n        except:  # pylint: disable=bare-except\n            # If anything goes wrong in getting the label just set it to ERR; we really don't care except for logging.\n            label = \"<LABEL-ERR>\"\n        self.logger.info(\n            \"Initialized Thread: Bulb: %s // Continuous: %s\", label, self.continuous\n        )\n\n    def match_color(self, bulb):\n        \"\"\" ColorThread target which calls the 'change_color' function on the bulb. \"\"\"\n        self.logger.debug(\"Starting color match.\")\n        self.prev_color = (\n            self.parent.get_color_values_hsbk()\n        )  # coupling to LightFrame from gui.py here\n        while not self.thread.stopped():\n            try:\n                color = list(\n                    self.color_function(initial_color=self.prev_color, **self.kwargs)\n                )\n                color[2] = min(color[2] + self.get_brightness_offset(), 65535)\n                bulb.set_color(\n                    color, duration=self.get_duration() * 1000, rapid=self.continuous\n                )\n                self.prev_color = color\n            except OSError:\n                # This is dirty, but we really don't care, just keep going\n                self.logger.info(\"Hit an os error\")\n                continue\n            if not self.continuous:\n                self.stop()\n        self.logger.debug(\"Color match finished.\")\n\n    def start(self):\n        \"\"\" Start the match_color thread\"\"\"\n        if self.thread.stopped():\n            self.thread = ColorThread(target=self.match_color, args=(self.bulb,))\n            self.thread.setDaemon(True)\n        try:\n            self.thread.start()\n            self.logger.debug(\"Thread started.\")\n        except RuntimeError:\n            self.logger.error(\"Tried to start ColorThread again.\")\n\n    def stop(self):\n        \"\"\" Stop the match_color thread\"\"\"\n        self.thread.stop()\n\n    @staticmethod\n    def get_duration():\n        \"\"\" Read the transition duration from the config file. \"\"\"\n        return float(config[\"AverageColor\"][\"duration\"])\n\n    @staticmethod\n    def get_brightness_offset():\n        \"\"\" Read the brightness offset from the config file. \"\"\"\n        return int(config[\"AverageColor\"][\"brightnessoffset\"])\n\n\ndef install_thread_excepthook():\n    \"\"\"\n    Workaround for sys.excepthook thread bug\n    (https://sourceforge.net/tracker/?func=detail&atid=105470&aid=1230540&group_label=5470).\n    Call once from __main__ before creating any threads.\n    If using psyco, call psycho.cannotcompile(threading.Thread.run)\n    since this replaces a new-style class method.\n    \"\"\"\n    import sys\n\n    run_old = threading.Thread.run\n\n    def run(*args, **kwargs):\n        \"\"\" Monkey-patch for the run function that installs local excepthook \"\"\"\n        try:\n            run_old(*args, **kwargs)\n        except (KeyboardInterrupt, SystemExit):\n            raise\n        except:  # pylint: disable=bare-except\n            sys.excepthook(*sys.exc_info())\n\n    threading.Thread.run = run\n\n\ninstall_thread_excepthook()\n"
  },
  {
    "path": "lifx_control_panel/utilities/keypress.py",
    "content": "# -*- coding: utf-8 -*-\n\"\"\"Keyboard shortcut interface\n\nContains single class for interfacing with user IO and binding to functions\n\"\"\"\n\nimport logging\n\nimport keyboard\n\n\nclass KeybindManager:\n    \"\"\" Interface with Mouse/Keyboard and register functions to keyboard shortcuts. \"\"\"\n\n    def __init__(self, master, sticky=False):\n        self.logger = logging.getLogger(master.logger.name + \".Keystroke_Watcher\")\n        self.keys_held = set()\n        self.sticky = sticky\n        self.hooks = {}\n        keyboard.on_press(lambda e: self.keys_held.add(e.name))\n        keyboard.on_release(lambda e: self.keys_held.discard(e.name))\n\n    @property\n    def key_combo_code(self) -> str:\n        \"\"\" Converts the keys currently being held into a string representing the combination \"\"\"\n        return \"+\".join(self.keys_held)\n\n    def register_function(self, key_combo, function):\n        \"\"\" Register function callback to key_combo \"\"\"\n        cb = keyboard.add_hotkey(key_combo, function)\n        self.hooks[key_combo] = cb\n        self.logger.info(\n            \"Registered function <%s> to keycombo <%s>.\",\n            function.__name__,\n            key_combo.lower(),\n        )\n\n    def unregister_function(self, key_combo):\n        \"\"\" Stop tracking function at key_combo \"\"\"\n        keyboard.remove_hotkey(key_combo)\n        self.logger.info(\n            \"Unregistered function at keycombo <%s>\", key_combo.lower(),\n        )\n\n    def _on_key_down(self, event: keyboard.KeyboardEvent):\n        \"\"\" Simply adds the key to keys held. \"\"\"\n        try:\n            self.keys_held.add(event.name)\n        except Exception as exc:\n            self.logger.error(\"Error in _on_key_down, %s\", exc)\n        return True\n\n    def _on_key_up(self, event: keyboard.KeyboardEvent):\n        \"\"\" If a function for the given key_combo is found, call it \"\"\"\n        if not self.sticky and event.name in self.keys_held:\n            self.keys_held.discard(event.name)\n\n    def shutdown(self):\n        \"\"\" Stop following keyboard events. \"\"\"\n        keyboard.unhook_all()\n\n    def restart(self):\n        \"\"\" Clear keys held and rehook keyboard. \"\"\"\n        self.keys_held = set()\n        for keycombo, cb in self.hooks.items():\n            keyboard.register_hotkey(keycombo, cb)\n"
  },
  {
    "path": "lifx_control_panel/utilities/utils.py",
    "content": "# -*- coding: utf-8 -*-\n\"\"\"General utility classes and functions.\"\"\"\nimport os\nimport sys\nfrom functools import lru_cache\nfrom math import log, floor\nfrom typing import Union, Tuple, List\n\nimport mss\n\n\nclass Color:\n    \"\"\" Container class for a single color vector in HSBK color-space. \"\"\"\n\n    __slots__ = [\"hue\", \"saturation\", \"brightness\", \"kelvin\"]\n\n    def __init__(self, hue: int, saturation: int, brightness: int, kelvin: int):\n        self.hue = hue\n        self.saturation = saturation\n        self.brightness = brightness\n        self.kelvin = kelvin\n\n    def __getitem__(self, item) -> int:\n        return self.__getattribute__(self.__slots__[item])\n\n    def __len__(self) -> int:\n        return 4\n\n    def __setitem__(self, key, value):\n        self.__setattr__(self.__slots__[key], value)\n\n    def __str__(self) -> str:\n        return f\"[{self.hue}, {self.saturation}, {self.brightness}, {self.kelvin}]\"\n\n    def __repr__(self) -> str:\n        return [self.hue, self.saturation, self.brightness, self.kelvin].__repr__()\n\n    def __eq__(self, other) -> bool:\n        return (\n            self.hue == other.hue\n            and self.brightness == other.brightness\n            and self.saturation == other.saturation\n            and self.kelvin == other.kelvin\n        )\n\n    def __add__(self, other):\n        return Color(\n            self.hue + other[0],\n            self.saturation + other[1],\n            self.brightness + other[2],\n            self.kelvin + other[3],\n        )\n\n    def __sub__(self, other):\n        return self.__add__([-v for v in other])\n\n    def __iter__(self):\n        return iter([self.hue, self.saturation, self.brightness, self.kelvin])\n\n\n# Derived types\nTypeRGB = Union[Tuple[int, int, int], Color]\nTypeHSBK = Union[Tuple[int, int, int, int], Color]\n\n\ndef hsbk_to_rgb(hsvk: TypeHSBK) -> TypeRGB:\n    \"\"\" Convert Tuple in HSBK color-space to RGB space.\n    Converted from PHP https://gist.github.com/joshrp/5200913 \"\"\"\n    # pylint: disable=invalid-name\n    iH, iS, iV, iK = hsvk\n    dS = (100 * iS / 65535) / 100.0  # Saturation: 0.0-1.0\n    dV = (100 * iV / 65535) / 100.0  # Lightness: 0.0-1.0\n    dC = dV * dS  # Chroma: 0.0-1.0\n    dH = (360 * iH / 65535) / 60.0  # H-prime: 0.0-6.0\n    dT = dH  # Temp variable\n\n    while dT >= 2.0:  # php modulus does not work with float\n        dT -= 2.0\n    dX = dC * (1 - abs(dT - 1))\n\n    dHf = floor(dH)\n    if dHf == 0:\n        dR = dC\n        dG = dX\n        dB = 0.0\n    elif dHf == 1:\n        dR = dX\n        dG = dC\n        dB = 0.0\n    elif dHf == 2:\n        dR = 0.0\n        dG = dC\n        dB = dX\n    elif dHf == 3:\n        dR = 0.0\n        dG = dX\n        dB = dC\n    elif dHf == 4:\n        dR = dX\n        dG = 0.0\n        dB = dC\n    elif dHf == 5:\n        dR = dC\n        dG = 0.0\n        dB = dX\n    else:\n        dR = 0.0\n        dG = 0.0\n        dB = 0.0\n\n    dM = dV - dC\n    dR += dM\n    dG += dM\n    dB += dM\n\n    # Finally, factor in Kelvin\n    # Adopted from:\n    # https://github.com/tort32/LightServer/blob/master/src/main/java/com/github/tort32/api/nodemcu/protocol/RawColor.java#L125\n    rgb_hsb = int(dR * 255), int(dG * 255), int(dB * 255)\n    rgb_k = kelvin_to_rgb(iK)\n    a = iS / 65535.0\n    b = (1.0 - a) / 255\n    x = int(rgb_hsb[0] * (a + rgb_k[0] * b))\n    y = int(rgb_hsb[1] * (a + rgb_k[1] * b))\n    z = int(rgb_hsb[2] * (a + rgb_k[2] * b))\n    return x, y, z\n\n\ndef hsv_to_rgb(h: float, s: float = 1, v: float = 1) -> TypeRGB:\n    \"\"\" Convert a Hue-angle to an RGB value for display. \"\"\"\n    # pylint: disable=invalid-name\n    h = float(h)\n    s = float(s)\n    v = float(v)\n    h60 = h / 60.0\n    h60f = floor(h60)\n    hi = int(h60f) % 6\n    f = h60 - h60f\n    p = v * (1 - s)\n    q = v * (1 - f * s)\n    t = v * (1 - (1 - f) * s)\n    r, g, b = 0, 0, 0\n    if hi == 0:\n        r, g, b = v, t, p\n    elif hi == 1:\n        r, g, b = q, v, p\n    elif hi == 2:\n        r, g, b = p, v, t\n    elif hi == 3:\n        r, g, b = p, q, v\n    elif hi == 4:\n        r, g, b = t, p, v\n    elif hi == 5:\n        r, g, b = v, p, q\n    r, g, b = int(r * 255), int(g * 255), int(b * 255)\n    return r, g, b\n\n\ndef kelvin_to_rgb(temperature: int) -> TypeRGB:\n    \"\"\" Convert a Kelvin (K) color-temperature to an RGB value for display.\"\"\"\n    # pylint: disable=invalid-name\n    temperature /= 100\n    if temperature <= 66:\n        red = 255\n        green = temperature\n        green = 99.4708025861 * log(green + 0.0000000001) - 161.1195681661\n    else:\n        red = temperature - 60\n        red = 329.698727466 * (red ** -0.1332047592)\n        red = max(red, 0)\n        red = min(red, 255)\n        green = temperature - 60\n        green = 288.1221695283 * (green ** -0.0755148492)\n    green = max(green, 0)\n    green = min(green, 255)\n    # calc blue\n    if temperature >= 66:\n        blue = 255\n    elif temperature <= 19:\n        blue = 0\n    else:\n        blue = temperature - 10\n        blue = 138.5177312231 * log(blue) - 305.0447927307\n        blue = max(blue, 0)\n        blue = min(blue, 255)\n    return int(red), int(green), int(blue)\n\n\ndef tuple2hex(tuple_: TypeRGB) -> str:\n    \"\"\" Takes a color in tuple form and converts it to hex. \"\"\"\n    return \"#%02x%02x%02x\" % tuple_\n\n\ndef str2list(string: str, type_func) -> List:\n    \"\"\" Takes a Python list-formatted string and returns a list of elements of type type_func \"\"\"\n    return list(map(type_func, string.strip(\"()[]\").split(\",\")))\n\n\ndef str2tuple(string: str, type_func) -> Tuple:\n    \"\"\" Takes a Python list-formatted string and returns a tuple of type type_func \"\"\"\n    return tuple(map(type_func, string.strip(\"()[]\").split(\",\")))\n\n\n# Multi monitor methods\n@lru_cache(maxsize=None)\ndef get_primary_monitor() -> Tuple[int, ...]:\n    \"\"\" Return the system's default primary monitor rectangle bounds. \"\"\"\n    return [rect for rect in get_display_rects() if rect[:2] == (0, 0)][\n        0\n    ]  # primary monitor has top left as 0, 0\n\n\ndef resource_path(relative_path) -> Union[int, bytes]:\n    \"\"\" Get absolute path to resource, works for dev and for PyInstaller \"\"\"\n    try:\n        # PyInstaller creates a temp folder and stores path in _MEIPASS\n        base_path = sys._MEIPASS  # pylint: disable=protected-access,no-member\n    except Exception:  # pylint: disable=broad-except\n        base_path = os.path.abspath(\"../\")\n\n    return os.path.join(base_path, relative_path)\n\n\ndef get_display_rects():\n    \"\"\" Return a list of tuples of monitor rectangles. \"\"\"\n    with mss.mss() as sct:\n        return [tuple(m.values()) for m in sct.monitors]\n"
  },
  {
    "path": "requirements-dev.txt",
    "content": "pre-commit\nblack\ncoverage\n"
  },
  {
    "path": "requirements.txt",
    "content": "keyboard\nmouse\nPillow\ngit+https://github.com/samclane/lifxlan@master#egg=lifxlan\nnumexpr\nnumpy\nmss\npystray\npywin32\n# pyaudio"
  },
  {
    "path": "setup.cfg",
    "content": "# Inside of setup.cfg\n[metadata]\ndescription-file = README.md"
  },
  {
    "path": "setup.py",
    "content": "from setuptools import setup, find_packages\n\nfrom lifx_control_panel._constants import VERSION\n\nwith open(\"README.md\", \"r\") as f:\n    long_description = f.read()\n\nsetup(\n    name=\"lifx_control_panel\",\n    version=str(VERSION),\n    description=\"An open source application for controlling your LIFX brand lights\",\n    url=\"http://github.com/samclane/LIFX-Control-Panel\",\n    author=\"Sawyer McLane\",\n    author_email=\"samclane@gmail.com\",\n    license=\"MIT\",\n    packages=find_packages(),\n    zip_safe=False,\n    scripts=[\"lifx_control_panel/__main__.pyw\"],\n    include_package_data=True,\n    keywords=[\"lifx\", \"iot\", \"smartbulb\", \"smartlight\", \"lan\", \"application\"],\n    install_requires=[\n        \"keyboard\",\n        \"mouse\",\n        \"pyaudio\",\n        \"Pillow\",\n        \"lifxlan\",\n        \"numexpr\",\n        \"numpy\",\n        \"mss\",\n        \"pystray\",\n    ],\n    download_url=(\n        (\"https://github.com/samclane/LIFX-Control-Panel/archive/\" + str(VERSION))\n        + \".tar.gz\"\n    ),\n    long_description_content_type=\"text/markdown\",\n    long_description=long_description,\n    classifiers=[\n        # How mature is this project? Common values are\n        #   3 - Alpha\n        #   4 - Beta\n        #   5 - Production/Stable\n        \"Development Status :: 5 - Production/Stable\",\n        # Indicate who your project is intended for\n        \"Intended Audience :: End Users/Desktop\",\n        \"Natural Language :: English\",\n        \"Operating System :: Microsoft :: Windows :: Windows 10\",\n        \"Operating System :: Microsoft :: Windows :: Windows 8.1\",\n        \"Operating System :: Microsoft :: Windows :: Windows 8\",\n        \"Operating System :: Microsoft :: Windows :: Windows 7\",\n        \"Topic :: Home Automation\",\n        # Pick your license as you wish (should match \"license\" above)\n        \"License :: OSI Approved :: MIT License\",\n        # Specify the Python versions you support here. In particular, ensure\n        # that you indicate whether you support Python 2, Python 3 or both.\n        \"Programming Language :: Python :: 3.8\",\n    ],\n)\n"
  }
]