diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..be0f256
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,174 @@
+# 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/
+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
+
+# poetry
+# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+# in version control.
+# https://pdm.fming.dev/#use-with-ide
+.pdm.toml
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+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/
+
+# PyCharm
+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+# and can be added to the global gitignore or merged into this file. For a more nuclear
+# option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
+
+# vscode
+.vscode
+
+# ruff
+.ruff_cache
+
+# macos
+*.DS_Store
+
+*.pyc
+
+# devices.json
+devices.json
diff --git a/GameSync.py b/GameSync.py
deleted file mode 100644
index 11d35d6..0000000
--- a/GameSync.py
+++ /dev/null
@@ -1,189 +0,0 @@
-# pywin32 must be installed
-# pip install wmi
-
-import win32gui
-import win32process
-import win32pdhutil
-import wmi
-import time
-import socket
-import json
-import math
-import threading
-from ctypes import *
-import ctypes.wintypes
-
-#Globals, you should not change these, leave them as is.
-appChange = True
-sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
-
-#Constants, you should change these.
-GoveeDeviceIP = '10.1.1.43'
-boxSize = 50
-
-
-def GoveeInternalControl(Command,brightness=0,color=None,Loop=False,UDP_IP=GoveeDeviceIP,UDP_PORT=4003):
- lastColor = None
- global sock
- global stopLoop
-
- def SendCommand(message,UDP_IP,UDP_PORT):
- jsonResult = json.dumps(message)
- # print("Sending: {}".format(Command))
- sock.sendto(bytes(jsonResult, "utf-8"), (UDP_IP, UDP_PORT))
-
- try:
- if Command == "On":
- message = {
- "msg":{
- "cmd":"turn",
- "data":{
- "value":1,
- }
- }
- }
- elif Command == "Off":
- message = {
- "msg":{
- "cmd":"turn",
- "data":{
- "value":0,
- }
- }
- }
- elif Command == "Color":
- message = {
- "msg":{
- "cmd":"colorwc",
- "data":{
- "color":{"r":color[0],"g":color[1],"b":color[2]},
- "colorTemInKelvin":0
- }
- }
- }
- elif Command == "BrightLevel":
- message = {
- "msg":{
- "cmd":"brightness",
- "data":{
- "value":brightness,
- }
- }
- }
- else:
- raise ValueError("Bad Command: {}".format(Command))
-
- if Loop == False:
- print("\nGovee Internal Control: {}".format(Command))
-
- SendCommand(message,UDP_IP,UDP_PORT)
- except Exception as E:
- print("\nGovee Internal Control Error: {}".format(str(E)))
-
-def DetectFocusChange():
- global appChange
- currentPID = ""
- while(1):
- time.sleep(1.5)
- whndl = win32gui.GetForegroundWindow()
- tempPID = (win32process.GetWindowThreadProcessId(whndl))[1]
- if currentPID != tempPID:
- print("\nDetected Window Change: {}".format(tempPID))
- currentPID = tempPID
- appChange = True
-
-def GameTime():
- global appChange
- global boxSize
- lastColor = None
- pid = ""
-
- def GetColors(handle,upperLeft,lowerLeft,upperRight,lowerRight):
- try:
- def rgbint2rgbtuple(RGBint,skew,):
- red = RGBint & 255
- green = (RGBint >> 8) & 255
- blue = (RGBint >> 16) & 255
- return (red * skew,green * skew,blue *skew)
-
- pixels = [rgbint2rgbtuple(win32gui.GetPixel(handle,upperLeft[0],upperLeft[1]),1),
- rgbint2rgbtuple(win32gui.GetPixel(handle,lowerLeft[0],lowerLeft[1]),1),
- rgbint2rgbtuple(win32gui.GetPixel(handle,upperRight[0],upperRight[1]),1),
- rgbint2rgbtuple(win32gui.GetPixel(handle,lowerRight[0],lowerRight[1]),1)]
- red = 0
- green = 0
- blue = 0
- for pixel in pixels:
- red += pixel[0]**2
- green += pixel[1]**2
- blue += pixel[2]**2
- return int(math.sqrt(red/len(pixels))),int(math.sqrt(green/len(pixels))),int(math.sqrt(blue/len(pixels)))
- except Exception as E:
- # time.sleep(10)
- return "Get Colors Error: {}".format(str(E))
-
- try:
- GoveeInternalControl("On")
- GoveeInternalControl("BrightLevel",brightness=100)
- while(1):
- if appChange == True:
- handle = None
- whndl = None
- whndl = win32gui.GetForegroundWindow()
- pid =(win32process.GetWindowThreadProcessId(whndl))[1]
- handle = win32gui.GetWindowDC(whndl)
- txt = win32gui.GetWindowText(whndl)
- rect = ctypes.wintypes.RECT()
- DWMWA_EXTENDED_FRAME_BOUNDS = 9
- ctypes.windll.dwmapi.DwmGetWindowAttribute(
- ctypes.wintypes.HWND(whndl),
- ctypes.wintypes.DWORD(DWMWA_EXTENDED_FRAME_BOUNDS),
- ctypes.byref(rect),
- ctypes.sizeof(rect)
- )
- size = (rect.right - rect.left, rect.bottom - rect.top)
- windowWidth = size[0]
- windowHeight = size[1]
- centerWidth = int(windowWidth / 2)
- centerHeight = int(windowHeight / 2)
- upperLeft = (centerWidth-boxSize,centerHeight-boxSize)
- lowerLeft = (centerWidth-boxSize,centerHeight+boxSize)
- upperRight = (centerWidth+boxSize,centerHeight-boxSize)
- lowerRight = (centerWidth+boxSize,centerHeight+boxSize)
- print("\nFound New Focused Window:\nPid: {}\nWidth: {}\nHeight: {}\nupperLeft: {}\nlowerLeft: {}\nupperRight: {}\nlowerRight: {}".format(pid,windowWidth,windowHeight,upperLeft,lowerLeft,upperRight,lowerRight))
- appChange = False
-
- pixelColor = GetColors(handle,upperLeft,lowerLeft,upperRight,lowerRight)
- if pixelColor == lastColor:
- continue
- elif "Error" in pixelColor:
- print("\nError In Loop: {}".format(pixelColor))
- else:
- lastColor = pixelColor
- GoveeInternalControl("Color",color=pixelColor,Loop=True)
- GoveeInternalControl("Off")
- return "Done"
- except Exception as E:
- print("\nGame Time Loop Error: {}".format(str(E)))
- GoveeInternalControl("Off")
- return "Error"
-
-GoveeInternalControl("Off")
-
-appFocusThread = threading.Thread(name="AppThread",target=DetectFocusChange)
-appFocusThread.start()
-
-GameTime()
-# GoveeInternalControl("On")
-# time.sleep(2)
-# GoveeInternalControl("BrightLevel",100) #1-100% expressed as an integer
-# time.sleep(2)
-# GoveeInternalControl("Color",color=(255,0,0))
-# time.sleep(2)
-# GoveeInternalControl("BrightLevel",50) #1-100% expressed as an integer
-# time.sleep(2)
-# GoveeInternalControl("Color",color=(0,255,0))
-# time.sleep(2)
-# GoveeInternalControl("BrightLevel",10) #1-100% expressed as an integer
-# time.sleep(2)
-# GoveeInternalControl("Off")
diff --git a/GameSync2023.py b/GameSync2023.py
deleted file mode 100644
index 94fc217..0000000
--- a/GameSync2023.py
+++ /dev/null
@@ -1,179 +0,0 @@
-from threading import Thread
-import ctypes
-from ctypes import *
-import ctypes.wintypes
-import json
-import socket
-sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
-import sys,os
-DeviceIP = "10.1.1.43" #INSERT YOUR DEVICE IP
-
-def GoveeLocalControl(Command,brightness=0,color=None,Loop=False,UDP_IP='',UDP_PORT=4003,printStatus=True,errorCount=0):
- lastColor = None
- global sock
-
- def SendCommand(message,individualIP,UDP_PORT):
- jsonResult = json.dumps(message)
- print("Sending: {} to {}".format(Command,individualIP))
- sock.sendto(bytes(jsonResult, "utf-8"), (individualIP, UDP_PORT))
-
- try:
- if Command == "Color":
- message = {
- "msg":{
- "cmd":"colorwc",
- "data":{
- "color":{"r":color[0],"g":color[1],"b":color[2]},
- "colorTemInKelvin":0
- }
- }
- }
- elif Command == "On":
- message = {
- "msg":{
- "cmd":"turn",
- "data":{
- "value":1,
- }
- }
- }
- elif Command == "Off":
- message = {
- "msg":{
- "cmd":"turn",
- "data":{
- "value":0,
- }
- }
- }
- elif Command == "BrightLevel":
- message = {
- "msg":{
- "cmd":"brightness",
- "data":{
- "value":brightness,
- }
- }
- }
- else:
- raise ValueError("Bad Command: {}".format(Command))
-
- #To print or not to print
- if Loop == False and printStatus == True:
- print("Govee Internal Control: {}".format(Command))
-
- #Send command from above
- SendCommand(message,UDP_IP,UDP_PORT)
- except Exception as E:
- errorCount += 1
- if errorCount > 3:
- print("Govee Internal Control Error Recursion Exit: {}".format(str(E)))
- return
- else:
- print("Govee Internal Control Error: {}".format(str(E)))
- return GoveeLocalControl(Command,brightness,color,Loop,UDP_IP,UDP_PORT,printStatus,errorCount)
-
-
-def main(DeviceIP):
- global boxSize
- global sock
- errorNotified = False
- lastColor = None
- handle = None
- whndl = None
- user32 = ctypes.windll.user32
- gdi32 = ctypes.windll.gdi32
-
- screen_width = user32.GetSystemMetrics(0)
- screen_height = user32.GetSystemMetrics(1)
- print("Using Screen Dimensions: {} {}".format(screen_width,screen_height))
- middle_x = screen_width // 2
- middle_y = screen_height // 2
-
- # Get the device context for the entire screen
- screen_dc = user32.GetDC(None)
-
- # Create a compatible device context
- mem_dc = gdi32.CreateCompatibleDC(screen_dc)
-
- # Create a bitmap compatible with the screen device context
- bmp = gdi32.CreateCompatibleBitmap(screen_dc, 1, 1)
-
- # Select the bitmap object into the compatible device context
- gdi32.SelectObject(mem_dc, bmp)
-
- # Define the value of SRCCOPY manually
- SRCCOPY = 0xCC0020
-
-
-
- try:
- print("Game Time Function: Turning Lights On")
- GoveeLocalControl(Command="On",UDP_IP=DeviceIP)
- GoveeLocalControl(Command="BrightLevel",UDP_IP=DeviceIP,brightness=100)
- while(1):
- # Copy the pixel color from the screen to the bitmap
- gdi32.BitBlt(mem_dc, 0, 0, 1, 1, screen_dc, middle_x, middle_y, SRCCOPY)
-
- # Retrieve the color value of the pixel
- pixel_color = gdi32.GetPixel(mem_dc, 0, 0)
-
- # Extract the individual color components (red, green, blue)
- red = pixel_color & 0xFF
- green = (pixel_color >> 8) & 0xFF
- blue = (pixel_color >> 16) & 0xFF
- pixelColor = (red,green,blue)
-
- try:
- #Send colors
- if pixelColor == lastColor:
- # print("Same Color")
- continue
- elif "Error" in pixelColor:
- if errorNotified == False:
- print("Error In Colors Loop: {}".format(pixelColor))
- errorNotified = True
- time.sleep(5)
- continue
- else:
- # print(pixelColor)
- lastColor = pixelColor
- errorNotified = False
-
- message = {
- "msg":{
- "cmd":"colorwc",
- "data":{
- "color":{"r":red,"g":green,"b":blue},
- "colorTemInKelvin":0
- }
- }
- }
- # print("Sending: {}".format(Command))
- sock.sendto(bytes(json.dumps(message), "utf-8"), (DeviceIP, 4003))
- except Exception as E:
- print("Govee Game Time Inner Loop Error: {}".format(str(E)))
- #If for some reason we break from loop and we return from this thread, we want to power down lights.
- print("Game Time Function: Turning Lights Off. Thread Is Dead.")
- gdi32.DeleteDC(mem_dc)
- gdi32.DeleteDC(screen_dc)
- GoveeLocalControl(Command="Off",UDP_IP=DeviceIP)
- raise NameError("NormalReturn")
- except Exception as E:
- if "NormalReturn" in str(E):
- print("Game Time Function: Turning Lights Off. Thread Is Dead.")
- return "NormalExit"
- else:
- print("Govee Game Time Loop Error: {}".format(str(E)))
- GoveeLocalControl(Command="Off",UDP_IP=DeviceIP)
- return "Error"
-
-try:
- print("Press Ctrl + C To Quit (Lights Should Turn Off)\n\n")
- main(DeviceIP)
-except KeyboardInterrupt:
- GoveeLocalControl(Command="Off",UDP_IP=DeviceIP)
- try:
- sys.exit(130)
- except SystemExit:
- os._exit(130)
\ No newline at end of file
diff --git a/GameSync2024.py b/GameSync2024.py
deleted file mode 100644
index 2785bea..0000000
--- a/GameSync2024.py
+++ /dev/null
@@ -1,262 +0,0 @@
-from threading import Thread
-import ctypes
-from ctypes import *
-import ctypes.wintypes
-import json
-import socket
-sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
-import sys,os
-import base64
-import time
-DeviceIP = ["10.1.1.54","10.1.1.43","10.1.1.44"] #INSERT YOUR DEVICE IP(S)
-
-def GoveeLocalControl(Command,brightness=0,color=None,Loop=False,UDP_IP='',UDP_PORT=4003,printStatus=True,errorCount=0):
- lastColor = None
- global sock
-
- def SendCommand(message,individualIP,UDP_PORT):
- jsonResult = json.dumps(message)
- # jsonResult = jsonResult.replace("'",'"')
- print("Sending: {} to {} - {}".format(Command,individualIP,jsonResult))
- sock.sendto(bytes(jsonResult, "utf-8"), (individualIP, UDP_PORT))
-
- try:
- if Command == "Color":
- message = {
- "msg":{
- "cmd":"colorwc",
- "data":{
- "color":{"r":color[0],"g":color[1],"b":color[2]},
- "colorTemInKelvin":0
- }
- }
- }
- elif Command == "On":
- message = {
- "msg":{
- "cmd":"turn",
- "data":{
- "value":1,
- }
- }
- }
- elif Command == "Off":
- message = {
- "msg":{
- "cmd":"turn",
- "data":{
- "value":0,
- }
- }
- }
- elif Command == "SegmentInit":
- message = {
- "msg":{
- "cmd":"status",
- "data":{}
- }
- }
- for individualIP in UDP_IP:
- SendCommand(message,individualIP,UDP_PORT)
- message = {
- "msg":{
- "cmd":"razer",
- "data":{
- 'pt':'uwABsQEK',
- }
- }
- }
- for individualIP in UDP_IP:
- SendCommand(message,individualIP,UDP_PORT)
- message = {
- "msg":{
- "cmd":"status",
- "data":{}
- }
- }
- time.sleep(2)
- elif Command == "SegmentTerm":
- message = {
- "msg":{
- "cmd":"status",
- "data":{}
- }
- }
- for individualIP in UDP_IP:
- SendCommand(message,individualIP,UDP_PORT)
- message = {
- "msg":{
- "cmd":"razer",
- "data":{
- 'pt':'uwABsQAL',
- }
- }
- }
- for individualIP in UDP_IP:
- SendCommand(message,individualIP,UDP_PORT)
- message = {
- "msg":{
- "cmd":"status",
- "data":{}
- }
- }
- time.sleep(2)
- elif Command == "SegmentColor":
- gradientFlag = 1
- r=color[0][0]
- g=color[0][1]
- b=color[0][2]
- gradientFlag = 1
- # byteArray = [187,0,32,176,gradientFlag,10,r,g,b,r,g,b,r,g,b,r,g,b,r,g,b,r,g,b,r,g,b,r,g,b,r,g,b,r,g,b]
- byteArray = [187,0,32,176,gradientFlag,10,color[0][0],color[0][1],color[0][2],color[0][0],color[0][1],color[0][2],color[0][0],color[0][1],color[0][2],color[0][0],color[0][1],color[0][2],color[0][0],color[0][1],color[0][2],color[1][0],color[1][1],color[1][2],color[1][0],color[1][1],color[1][2],color[1][0],color[1][1],color[1][2],color[1][0],color[1][1],color[1][2],color[1][0],color[1][1],color[1][2]]
- num2 = 0
- for byte in byteArray:
- num2 ^= byte
- byteArray.append(num2)
- finalSendValue = (base64.b64encode(bytes(byteArray)).decode())
- message = {
- "msg":{
- "cmd":"razer",
- "data":{
- 'pt':finalSendValue,
- }
- }
- }
- elif Command == "BrightLevel":
- message = {
- "msg":{
- "cmd":"brightness",
- "data":{
- "value":brightness,
- }
- }
- }
- else:
- raise ValueError("Bad Command: {}".format(Command))
-
- #To print or not to print
- if Loop == False and printStatus == True:
- print("Govee Internal Control: {}".format(Command))
-
- #Send command from above
- for individualIP in UDP_IP:
- SendCommand(message,individualIP,UDP_PORT)
- except Exception as E:
- errorCount += 1
- if errorCount > 3:
- print("Govee Internal Control Error Recursion Exit: {}".format(str(E)))
- return
- else:
- print("Govee Internal Control Error: {}".format(str(E)))
- return GoveeLocalControl(Command,brightness,color,Loop,UDP_IP,UDP_PORT,printStatus,errorCount)
-
-def main(DeviceIP):
- global boxSize
- global sock
- errorNotified = False
- lastColor = None
- handle = None
- whndl = None
- user32 = ctypes.windll.user32
- gdi32 = ctypes.windll.gdi32
-
- screen_width = user32.GetSystemMetrics(0)
- screen_height = user32.GetSystemMetrics(1)
- print("Using Screen Dimensions: {} {}".format(screen_width,screen_height))
- middle_x = screen_width // 2
- middle_y = screen_height // 2
- chunk = int(screen_width / 4)
-
- # Get the device context for the entire screen
- screen_dc = user32.GetDC(None)
-
- # Create a compatible device context
- mem_dc = gdi32.CreateCompatibleDC(screen_dc)
-
- # Create a bitmap compatible with the screen device context
- bmp = gdi32.CreateCompatibleBitmap(screen_dc, 1, 1)
-
- # Select the bitmap object into the compatible device context
- gdi32.SelectObject(mem_dc, bmp)
-
- # Define the value of SRCCOPY manually
- SRCCOPY = 0xCC0020
-
- try:
- print("Game Time Function: Turning Lights On")
- while(1):
- # Copy the pixel color from the screen to the bitmap
- gdi32.BitBlt(mem_dc, 0, 0, 1, 1, screen_dc, middle_x-chunk, middle_y, SRCCOPY)
-
- # Retrieve the color value of the pixel
- pixel_color = gdi32.GetPixel(mem_dc, 0, 0)
-
- # Extract the individual color components (red, green, blue)
- red = pixel_color & 0xFF
- green = (pixel_color >> 8) & 0xFF
- blue = (pixel_color >> 16) & 0xFF
- pixelColor1 = (red,green,blue)
-
- gdi32.BitBlt(mem_dc, 0, 0, 1, 1, screen_dc, middle_x+chunk, middle_y, SRCCOPY)
-
- # Retrieve the color value of the pixel
- pixel_color = gdi32.GetPixel(mem_dc, 0, 0)
-
- # Extract the individual color components (red, green, blue)
- red = pixel_color & 0xFF
- green = (pixel_color >> 8) & 0xFF
- blue = (pixel_color >> 16) & 0xFF
- pixelColor2 = (red,green,blue)
- # print(str(middle_x-chunk)+"-"+str(pixelColor1))
- # print(str(middle_x+chunk)+"-"+str(pixelColor2))
-
- try:
- #Send colors
- if "Error" in pixelColor1 or "Error" in pixelColor2:
- if errorNotified == False:
- print("Error In Colors Loop: {}".format(pixelColor))
- errorNotified = True
- time.sleep(5)
- continue
- else:
- # print(pixelColor)
- lastColor = pixelColor1
- errorNotified = False
- GoveeLocalControl(Command="SegmentColor",UDP_IP=DeviceIP,color=(pixelColor1,pixelColor2))
- except Exception as E:
- print("Govee Game Time Inner Loop Error: {}".format(str(E)))
- #If for some reason we break from loop and we return from this thread, we want to power down lights.
- print("Game Time Function: Turning Lights Off. Thread Is Dead.")
- gdi32.DeleteDC(mem_dc)
- gdi32.DeleteDC(screen_dc)
- GoveeLocalControl(Command="Off",UDP_IP=DeviceIP)
- raise NameError("NormalReturn")
- except Exception as E:
- if "NormalReturn" in str(E):
- print("Game Time Function: Turning Lights Off. Thread Is Dead.")
- return "NormalExit"
- else:
- print("Govee Game Time Loop Error: {}".format(str(E)))
- GoveeLocalControl(Command="Off",UDP_IP=DeviceIP)
- return "Error"
-
-try:
- print("Press Ctrl + C To Quit (Lights Should Turn Off)\n\n")
- GoveeLocalControl(Command="SegmentTerm",UDP_IP=DeviceIP)
- GoveeLocalControl(Command="Off",UDP_IP=DeviceIP)
- time.sleep(2)
- GoveeLocalControl(Command="On",UDP_IP=DeviceIP)
- GoveeLocalControl(Command="BrightLevel",UDP_IP=DeviceIP,brightness=100)
- time.sleep(2)
- GoveeLocalControl(Command="SegmentInit",UDP_IP=DeviceIP)
- time.sleep(5)
- GoveeLocalControl(Command="SegmentColor",color=(255,0,0),UDP_IP=DeviceIP)
- main(DeviceIP)
-except KeyboardInterrupt:
- GoveeLocalControl(Command="SegmentTerm",UDP_IP=DeviceIP)
- GoveeLocalControl(Command="Off",UDP_IP=DeviceIP)
- try:
- sys.exit(130)
- except SystemExit:
- os._exit(130)
-
diff --git a/README.md b/README.md
index e142368..69fbf18 100644
--- a/README.md
+++ b/README.md
@@ -1,45 +1,202 @@
-# GoveeSync
-This code will allow you to use Govee internal UDP api to control your device as well as sync what is on the screen. You need to install the below libs before running:
-pip install wmi
-pip install pywin32
-
-# Constants
-Update the device IP of the device you want to control on line 21.
-
-# No Frills Screen Sync Script
-
-If you don't want to get into the different functions and you just want something simple you can run and have work, just run GameSync2023.py file.
-
-- Replace line 10 with your device IP, for example: DeviceIP = "192.168.0.1"
-- Open cmd prompt then simply run with - python c:\PathToFile\GameSync2023.py
-- You can simply do Ctrl + C to exit when you want to end the light sync.
-
-
-# Full Featured Code With Many Supported Commands - All of the below commands are implemented within GameSync.py
-
-To turn the device on/off:
-GoveeInternalControl("On")
-GoveeInternalControl("Off")
-
-To set the brightness:
-GoveeInternalControl("BrightLevel",100) #1-100% expressed as an integer
-GoveeInternalControl("BrightLevel",50) #1-100% expressed as an integer
-GoveeInternalControl("BrightLevel",10) #1-100% expressed as an integer
-
-To change the color:
-GoveeInternalControl("Color",color=(0,255,0)) #Color expressed as a RGB tuple
-GoveeInternalControl("Color",color=(255,0,0)) #Color expressed as a RGB tuple
-
-To go into game mode where the screen will sync to the device:
-Do note: in this mode the code will attempt to lock onto the window that is in focus.
There is a box size constant you can mess around with if you want to and see if you get better results.
This mode essentially works by creating a square in the center of the app's window. We sample a pixel at each of the four corners of the square, get the color values, then average those 4 color values together. That averaged out value is then sent to the Govee lights over UDP api.
-
-GameTime()
-
-# Testing / Searching for devices - This is optional if you want to search for available Govee LAN api devices on your network.
-Download UDPReceiver.py and UDPSender.py
-
-- Start CMD prompt, navigate to the folder where you downloaded the above files. Then type: python UDPReceiver.py
This should start a UDP multicast listener on port 4002
-- Once the listener is started open another CMD prompt. Navigate to the folder, and type: python UDPSender.py
This should output any Govee devices found to the shell.
-
+# Govee Screen Sync
+Govee Screen Sync is a Python project that synchronizes Govee LED lights with your computer screen, creating an immersive lighting experience. The lights will change colors based on the content displayed on your screen.
+## Acknowledgement
+This project builds from the work of https://github.com/strunker/GoveeSync who wrote out all the commands and showed how to use the Govee API. They also found the 'razer' command which is the most importart part, low-latency control of the color segments.
+
+## Prerequisites
+
+Before getting started, ensure that you have the following:
+
+- Python 3.7 or higher installed on your system
+- Poetry package manager installed (see installation instructions [here](https://python-poetry.org/docs/#installation))
+- Govee LED lights compatible with the Govee API
+
+## Installation
+
+1. Clone the repository:
+
+```bash
+git clone https://github.com/yourusername/govee-screen-sync.git
+cd govee-screen-sync
+```
+
+2. Install the required dependencies using Poetry:
+
+```bash
+poetry install
+```
+
+## Device Discovery
+
+To discover your Govee devices and identify their IP addresses, you can run the device discovery script:
+
+1. Activate the Poetry virtual environment:
+
+```bash
+poetry shell
+```
+
+2. Run the device discovery script:
+
+```bash
+python discover_devices.py
+```
+
+The script will attempt to discover your Govee devices automatically. If successful, it will light up each device with a different color of the rainbow and display the corresponding IP address in the console output.
+
+Press Enter to power off the devices.
+
+Use the discovered IP addresses to update the `devices.json` file accordingly.
+
+## Mapping Devices to Screen Sections
+Using the `devices.json` file, you can map each Govee device to specific sections of your computer screen. This mapping allows you to sonchronize the lighting effects with different parts of the screen content.
+
+
+### Moniter Grid
+```
+ 0 1 2 3 4 5 6 7 8 9
+ -----------------------------------------
+ 0 | x x x x x x x x x x |
+ 1 | x x x x x x x x x x |
+ 2 | x x x x x x x x x x |
+ 3 | x x x x x x x x x x |
+ 4 | x x x x x x x x x x |
+ 5 | x x x x x x x x x x |
+ 6 | x x x x x x x x x x |
+ 7 | x x x x x x x x x x |
+ 8 | x x x x x x x x x x |
+ 9 | x x x x x x x x x x |
+ -----------------------------------------
+ ||||||||||||||
+ ||||||||||||||
+ ______________________
+```
+
+For example, suppose you have two verticle light devices.
+You want to map them to the left and right sides of the screen.
+
+```
+- Map the left device (0) to the left side of the screen
+- Map the right device (1) to the right side of the screen.
+
+
+Govee device 0 govee device 1
+ ___ ___
+ [ 9 ] [ 9 ]
+ [ 9 ] [ 9 ]
+ [ 8 ] [ 8 ]
+ [ 8 ] [ 8 ]
+ [ 7 ] [ 7 ]
+ [ 7 ] 0 1 2 3 4 5 6 7 8 9 [ 7 ]
+ [ 6 ] ----------------------------------------- [ 6 ]
+ [ 6 ] 0 | 0 0 0 x x x x 1 1 1 | [ 6 ]
+ [ 5 ] 1 | 0 0 0 x x x x 1 1 1 | [ 5 ]
+ [ 5 ] 2 | 0 0 0 x x x x 1 1 1 | [ 5 ]
+ [ 4 ] 3 | 0 0 0 x x x x 1 1 1 | [ 4 ]
+ [ 4 ] 4 | 0 0 0 x x x x 1 1 1 | [ 4 ]
+ [ 3 ] 5 | 0 0 0 x x x x 1 1 1 | [ 3 ]
+ [ 3 ] 6 | 0 0 0 x x x x 1 1 1 | [ 3 ]
+ [ 2 ] 7 | 0 0 0 x x x x 1 1 1 | [ 2 ]
+ [ 2 ] 8 | 0 0 0 x x x x 1 1 1 | [ 2 ]
+ [ 1 ] 9 | 0 0 0 x x x x 1 1 1 | [ 1 ]
+ [ 1 ] ----------------------------------------- [ 1 ]
+ [ 0 ] |||||||||||||| [ 0 ]
+ [ 0 ] |||||||||||||| [ 0 ] _
+( _____ ) ______________________ ( _____ )
+```
+
+**The order of the device locations is likely 0-9 from closest to power source to the tip of the device, but I'm just guessing this.**
+
+We will use a "mask" of the screen to map the devices. The mask will be a 2D array that represents the screen. The value (0-9) in a location indicates that the device should be mapped to that position, while -1 indicates that it should not be used by the device.
+
+Device 0 Mapping
+```
+screen_map = [
+ [9, 9, 9, -1, -1, -1, -1, -1, -1, -1],
+ [8, 8, 8, -1, -1, -1, -1, -1, -1, -1],
+ [7, 7, 7, -1, -1, -1, -1, -1, -1, -1],
+ [6, 6, 6, -1, -1, -1, -1, -1, -1, -1],
+ [5, 5, 5, -1, -1, -1, -1, -1, -1, -1],
+ [4, 4, 4, -1, -1, -1, -1, -1, -1, -1],
+ [3, 3, 3, -1, -1, -1, -1, -1, -1, -1],
+ [2, 2, 2, -1, -1, -1, -1, -1, -1, -1],
+ [1, 1, 1, -1, -1, -1, -1, -1, -1, -1],
+ [0, 0, 0, -1, -1, -1, -1, -1, -1, -1],
+]
+```
+
+
+We can formalize this within our JSON object. You can provide either the screen positions or the screen mask. The screen mask is a 2D array that represents the screen. The value of 1 indicates that the device should be mapped to that position, while 0 indicates that it should not.
+```json
+[
+ {
+ "ip": "192.168.0.xxx",
+ "name": "device_0",
+ "screen_map": [
+ [9, 9, 9, -1, -1, -1, -1, -1, -1, -1],
+ [8, 8, 8, -1, -1, -1, -1, -1, -1, -1],
+ [7, 7, 7, -1, -1, -1, -1, -1, -1, -1],
+ [6, 6, 6, -1, -1, -1, -1, -1, -1, -1],
+ [5, 5, 5, -1, -1, -1, -1, -1, -1, -1],
+ [4, 4, 4, -1, -1, -1, -1, -1, -1, -1],
+ [3, 3, 3, -1, -1, -1, -1, -1, -1, -1],
+ [2, 2, 2, -1, -1, -1, -1, -1, -1, -1],
+ [1, 1, 1, -1, -1, -1, -1, -1, -1, -1],
+ [0, 0, 0, -1, -1, -1, -1, -1, -1, -1]
+ ]
+ }
+]
+```
+
+
+## Configuration
+
+1. Locate the `devices_template.json` file in the project directory.
+
+2. Make a copy of the `devices_template.json` file and rename it to `devices.json`.
+
+3. Open the `devices.json` file in a text editor.
+
+4. Replace the placeholder IP addresses (`192.168.0.xxx` and `192.168.0.yyy`) with the actual IP addresses of your Govee devices.
+
+5. Customize the device names and screen positions according to your setup. The `screen_positions` array should contain the indices of the screen sections you want the device to be mapped to.
+
+6. Save the `devices.json` file.
+
+7. Customize screen positions (optional):
+
+ The `screen_positions` array in the `devices.json` file allows you to specify which parts of the screen should be mapped to each device. You can customize the values to select specific areas of the screen for each device.
+
+## Usage
+
+1. Activate the Poetry virtual environment:
+
+```bash
+poetry shell
+```
+
+2. Run the script:
+
+```bash
+python main.py
+```
+
+The script will start capturing your screen, processing the colors, and sending commands to your Govee devices to synchronize the lighting with your screen content.
+
+## Troubleshooting
+
+- If the script fails to discover your devices automatically, make sure your devices are powered on and connected to the same network as your computer.
+
+- If the colors don't match your expectations, you can adjust the color processing parameters in the `capture_screen_and_process_colors()` function in the `screen_capture.py` file.
+
+- If the script displays an error message about the `devices.json` file not being found, make sure you have created the file by following the configuration steps above.
+
+## Contributing
+
+Feel free to submit issues and pull requests to improve the project. Contributions are always welcome!
+
+## License
+
+This project is licensed under the [MIT License](LICENSE).
\ No newline at end of file
diff --git a/UDPReceiver.py b/UDPReceiver.py
deleted file mode 100644
index 6d61b95..0000000
--- a/UDPReceiver.py
+++ /dev/null
@@ -1,19 +0,0 @@
-import socket
-import struct
-
-MCAST_GRP = "239.255.255.250"
-MCAST_PORT = 4002
-
-sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
-sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
-
-sock.bind(('', MCAST_PORT))
-mreq = struct.pack("4sl", socket.inet_aton(MCAST_GRP), socket.INADDR_ANY)
-
-sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
-
-while True:
- print("Listening on {}".format(MCAST_PORT))
- if sock.recv:
- print(sock.recv(10240))
- break
\ No newline at end of file
diff --git a/UDPSender.py b/UDPSender.py
deleted file mode 100644
index d6a237e..0000000
--- a/UDPSender.py
+++ /dev/null
@@ -1,24 +0,0 @@
-import json,time,socket,struct
-message = {
- "msg":{
- "cmd":"scan",
- "data":{
- "account_topic":"reserve",
- }
- }
-}
-import socket
-group = "239.255.255.250"
-port = 4001
-# 2-hop restriction in network
-ttl = 2
-sock = socket.socket(socket.AF_INET,
- socket.SOCK_DGRAM,
- socket.IPPROTO_UDP)
-sock.setsockopt(socket.IPPROTO_IP,
- socket.IP_MULTICAST_TTL,
- ttl)
-jsonResult = json.dumps(message)
-print("Sending: "+jsonResult)
-sock.sendto(bytes(jsonResult, "utf-8"), (group, port))
-
diff --git a/devices_template.json b/devices_template.json
new file mode 100644
index 0000000..b812042
--- /dev/null
+++ b/devices_template.json
@@ -0,0 +1,34 @@
+[
+ {
+ "ip": "192.168.0.xxx",
+ "name": "Govee Light 1",
+ "screen_map": [
+ [9, 9, 9, -1, -1, -1, -1, -1, -1, -1],
+ [8, 8, 8, -1, -1, -1, -1, -1, -1, -1],
+ [7, 7, 7, -1, -1, -1, -1, -1, -1, -1],
+ [6, 6, 6, -1, -1, -1, -1, -1, -1, -1],
+ [5, 5, 5, -1, -1, -1, -1, -1, -1, -1],
+ [4, 4, 4, -1, -1, -1, -1, -1, -1, -1],
+ [3, 3, 3, -1, -1, -1, -1, -1, -1, -1],
+ [2, 2, 2, -1, -1, -1, -1, -1, -1, -1],
+ [1, 1, 1, -1, -1, -1, -1, -1, -1, -1],
+ [0, 0, 0, -1, -1, -1, -1, -1, -1, -1]
+ ]
+ },
+ {
+ "ip": "192.168.0.yyy",
+ "name": "Govee Light 2",
+ "screen_map": [
+ [-1, -1, -1, -1, -1, -1, -1, 9, 9, 9],
+ [-1, -1, -1, -1, -1, -1, -1, 8, 8, 8],
+ [-1, -1, -1, -1, -1, -1, -1, 7, 7, 7],
+ [-1, -1, -1, -1, -1, -1, -1, 6, 6, 6],
+ [-1, -1, -1, -1, -1, -1, -1, 5, 5, 5],
+ [-1, -1, -1, -1, -1, -1, -1, 4, 4, 4],
+ [-1, -1, -1, -1, -1, -1, -1, 3, 3, 3],
+ [-1, -1, -1, -1, -1, -1, -1, 2, 2, 2],
+ [-1, -1, -1, -1, -1, -1, -1, 1, 1, 1],
+ [-1, -1, -1, -1, -1, -1, -1, 0, 0, 0]
+ ]
+ }
+]
diff --git a/discover_devices.py b/discover_devices.py
new file mode 100644
index 0000000..71c5454
--- /dev/null
+++ b/discover_devices.py
@@ -0,0 +1,109 @@
+import socket
+import struct
+import time
+
+from src.govee_screen_sync.light_device import GoveeLightDevice
+from src.govee_screen_sync.models import Color, DeviceScanMessage, NetworkedGoveeDevice
+
+
+def discover_devices(
+ send_group: str = "239.255.255.250",
+ send_port: int = 4001,
+ receive_port: int = 4002,
+ message: DeviceScanMessage = DeviceScanMessage(),
+ timeout: int = 1,
+ expected_devices: list[GoveeLightDevice] = None,
+):
+ # Set up the sending socket with TTL for multicast
+ send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+ ttl = struct.pack("b", 1) # Time-to-live of the multicast message
+ send_sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl)
+
+ # Set up the receiving socket
+ recv_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+ recv_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ recv_sock.bind(("", receive_port))
+
+ # Join multicast group
+ mreq = struct.pack("4sl", socket.inet_aton(send_group), socket.INADDR_ANY)
+ recv_sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
+
+ # Send the multicast message
+ json_message = message.model_dump_json()
+ print(f"Sending: {json_message}")
+ send_sock.sendto(bytes(json_message, "utf-8"), (send_group, send_port))
+ send_sock.close()
+
+ # Listen for responses
+ recv_sock.settimeout(timeout)
+ device_ips = []
+ ti = time.time()
+ try:
+ while True:
+ data, addr = recv_sock.recvfrom(10240)
+ print(f"Received response from {addr}: {data} in {time.time() - ti} seconds.")
+ device_ips.append(addr[0])
+
+ # Check if all expected devices have responded
+ if expected_devices and all(device.ip in device_ips for device in expected_devices):
+ print("All expected devices have responded.")
+ break
+ except socket.timeout:
+ print("Listening timeout reached. No more responses.")
+ finally:
+ recv_sock.close()
+ return device_ips
+
+
+def color_device_by_ip():
+ # For each discovered device, create a GoveeLightDevice object
+ devices = [
+ GoveeLightDevice(NetworkedGoveeDevice(ip=ip, name=f"Govee Light {i}", screen_map=[]))
+ for i, ip in enumerate(discover_devices())
+ ]
+
+ rainbow_rgb = {
+ "red": (255, 0, 0),
+ "orange": (255, 165, 0),
+ "yellow": (255, 255, 0),
+ "green": (0, 128, 0),
+ "blue": (0, 0, 255),
+ "indigo": (75, 0, 130),
+ "violet": (238, 130, 238),
+ }
+
+ # Light up the devices to indicate they have been discovered
+ device_count_location = 0
+ for i, device in enumerate(devices):
+ device.power_on()
+ device.set_brightness(100)
+ device.initialize_segment()
+
+ color_index = i % len(rainbow_rgb)
+ if i % 2 != 0:
+ color_index = len(rainbow_rgb) - color_index - 1
+ color_name = list(rainbow_rgb.keys())[color_index]
+ color_rgb = rainbow_rgb[color_name]
+
+ segment_colors = [Color.from_rgb(color_rgb) for _ in range(10)]
+ if i % len(rainbow_rgb) == 0 and i != 0:
+ device_count_location += 1
+
+ segment_colors[device_count_location] = Color.from_rgb((5, 5, 5))
+ device.set_segment_colors(segment_colors, gradient=False)
+
+ print(
+ f"Device at ip {device.ip} as color {color_name} in location {device_count_location}"
+ )
+
+ _ = input("Press enter to power off devices...")
+
+ for device in devices:
+ device.terminate_segment()
+ device.power_off()
+
+ return devices
+
+
+if __name__ == "__main__":
+ color_device_by_ip()
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..5667fef
--- /dev/null
+++ b/main.py
@@ -0,0 +1,38 @@
+import signal
+
+from src.govee_screen_sync.game_sync import game_loop
+from src.govee_screen_sync.light_device import GoveeLightDevice
+from src.govee_screen_sync.models import NetworkedGoveeDevices
+
+
+def get_my_devices() -> list[GoveeLightDevice]:
+ try:
+ with open("devices.json", "r") as file:
+ my_devices = NetworkedGoveeDevices.model_validate_json(file.read())
+ except FileNotFoundError:
+ print("devices.json not found.")
+ print(
+ "Please make a copy of devices_template.json, rename it to devices.json, and configure your devices."
+ )
+ return []
+ devices = [GoveeLightDevice(device) for device in my_devices.devices]
+ print(f"Found devices: {[device.name for device in devices]}")
+ return devices
+
+
+def run():
+ devices = get_my_devices()
+
+ def power_off_devices_and_exit():
+ for device in devices:
+ device.power_off()
+ exit()
+
+ print("Starting Govee Light Controller...")
+ signal.signal(signal.SIGTERM, lambda signum, frame: power_off_devices_and_exit())
+ signal.signal(signal.SIGINT, lambda signum, frame: power_off_devices_and_exit())
+ game_loop(devices=devices)
+
+
+if __name__ == "__main__":
+ run()
diff --git a/poetry.lock b/poetry.lock
new file mode 100644
index 0000000..44a8a0b
--- /dev/null
+++ b/poetry.lock
@@ -0,0 +1,295 @@
+# This file is automatically @generated by Poetry 1.7.0 and should not be changed by hand.
+
+[[package]]
+name = "annotated-types"
+version = "0.6.0"
+description = "Reusable constraint types to use with typing.Annotated"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"},
+ {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"},
+]
+
+[[package]]
+name = "numpy"
+version = "1.26.4"
+description = "Fundamental package for array computing in Python"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"},
+ {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"},
+ {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4"},
+ {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f"},
+ {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a"},
+ {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2"},
+ {file = "numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07"},
+ {file = "numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5"},
+ {file = "numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71"},
+ {file = "numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef"},
+ {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e"},
+ {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5"},
+ {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a"},
+ {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a"},
+ {file = "numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20"},
+ {file = "numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2"},
+ {file = "numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218"},
+ {file = "numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b"},
+ {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b"},
+ {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed"},
+ {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a"},
+ {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0"},
+ {file = "numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110"},
+ {file = "numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818"},
+ {file = "numpy-1.26.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c"},
+ {file = "numpy-1.26.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be"},
+ {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764"},
+ {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3"},
+ {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd"},
+ {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c"},
+ {file = "numpy-1.26.4-cp39-cp39-win32.whl", hash = "sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6"},
+ {file = "numpy-1.26.4-cp39-cp39-win_amd64.whl", hash = "sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea"},
+ {file = "numpy-1.26.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30"},
+ {file = "numpy-1.26.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c"},
+ {file = "numpy-1.26.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0"},
+ {file = "numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010"},
+]
+
+[[package]]
+name = "pillow"
+version = "10.3.0"
+description = "Python Imaging Library (Fork)"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pillow-10.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:90b9e29824800e90c84e4022dd5cc16eb2d9605ee13f05d47641eb183cd73d45"},
+ {file = "pillow-10.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a2c405445c79c3f5a124573a051062300936b0281fee57637e706453e452746c"},
+ {file = "pillow-10.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78618cdbccaa74d3f88d0ad6cb8ac3007f1a6fa5c6f19af64b55ca170bfa1edf"},
+ {file = "pillow-10.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:261ddb7ca91fcf71757979534fb4c128448b5b4c55cb6152d280312062f69599"},
+ {file = "pillow-10.3.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:ce49c67f4ea0609933d01c0731b34b8695a7a748d6c8d186f95e7d085d2fe475"},
+ {file = "pillow-10.3.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b14f16f94cbc61215115b9b1236f9c18403c15dd3c52cf629072afa9d54c1cbf"},
+ {file = "pillow-10.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d33891be6df59d93df4d846640f0e46f1a807339f09e79a8040bc887bdcd7ed3"},
+ {file = "pillow-10.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b50811d664d392f02f7761621303eba9d1b056fb1868c8cdf4231279645c25f5"},
+ {file = "pillow-10.3.0-cp310-cp310-win32.whl", hash = "sha256:ca2870d5d10d8726a27396d3ca4cf7976cec0f3cb706debe88e3a5bd4610f7d2"},
+ {file = "pillow-10.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:f0d0591a0aeaefdaf9a5e545e7485f89910c977087e7de2b6c388aec32011e9f"},
+ {file = "pillow-10.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:ccce24b7ad89adb5a1e34a6ba96ac2530046763912806ad4c247356a8f33a67b"},
+ {file = "pillow-10.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:5f77cf66e96ae734717d341c145c5949c63180842a545c47a0ce7ae52ca83795"},
+ {file = "pillow-10.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4b878386c4bf293578b48fc570b84ecfe477d3b77ba39a6e87150af77f40c57"},
+ {file = "pillow-10.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdcbb4068117dfd9ce0138d068ac512843c52295ed996ae6dd1faf537b6dbc27"},
+ {file = "pillow-10.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9797a6c8fe16f25749b371c02e2ade0efb51155e767a971c61734b1bf6293994"},
+ {file = "pillow-10.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9e91179a242bbc99be65e139e30690e081fe6cb91a8e77faf4c409653de39451"},
+ {file = "pillow-10.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:1b87bd9d81d179bd8ab871603bd80d8645729939f90b71e62914e816a76fc6bd"},
+ {file = "pillow-10.3.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:81d09caa7b27ef4e61cb7d8fbf1714f5aec1c6b6c5270ee53504981e6e9121ad"},
+ {file = "pillow-10.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:048ad577748b9fa4a99a0548c64f2cb8d672d5bf2e643a739ac8faff1164238c"},
+ {file = "pillow-10.3.0-cp311-cp311-win32.whl", hash = "sha256:7161ec49ef0800947dc5570f86568a7bb36fa97dd09e9827dc02b718c5643f09"},
+ {file = "pillow-10.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8eb0908e954d093b02a543dc963984d6e99ad2b5e36503d8a0aaf040505f747d"},
+ {file = "pillow-10.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:4e6f7d1c414191c1199f8996d3f2282b9ebea0945693fb67392c75a3a320941f"},
+ {file = "pillow-10.3.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:e46f38133e5a060d46bd630faa4d9fa0202377495df1f068a8299fd78c84de84"},
+ {file = "pillow-10.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:50b8eae8f7334ec826d6eeffaeeb00e36b5e24aa0b9df322c247539714c6df19"},
+ {file = "pillow-10.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d3bea1c75f8c53ee4d505c3e67d8c158ad4df0d83170605b50b64025917f338"},
+ {file = "pillow-10.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19aeb96d43902f0a783946a0a87dbdad5c84c936025b8419da0a0cd7724356b1"},
+ {file = "pillow-10.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:74d28c17412d9caa1066f7a31df8403ec23d5268ba46cd0ad2c50fb82ae40462"},
+ {file = "pillow-10.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ff61bfd9253c3915e6d41c651d5f962da23eda633cf02262990094a18a55371a"},
+ {file = "pillow-10.3.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d886f5d353333b4771d21267c7ecc75b710f1a73d72d03ca06df49b09015a9ef"},
+ {file = "pillow-10.3.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b5ec25d8b17217d635f8935dbc1b9aa5907962fae29dff220f2659487891cd3"},
+ {file = "pillow-10.3.0-cp312-cp312-win32.whl", hash = "sha256:51243f1ed5161b9945011a7360e997729776f6e5d7005ba0c6879267d4c5139d"},
+ {file = "pillow-10.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:412444afb8c4c7a6cc11a47dade32982439925537e483be7c0ae0cf96c4f6a0b"},
+ {file = "pillow-10.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:798232c92e7665fe82ac085f9d8e8ca98826f8e27859d9a96b41d519ecd2e49a"},
+ {file = "pillow-10.3.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:4eaa22f0d22b1a7e93ff0a596d57fdede2e550aecffb5a1ef1106aaece48e96b"},
+ {file = "pillow-10.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cd5e14fbf22a87321b24c88669aad3a51ec052eb145315b3da3b7e3cc105b9a2"},
+ {file = "pillow-10.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1530e8f3a4b965eb6a7785cf17a426c779333eb62c9a7d1bbcf3ffd5bf77a4aa"},
+ {file = "pillow-10.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d512aafa1d32efa014fa041d38868fda85028e3f930a96f85d49c7d8ddc0383"},
+ {file = "pillow-10.3.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:339894035d0ede518b16073bdc2feef4c991ee991a29774b33e515f1d308e08d"},
+ {file = "pillow-10.3.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:aa7e402ce11f0885305bfb6afb3434b3cd8f53b563ac065452d9d5654c7b86fd"},
+ {file = "pillow-10.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0ea2a783a2bdf2a561808fe4a7a12e9aa3799b701ba305de596bc48b8bdfce9d"},
+ {file = "pillow-10.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c78e1b00a87ce43bb37642c0812315b411e856a905d58d597750eb79802aaaa3"},
+ {file = "pillow-10.3.0-cp38-cp38-win32.whl", hash = "sha256:72d622d262e463dfb7595202d229f5f3ab4b852289a1cd09650362db23b9eb0b"},
+ {file = "pillow-10.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:2034f6759a722da3a3dbd91a81148cf884e91d1b747992ca288ab88c1de15999"},
+ {file = "pillow-10.3.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:2ed854e716a89b1afcedea551cd85f2eb2a807613752ab997b9974aaa0d56936"},
+ {file = "pillow-10.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dc1a390a82755a8c26c9964d457d4c9cbec5405896cba94cf51f36ea0d855002"},
+ {file = "pillow-10.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4203efca580f0dd6f882ca211f923168548f7ba334c189e9eab1178ab840bf60"},
+ {file = "pillow-10.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3102045a10945173d38336f6e71a8dc71bcaeed55c3123ad4af82c52807b9375"},
+ {file = "pillow-10.3.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:6fb1b30043271ec92dc65f6d9f0b7a830c210b8a96423074b15c7bc999975f57"},
+ {file = "pillow-10.3.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:1dfc94946bc60ea375cc39cff0b8da6c7e5f8fcdc1d946beb8da5c216156ddd8"},
+ {file = "pillow-10.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b09b86b27a064c9624d0a6c54da01c1beaf5b6cadfa609cf63789b1d08a797b9"},
+ {file = "pillow-10.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d3b2348a78bc939b4fed6552abfd2e7988e0f81443ef3911a4b8498ca084f6eb"},
+ {file = "pillow-10.3.0-cp39-cp39-win32.whl", hash = "sha256:45ebc7b45406febf07fef35d856f0293a92e7417ae7933207e90bf9090b70572"},
+ {file = "pillow-10.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:0ba26351b137ca4e0db0342d5d00d2e355eb29372c05afd544ebf47c0956ffeb"},
+ {file = "pillow-10.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:50fd3f6b26e3441ae07b7c979309638b72abc1a25da31a81a7fbd9495713ef4f"},
+ {file = "pillow-10.3.0-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:6b02471b72526ab8a18c39cb7967b72d194ec53c1fd0a70b050565a0f366d355"},
+ {file = "pillow-10.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8ab74c06ffdab957d7670c2a5a6e1a70181cd10b727cd788c4dd9005b6a8acd9"},
+ {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:048eeade4c33fdf7e08da40ef402e748df113fd0b4584e32c4af74fe78baaeb2"},
+ {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2ec1e921fd07c7cda7962bad283acc2f2a9ccc1b971ee4b216b75fad6f0463"},
+ {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:4c8e73e99da7db1b4cad7f8d682cf6abad7844da39834c288fbfa394a47bbced"},
+ {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:16563993329b79513f59142a6b02055e10514c1a8e86dca8b48a893e33cf91e3"},
+ {file = "pillow-10.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:dd78700f5788ae180b5ee8902c6aea5a5726bac7c364b202b4b3e3ba2d293170"},
+ {file = "pillow-10.3.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:aff76a55a8aa8364d25400a210a65ff59d0168e0b4285ba6bf2bd83cf675ba32"},
+ {file = "pillow-10.3.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:b7bc2176354defba3edc2b9a777744462da2f8e921fbaf61e52acb95bafa9828"},
+ {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:793b4e24db2e8742ca6423d3fde8396db336698c55cd34b660663ee9e45ed37f"},
+ {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d93480005693d247f8346bc8ee28c72a2191bdf1f6b5db469c096c0c867ac015"},
+ {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83341b89884e2b2e55886e8fbbf37c3fa5efd6c8907124aeb72f285ae5696e5"},
+ {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1a1d1915db1a4fdb2754b9de292642a39a7fb28f1736699527bb649484fb966a"},
+ {file = "pillow-10.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a0eaa93d054751ee9964afa21c06247779b90440ca41d184aeb5d410f20ff591"},
+ {file = "pillow-10.3.0.tar.gz", hash = "sha256:9d2455fbf44c914840c793e89aa82d0e1763a14253a000743719ae5946814b2d"},
+]
+
+[package.extras]
+docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-removed-in", "sphinxext-opengraph"]
+fpx = ["olefile"]
+mic = ["olefile"]
+tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"]
+typing = ["typing-extensions"]
+xmp = ["defusedxml"]
+
+[[package]]
+name = "pydantic"
+version = "2.7.0"
+description = "Data validation using Python type hints"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pydantic-2.7.0-py3-none-any.whl", hash = "sha256:9dee74a271705f14f9a1567671d144a851c675b072736f0a7b2608fd9e495352"},
+ {file = "pydantic-2.7.0.tar.gz", hash = "sha256:b5ecdd42262ca2462e2624793551e80911a1e989f462910bb81aef974b4bb383"},
+]
+
+[package.dependencies]
+annotated-types = ">=0.4.0"
+pydantic-core = "2.18.1"
+typing-extensions = ">=4.6.1"
+
+[package.extras]
+email = ["email-validator (>=2.0.0)"]
+
+[[package]]
+name = "pydantic-core"
+version = "2.18.1"
+description = "Core functionality for Pydantic validation and serialization"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pydantic_core-2.18.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:ee9cf33e7fe14243f5ca6977658eb7d1042caaa66847daacbd2117adb258b226"},
+ {file = "pydantic_core-2.18.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6b7bbb97d82659ac8b37450c60ff2e9f97e4eb0f8a8a3645a5568b9334b08b50"},
+ {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df4249b579e75094f7e9bb4bd28231acf55e308bf686b952f43100a5a0be394c"},
+ {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d0491006a6ad20507aec2be72e7831a42efc93193d2402018007ff827dc62926"},
+ {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ae80f72bb7a3e397ab37b53a2b49c62cc5496412e71bc4f1277620a7ce3f52b"},
+ {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58aca931bef83217fca7a390e0486ae327c4af9c3e941adb75f8772f8eeb03a1"},
+ {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1be91ad664fc9245404a789d60cba1e91c26b1454ba136d2a1bf0c2ac0c0505a"},
+ {file = "pydantic_core-2.18.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:667880321e916a8920ef49f5d50e7983792cf59f3b6079f3c9dac2b88a311d17"},
+ {file = "pydantic_core-2.18.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f7054fdc556f5421f01e39cbb767d5ec5c1139ea98c3e5b350e02e62201740c7"},
+ {file = "pydantic_core-2.18.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:030e4f9516f9947f38179249778709a460a3adb516bf39b5eb9066fcfe43d0e6"},
+ {file = "pydantic_core-2.18.1-cp310-none-win32.whl", hash = "sha256:2e91711e36e229978d92642bfc3546333a9127ecebb3f2761372e096395fc649"},
+ {file = "pydantic_core-2.18.1-cp310-none-win_amd64.whl", hash = "sha256:9a29726f91c6cb390b3c2338f0df5cd3e216ad7a938762d11c994bb37552edb0"},
+ {file = "pydantic_core-2.18.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9ece8a49696669d483d206b4474c367852c44815fca23ac4e48b72b339807f80"},
+ {file = "pydantic_core-2.18.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a5d83efc109ceddb99abd2c1316298ced2adb4570410defe766851a804fcd5b"},
+ {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f7973c381283783cd1043a8c8f61ea5ce7a3a58b0369f0ee0ee975eaf2f2a1b"},
+ {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:54c7375c62190a7845091f521add19b0f026bcf6ae674bdb89f296972272e86d"},
+ {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd63cec4e26e790b70544ae5cc48d11b515b09e05fdd5eff12e3195f54b8a586"},
+ {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:561cf62c8a3498406495cfc49eee086ed2bb186d08bcc65812b75fda42c38294"},
+ {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68717c38a68e37af87c4da20e08f3e27d7e4212e99e96c3d875fbf3f4812abfc"},
+ {file = "pydantic_core-2.18.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d5728e93d28a3c63ee513d9ffbac9c5989de8c76e049dbcb5bfe4b923a9739d"},
+ {file = "pydantic_core-2.18.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f0f17814c505f07806e22b28856c59ac80cee7dd0fbb152aed273e116378f519"},
+ {file = "pydantic_core-2.18.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d816f44a51ba5175394bc6c7879ca0bd2be560b2c9e9f3411ef3a4cbe644c2e9"},
+ {file = "pydantic_core-2.18.1-cp311-none-win32.whl", hash = "sha256:09f03dfc0ef8c22622eaa8608caa4a1e189cfb83ce847045eca34f690895eccb"},
+ {file = "pydantic_core-2.18.1-cp311-none-win_amd64.whl", hash = "sha256:27f1009dc292f3b7ca77feb3571c537276b9aad5dd4efb471ac88a8bd09024e9"},
+ {file = "pydantic_core-2.18.1-cp311-none-win_arm64.whl", hash = "sha256:48dd883db92e92519201f2b01cafa881e5f7125666141a49ffba8b9facc072b0"},
+ {file = "pydantic_core-2.18.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b6b0e4912030c6f28bcb72b9ebe4989d6dc2eebcd2a9cdc35fefc38052dd4fe8"},
+ {file = "pydantic_core-2.18.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3202a429fe825b699c57892d4371c74cc3456d8d71b7f35d6028c96dfecad31"},
+ {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3982b0a32d0a88b3907e4b0dc36809fda477f0757c59a505d4e9b455f384b8b"},
+ {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25595ac311f20e5324d1941909b0d12933f1fd2171075fcff763e90f43e92a0d"},
+ {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14fe73881cf8e4cbdaded8ca0aa671635b597e42447fec7060d0868b52d074e6"},
+ {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca976884ce34070799e4dfc6fbd68cb1d181db1eefe4a3a94798ddfb34b8867f"},
+ {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:684d840d2c9ec5de9cb397fcb3f36d5ebb6fa0d94734f9886032dd796c1ead06"},
+ {file = "pydantic_core-2.18.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:54764c083bbe0264f0f746cefcded6cb08fbbaaf1ad1d78fb8a4c30cff999a90"},
+ {file = "pydantic_core-2.18.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:201713f2f462e5c015b343e86e68bd8a530a4f76609b33d8f0ec65d2b921712a"},
+ {file = "pydantic_core-2.18.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fd1a9edb9dd9d79fbeac1ea1f9a8dd527a6113b18d2e9bcc0d541d308dae639b"},
+ {file = "pydantic_core-2.18.1-cp312-none-win32.whl", hash = "sha256:d5e6b7155b8197b329dc787356cfd2684c9d6a6b1a197f6bbf45f5555a98d411"},
+ {file = "pydantic_core-2.18.1-cp312-none-win_amd64.whl", hash = "sha256:9376d83d686ec62e8b19c0ac3bf8d28d8a5981d0df290196fb6ef24d8a26f0d6"},
+ {file = "pydantic_core-2.18.1-cp312-none-win_arm64.whl", hash = "sha256:c562b49c96906b4029b5685075fe1ebd3b5cc2601dfa0b9e16c2c09d6cbce048"},
+ {file = "pydantic_core-2.18.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:3e352f0191d99fe617371096845070dee295444979efb8f27ad941227de6ad09"},
+ {file = "pydantic_core-2.18.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c0295d52b012cbe0d3059b1dba99159c3be55e632aae1999ab74ae2bd86a33d7"},
+ {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56823a92075780582d1ffd4489a2e61d56fd3ebb4b40b713d63f96dd92d28144"},
+ {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd3f79e17b56741b5177bcc36307750d50ea0698df6aa82f69c7db32d968c1c2"},
+ {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:38a5024de321d672a132b1834a66eeb7931959c59964b777e8f32dbe9523f6b1"},
+ {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2ce426ee691319d4767748c8e0895cfc56593d725594e415f274059bcf3cb76"},
+ {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2adaeea59849ec0939af5c5d476935f2bab4b7f0335b0110f0f069a41024278e"},
+ {file = "pydantic_core-2.18.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9b6431559676a1079eac0f52d6d0721fb8e3c5ba43c37bc537c8c83724031feb"},
+ {file = "pydantic_core-2.18.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:85233abb44bc18d16e72dc05bf13848a36f363f83757541f1a97db2f8d58cfd9"},
+ {file = "pydantic_core-2.18.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:641a018af4fe48be57a2b3d7a1f0f5dbca07c1d00951d3d7463f0ac9dac66622"},
+ {file = "pydantic_core-2.18.1-cp38-none-win32.whl", hash = "sha256:63d7523cd95d2fde0d28dc42968ac731b5bb1e516cc56b93a50ab293f4daeaad"},
+ {file = "pydantic_core-2.18.1-cp38-none-win_amd64.whl", hash = "sha256:907a4d7720abfcb1c81619863efd47c8a85d26a257a2dbebdb87c3b847df0278"},
+ {file = "pydantic_core-2.18.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:aad17e462f42ddbef5984d70c40bfc4146c322a2da79715932cd8976317054de"},
+ {file = "pydantic_core-2.18.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:94b9769ba435b598b547c762184bcfc4783d0d4c7771b04a3b45775c3589ca44"},
+ {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80e0e57cc704a52fb1b48f16d5b2c8818da087dbee6f98d9bf19546930dc64b5"},
+ {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:76b86e24039c35280ceee6dce7e62945eb93a5175d43689ba98360ab31eebc4a"},
+ {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12a05db5013ec0ca4a32cc6433f53faa2a014ec364031408540ba858c2172bb0"},
+ {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:250ae39445cb5475e483a36b1061af1bc233de3e9ad0f4f76a71b66231b07f88"},
+ {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a32204489259786a923e02990249c65b0f17235073149d0033efcebe80095570"},
+ {file = "pydantic_core-2.18.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6395a4435fa26519fd96fdccb77e9d00ddae9dd6c742309bd0b5610609ad7fb2"},
+ {file = "pydantic_core-2.18.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2533ad2883f001efa72f3d0e733fb846710c3af6dcdd544fe5bf14fa5fe2d7db"},
+ {file = "pydantic_core-2.18.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b560b72ed4816aee52783c66854d96157fd8175631f01ef58e894cc57c84f0f6"},
+ {file = "pydantic_core-2.18.1-cp39-none-win32.whl", hash = "sha256:582cf2cead97c9e382a7f4d3b744cf0ef1a6e815e44d3aa81af3ad98762f5a9b"},
+ {file = "pydantic_core-2.18.1-cp39-none-win_amd64.whl", hash = "sha256:ca71d501629d1fa50ea7fa3b08ba884fe10cefc559f5c6c8dfe9036c16e8ae89"},
+ {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e178e5b66a06ec5bf51668ec0d4ac8cfb2bdcb553b2c207d58148340efd00143"},
+ {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:72722ce529a76a4637a60be18bd789d8fb871e84472490ed7ddff62d5fed620d"},
+ {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fe0c1ce5b129455e43f941f7a46f61f3d3861e571f2905d55cdbb8b5c6f5e2c"},
+ {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4284c621f06a72ce2cb55f74ea3150113d926a6eb78ab38340c08f770eb9b4d"},
+ {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a0c3e718f4e064efde68092d9d974e39572c14e56726ecfaeebbe6544521f47"},
+ {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2027493cc44c23b598cfaf200936110433d9caa84e2c6cf487a83999638a96ac"},
+ {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:76909849d1a6bffa5a07742294f3fa1d357dc917cb1fe7b470afbc3a7579d539"},
+ {file = "pydantic_core-2.18.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ee7ccc7fb7e921d767f853b47814c3048c7de536663e82fbc37f5eb0d532224b"},
+ {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ee2794111c188548a4547eccc73a6a8527fe2af6cf25e1a4ebda2fd01cdd2e60"},
+ {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a139fe9f298dc097349fb4f28c8b81cc7a202dbfba66af0e14be5cfca4ef7ce5"},
+ {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d074b07a10c391fc5bbdcb37b2f16f20fcd9e51e10d01652ab298c0d07908ee2"},
+ {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c69567ddbac186e8c0aadc1f324a60a564cfe25e43ef2ce81bcc4b8c3abffbae"},
+ {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf1c7b78cddb5af00971ad5294a4583188bda1495b13760d9f03c9483bb6203"},
+ {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2684a94fdfd1b146ff10689c6e4e815f6a01141781c493b97342cdc5b06f4d5d"},
+ {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:73c1bc8a86a5c9e8721a088df234265317692d0b5cd9e86e975ce3bc3db62a59"},
+ {file = "pydantic_core-2.18.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e60defc3c15defb70bb38dd605ff7e0fae5f6c9c7cbfe0ad7868582cb7e844a6"},
+ {file = "pydantic_core-2.18.1.tar.gz", hash = "sha256:de9d3e8717560eb05e28739d1b35e4eac2e458553a52a301e51352a7ffc86a35"},
+]
+
+[package.dependencies]
+typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
+
+[[package]]
+name = "ruff"
+version = "0.3.7"
+description = "An extremely fast Python linter and code formatter, written in Rust."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "ruff-0.3.7-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0e8377cccb2f07abd25e84fc5b2cbe48eeb0fea9f1719cad7caedb061d70e5ce"},
+ {file = "ruff-0.3.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:15a4d1cc1e64e556fa0d67bfd388fed416b7f3b26d5d1c3e7d192c897e39ba4b"},
+ {file = "ruff-0.3.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d28bdf3d7dc71dd46929fafeec98ba89b7c3550c3f0978e36389b5631b793663"},
+ {file = "ruff-0.3.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:379b67d4f49774ba679593b232dcd90d9e10f04d96e3c8ce4a28037ae473f7bb"},
+ {file = "ruff-0.3.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c060aea8ad5ef21cdfbbe05475ab5104ce7827b639a78dd55383a6e9895b7c51"},
+ {file = "ruff-0.3.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ebf8f615dde968272d70502c083ebf963b6781aacd3079081e03b32adfe4d58a"},
+ {file = "ruff-0.3.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d48098bd8f5c38897b03604f5428901b65e3c97d40b3952e38637b5404b739a2"},
+ {file = "ruff-0.3.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:da8a4fda219bf9024692b1bc68c9cff4b80507879ada8769dc7e985755d662ea"},
+ {file = "ruff-0.3.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c44e0149f1d8b48c4d5c33d88c677a4aa22fd09b1683d6a7ff55b816b5d074f"},
+ {file = "ruff-0.3.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3050ec0af72b709a62ecc2aca941b9cd479a7bf2b36cc4562f0033d688e44fa1"},
+ {file = "ruff-0.3.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a29cc38e4c1ab00da18a3f6777f8b50099d73326981bb7d182e54a9a21bb4ff7"},
+ {file = "ruff-0.3.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b15cc59c19edca917f51b1956637db47e200b0fc5e6e1878233d3a938384b0b"},
+ {file = "ruff-0.3.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e491045781b1e38b72c91247cf4634f040f8d0cb3e6d3d64d38dcf43616650b4"},
+ {file = "ruff-0.3.7-py3-none-win32.whl", hash = "sha256:bc931de87593d64fad3a22e201e55ad76271f1d5bfc44e1a1887edd0903c7d9f"},
+ {file = "ruff-0.3.7-py3-none-win_amd64.whl", hash = "sha256:5ef0e501e1e39f35e03c2acb1d1238c595b8bb36cf7a170e7c1df1b73da00e74"},
+ {file = "ruff-0.3.7-py3-none-win_arm64.whl", hash = "sha256:789e144f6dc7019d1f92a812891c645274ed08af6037d11fc65fcbc183b7d59f"},
+ {file = "ruff-0.3.7.tar.gz", hash = "sha256:d5c1aebee5162c2226784800ae031f660c350e7a3402c4d1f8ea4e97e232e3ba"},
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.11.0"
+description = "Backported and Experimental Type Hints for Python 3.8+"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"},
+ {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"},
+]
+
+[metadata]
+lock-version = "2.0"
+python-versions = "^3.12"
+content-hash = "16ac7da567fa2620d9ccff783306fa96f8041740a0d0c263d5b6b8174bec8b2a"
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..bd29638
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,71 @@
+[tool.poetry]
+name = "govee-screen-sync"
+version = "0.0.1"
+description = "Sync your Govee device to the pixels on your screen"
+authors = ["Steven Kauwe "]
+
+[tool.poetry.dependencies]
+python = "^3.12"
+numpy = "^1.26.4"
+pillow = "^10.3.0"
+pydantic = "^2.4.2"
+
+[tool.poetry.dev-dependencies]
+ruff = "^0.3.7"
+
+[build-system]
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"
+
+[tool.ruff]
+# Exclude a variety of commonly ignored directories.
+exclude = [
+ ".bzr",
+ ".direnv",
+ ".eggs",
+ ".git",
+ ".git-rewrite",
+ ".hg",
+ ".mypy_cache",
+ ".nox",
+ ".pants.d",
+ ".pytype",
+ ".ruff_cache",
+ ".svn",
+ ".tox",
+ ".venv",
+ "__pypackages__",
+ "_build",
+ "buck-out",
+ "build",
+ "dist",
+ "node_modules",
+ "venv",
+]
+extend-select = ["I", "Q"]
+ignore-init-module-imports = true
+# Same as Black.
+line-length = 99
+indent-width = 4
+# Assume Python 3.12
+target-version = "py312"
+
+[tool.ruff.lint]
+# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
+select = ["E4", "E7", "E9", "F"]
+ignore = []
+# Allow fix for all enabled rules (when `--fix`) is provided.
+fixable = ["ALL"]
+unfixable = []
+# Allow unused variables when underscore-prefixed.
+dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
+
+[tool.ruff.format]
+# Like Black, use double quotes for strings.
+quote-style = "double"
+# Like Black, indent with spaces, rather than tabs.
+indent-style = "space"
+# Like Black, respect magic trailing commas.
+skip-magic-trailing-comma = false
+# Like Black, automatically detect the appropriate line ending.
+line-ending = "auto"
\ No newline at end of file
diff --git a/src/govee_screen_sync/__init__.py b/src/govee_screen_sync/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/govee_screen_sync/config.py b/src/govee_screen_sync/config.py
new file mode 100644
index 0000000..2ea49f0
--- /dev/null
+++ b/src/govee_screen_sync/config.py
@@ -0,0 +1,6 @@
+DEBUG = False
+UDP_PORT = 4003
+
+# This is the number if different colors that can be displayed on the LED strip.
+# We use this to section the screen.
+MAX_LED_COLOR_GRADIENT = 10
diff --git a/src/govee_screen_sync/game_sync.py b/src/govee_screen_sync/game_sync.py
new file mode 100644
index 0000000..35fda20
--- /dev/null
+++ b/src/govee_screen_sync/game_sync.py
@@ -0,0 +1,64 @@
+import time
+
+import numpy as np
+
+from govee_screen_sync.light_device import GoveeLightDevice
+from govee_screen_sync.models import Color
+from govee_screen_sync.screen_capture import capture_screen_and_process_colors
+
+
+class FrameCounter:
+ def __init__(self):
+ self.last_100_fps = []
+ self.start_time = time.time()
+ self.previous_time = time.time()
+
+ def update_and_print(self):
+ elapsed_time = time.time() - self.previous_time
+ self.previous_time = time.time()
+ time_since_start = time.time() - self.start_time
+ fps = 1 / elapsed_time
+ self.last_100_fps = self.last_100_fps[-99:]
+ self.last_100_fps.append(fps)
+ average_fps = sum(self.last_100_fps) / len(self.last_100_fps) if self.last_100_fps else 0
+ print(
+ f"\rFPS: {fps:.1f} - Average FPS {average_fps:.1f} - Total Time:{time_since_start:.1f}",
+ end="",
+ )
+
+ return fps
+
+ def reset(self):
+ self.frame_count = 0
+ self.start_time = time.time()
+
+
+def game_loop(devices: list[GoveeLightDevice]):
+ frame_counter = FrameCounter()
+ try:
+ for device in devices:
+ device.power_on()
+ device.set_brightness(100)
+
+ for device in devices:
+ device.initialize_segment()
+
+ while True:
+ screen_colors = capture_screen_and_process_colors()
+ for device in devices:
+ color_data = [
+ Color.from_rgb(
+ screen_colors[(np.array(device.screen_map)) == i].mean(axis=0).astype(int)
+ )
+ for i in range(10)
+ ]
+ device.set_segment_colors(color_data)
+ frame_counter.update_and_print()
+
+ except KeyboardInterrupt:
+ print("Operation stopped by user.")
+ finally:
+ for device in devices:
+ device.terminate_segment()
+ device.set_color(Color())
+ device.power_off()
diff --git a/src/govee_screen_sync/light_device.py b/src/govee_screen_sync/light_device.py
new file mode 100644
index 0000000..d836776
--- /dev/null
+++ b/src/govee_screen_sync/light_device.py
@@ -0,0 +1,101 @@
+import base64
+import socket
+import time
+
+from govee_screen_sync.config import DEBUG, MAX_LED_COLOR_GRADIENT, UDP_PORT
+from govee_screen_sync.models import (
+ BrightnessCommand,
+ BrightnessData,
+ Color,
+ ColorCommand,
+ ColorData,
+ Command,
+ Message,
+ NetworkedGoveeDevice,
+ PowerCommand,
+ PowerData,
+ PowerState,
+ SegmentCommand,
+ SegmentData,
+)
+
+
+class GoveeLightDevice:
+ def __init__(self, device: NetworkedGoveeDevice):
+ self.ip = device.ip
+ self.name = device.name
+ self.screen_map = device.screen_map
+
+ def _send_command(self, command: Command, sleep_time=0.1):
+ message = Message(msg=command).model_dump_json()
+
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
+ sock.sendto(message.encode(), (self.ip, UDP_PORT))
+ if DEBUG:
+ print(f"Command sent to {self.ip}: {message}")
+ time.sleep(sleep_time)
+
+ def power_on(self):
+ power_on_command = PowerCommand(data=PowerData(value=PowerState.ON))
+ self._send_command(power_on_command)
+
+ def power_off(self):
+ power_off_command = PowerCommand(data=PowerData(value=PowerState.OFF))
+ self._send_command(power_off_command)
+
+ def set_color(self, color: Color):
+ color_command = ColorCommand(data=ColorData(color=color))
+ self._send_command(color_command)
+
+ def set_brightness(self, brightness: int):
+ brightness_command = BrightnessCommand(data=BrightnessData(value=brightness))
+ self._send_command(brightness_command)
+
+ def initialize_segment(self):
+ """Initializes a segment to be set with set_segment_colors."""
+ segment_command = SegmentCommand(data=SegmentData(pt="uwABsQEK"))
+ self._send_command(segment_command)
+
+ def terminate_segment(self):
+ """Returns the color to the state before the segment was initialized."""
+ segment_command = SegmentCommand(data=SegmentData(pt="uwABsQAL"))
+ self._send_command(segment_command)
+
+ def set_segment_colors(self, list_of_colors: list[Color], gradient=True):
+ """Sets the colors of the segment to the given list of colors.
+ color list of size (min 2, max 10)
+
+ probably move "intrepolation" and "resolution" to some other place.
+
+ colors ordering goes from bottom to top for a lamp.
+ not sure about a strip.
+ but probably power source to the end of the strip.
+ """
+
+ assert (
+ 2 <= len(list_of_colors) <= MAX_LED_COLOR_GRADIENT
+ ), f"Color list must be between 1 and 10, got {len(list_of_colors)}"
+
+ segment_color_data = self._get_segment_color_data(list_of_colors, gradient)
+ segment_command = SegmentCommand(data=segment_color_data)
+ self._send_command(segment_command, sleep_time=0)
+
+ def _get_segment_color_data(self, list_of_colors: list[Color], gradient=True) -> SegmentData:
+ """Returns the SegmentData object for the given list of colors.
+
+ The segment data is a base64 encoded string that represents the colors to be set.
+ I have no idea what the values for the first 4 bytes are.
+ I assume the 6th byte is the number of colors.
+ """
+ gradient_flag = 1 if gradient else 0
+ byte_array = [187, 0, 32, 176, gradient_flag, len(list_of_colors)]
+
+ for color in list_of_colors:
+ byte_array.extend(color.rgb)
+
+ num2 = 0
+ for byte in byte_array:
+ num2 ^= byte
+ byte_array.append(num2)
+ final_send_value = base64.b64encode(bytes(byte_array)).decode()
+ return SegmentData(pt=final_send_value)
diff --git a/src/govee_screen_sync/models.py b/src/govee_screen_sync/models.py
new file mode 100644
index 0000000..886c5f1
--- /dev/null
+++ b/src/govee_screen_sync/models.py
@@ -0,0 +1,92 @@
+from enum import Enum
+from typing import Union
+
+from pydantic import BaseModel, Field
+
+
+class PowerState(Enum):
+ OFF = 0
+ ON = 1
+
+
+class Color(BaseModel):
+ r: int = Field(default=145, ge=0, le=255)
+ g: int = Field(default=125, ge=0, le=255)
+ b: int = Field(default=0, ge=0, le=255)
+
+ @property
+ def rgb(self):
+ return (self.r, self.g, self.b)
+
+ @classmethod
+ def from_rgb(cls, rgb: tuple[int, int, int]):
+ return cls(r=rgb[0], g=rgb[1], b=rgb[2])
+
+
+class PowerData(BaseModel):
+ value: PowerState
+
+
+class BrightnessData(BaseModel):
+ value: int
+
+
+class ColorData(BaseModel):
+ color: Color
+ colorTemInKelvin: int = 0
+
+
+class SegmentData(BaseModel):
+ pt: str
+
+
+class DeviceScanData(BaseModel):
+ account_topic: str = "reserve"
+
+
+class Command(BaseModel):
+ cmd: str
+ data: Union[PowerData, BrightnessData, ColorData, SegmentData]
+
+
+class Message(BaseModel):
+ msg: Command
+
+
+class PowerCommand(Command):
+ cmd: str = "turn"
+ data: PowerData
+
+
+class BrightnessCommand(Command):
+ cmd: str = "brightness"
+ data: BrightnessData
+
+
+class ColorCommand(Command):
+ cmd: str = "colorwc"
+ data: ColorData
+
+
+class SegmentCommand(Command):
+ cmd: str = "razer"
+ data: SegmentData
+
+
+class DeviceScanCommand(Command):
+ cmd: str = "scan"
+ data: DeviceScanData = DeviceScanData()
+
+
+class DeviceScanMessage(Message):
+ msg: DeviceScanCommand = DeviceScanCommand()
+
+
+class NetworkedGoveeDevice(BaseModel):
+ ip: str
+ name: str
+ screen_map: list[list[int]]
+
+
+class NetworkedGoveeDevices(BaseModel):
+ devices: list[NetworkedGoveeDevice]
diff --git a/src/govee_screen_sync/screen_capture.py b/src/govee_screen_sync/screen_capture.py
new file mode 100644
index 0000000..94452c3
--- /dev/null
+++ b/src/govee_screen_sync/screen_capture.py
@@ -0,0 +1,76 @@
+import numpy as np
+from PIL import Image, ImageEnhance, ImageGrab
+
+from govee_screen_sync.config import DEBUG, MAX_LED_COLOR_GRADIENT
+
+
+def capture_screen_and_process_colors() -> np.ndarray:
+ # Capture the entire screen
+ screen_image = ImageGrab.grab()
+ resize_factor = 10
+
+ # Resize image to a manageable size that maintains the aspect ratio of 10x10 blocks
+ resized_image = screen_image.resize(
+ (MAX_LED_COLOR_GRADIENT * resize_factor, MAX_LED_COLOR_GRADIENT * resize_factor)
+ )
+
+ # Increase the saturation by 1.5 times
+ converter = ImageEnhance.Color(resized_image)
+ saturated_image = converter.enhance(2.5)
+
+ # Convert image to numpy array for processing
+ color_matrix = np.array(saturated_image)
+
+ # Reshape the array to (10, 10, 10, 10, 3) where each 10x10 block's pixels are separately accessible
+ color_matrix = color_matrix.reshape(
+ (
+ MAX_LED_COLOR_GRADIENT,
+ resize_factor,
+ MAX_LED_COLOR_GRADIENT,
+ resize_factor,
+ 3,
+ )
+ )
+
+ # I favor red > green > blue for the most "colorful pixel". Let's scale the colors accordingly
+ biased_color_matrix = color_matrix.copy()
+ biased_color_matrix[..., 0] *= 2
+ biased_color_matrix[..., 1] *= 1
+ biased_color_matrix[..., 2] *= 1
+
+ # Compute variance across each pixel to find the most "colorful" pixel
+ pixel_variances = np.var(biased_color_matrix, axis=4)
+
+ # Transpose the array so that the 10x10 blocks are along the last two axes
+ pixel_variances_transposed = pixel_variances.transpose(0, 2, 1, 3)
+
+ # Flatten the last two dimensions
+ pixel_variances_flattened = pixel_variances_transposed.reshape(
+ MAX_LED_COLOR_GRADIENT, MAX_LED_COLOR_GRADIENT, -1
+ )
+
+ # Compute argmax over the flattened dimension
+ max_colorful_indices = np.unravel_index(
+ pixel_variances_flattened.argmax(axis=2), (resize_factor, resize_factor)
+ )
+
+ # For each block, get the most colorful pixel
+ most_colorful_pixels = color_matrix[
+ np.arange(MAX_LED_COLOR_GRADIENT)[:, None],
+ max_colorful_indices[0],
+ np.arange(MAX_LED_COLOR_GRADIENT),
+ max_colorful_indices[1],
+ ]
+
+ most_colorful_pixels = most_colorful_pixels.reshape(
+ MAX_LED_COLOR_GRADIENT, MAX_LED_COLOR_GRADIENT, 3
+ )
+
+ if DEBUG:
+ saturated_image.save("debug_saturated_image.png")
+ most_colorful_pixels_image = Image.fromarray(most_colorful_pixels.astype(np.uint8), "RGB")
+ most_colorful_pixels_image.save("debug_preview.png")
+ # raise an exception to stop the program and show the image
+ raise Exception("Debug mode enabled. Stopping program to show image.")
+
+ return most_colorful_pixels