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

Screenshot

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