Skip to content

Commit bf2b158

Browse files
committed
Refactor CI workflow and improve code structure; update dependencies and enhance test coverage
1 parent 10ac28c commit bf2b158

14 files changed

Lines changed: 196 additions & 310 deletions

File tree

.github/workflows/main.yml

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -50,33 +50,25 @@ jobs:
5050
test:
5151
runs-on: windows-latest
5252
steps:
53-
- uses: actions/checkout@v2
54-
- name: Set up Python 3.8
55-
uses: actions/setup-python@v1
53+
- uses: actions/checkout@v4
54+
- name: Set up Python
55+
uses: actions/setup-python@v5
5656
with:
57-
python-version: '3.8.x' # Semantic version range syntax or exact version of a Python version
58-
architecture: 'x64'
59-
- name: Cache pip
60-
uses: actions/cache@v1
61-
with:
62-
path: ~/.cache/pip # This path is specific to Ubuntu
63-
# Look to see if there is a cache hit for the corresponding requirements file
64-
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
65-
restore-keys: |
66-
${{ runner.os }}-pip-
67-
${{ runner.os }}-
57+
python-version: '3.14'
58+
cache: 'pip'
59+
- name: Install deps
60+
run: |
61+
python -m pip install --upgrade pip
62+
pip install -r requirements.txt -r requirements-dev.txt
6863
- name: Test Project
64+
working-directory: ./lifx_control_panel
6965
run: |
70-
pip3 install --user -r requirements.txt
71-
pip3 install --user -r requirements-dev.txt
72-
cd ./lifx_control_panel
73-
set PYTHONPATH=.
7466
coverage run -m unittest discover test -p "*test*.py"
7567
coverage report
7668
coverage xml -o coverage.xml
77-
cd ..
7869
- name: Upload Coverage to Codecov
79-
uses: codecov/codecov-action@v2
70+
uses: codecov/codecov-action@v5
8071
with:
8172
files: ./lifx_control_panel/coverage.xml
82-
flags: unittests
73+
flags: unittests
74+
fail_ci_if_error: false

lifx_control_panel/__main__.pyw

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ if os.name == 'nt':
2727

2828
from lifx_control_panel import HEARTBEAT_RATE_MS, FRAME_PERIOD_MS, LOGFILE
2929
from lifx_control_panel._constants import BUILD_DATE, AUTHOR, DEBUGGING, VERSION
30-
from lifx_control_panel.frames import LightFrame, MultiZoneFrame, GroupFrame
30+
from lifx_control_panel.frames import LightFrame, GroupFrame
3131
from lifx_control_panel.ui import settings
3232
from lifx_control_panel.ui.icon_list import BulbIconList
3333
from lifx_control_panel.ui.settings import config
@@ -116,23 +116,16 @@ class LifxFrame(ttk.Frame): # pylint: disable=too-many-ancestors
116116
self.group_icons.canvas.bind('<Button-1>', self.on_bulb_canvas_click)
117117

118118
# Setup tray icon
119-
def lambda_quit(self_):
120-
""" Build an anonymous function call w/ correct 'self' scope"""
121-
return lambda *_, **__: self_.on_closing()
122-
123-
def lambda_adjust(self_):
124-
return lambda *_, **__: self_.master.deiconify()
125-
126119
def run_tray_icon():
127120
""" Allow SysTrayIcon in a separate thread """
128121
image = Image.open(resource_path('res/icon_vector.ico'))
129122

130123
icon = pystray.Icon("LIFX Control Panel", image, menu=pystray.Menu(
131124
pystray.MenuItem('Open',
132-
lambda_adjust(self),
125+
lambda *_, **__: self.master.deiconify(),
133126
default=True),
134127
pystray.MenuItem('Quit',
135-
lambda_quit(self)),
128+
lambda *_, **__: self.on_closing()),
136129
))
137130
icon.run()
138131

@@ -165,8 +158,6 @@ class LifxFrame(ttk.Frame): # pylint: disable=too-many-ancestors
165158
if not stop_event.is_set():
166159
stop_event.set()
167160
device_list: List[Union[lifxlan.Group, lifxlan.Light, lifxlan.MultiZoneLight]] = self.lifx.get_devices()
168-
if self.bulb_interface:
169-
del self.bulb_interface
170161
self.bulb_interface = AsyncBulbInterface(stop_event, HEARTBEAT_RATE_MS)
171162
self.bulb_interface.set_device_list(device_list)
172163
self.bulb_interface.daemon = True
@@ -184,10 +175,8 @@ class LifxFrame(ttk.Frame): # pylint: disable=too-many-ancestors
184175
if label not in self.bulb_icons.bulb_dict:
185176
self.bulb_icons.draw_bulb_icon(light, label)
186177
if label not in self.frame_map:
187-
if light.supports_multizone():
188-
self.frame_map[label] = MultiZoneFrame(self, light)
189-
else:
190-
self.frame_map[label] = LightFrame(self, light)
178+
# LightFrame._get_light_info already handles multizone devices
179+
self.frame_map[label] = LightFrame(self, light)
191180
self.current_lightframe = self.frame_map[label]
192181
try:
193182
self.bulb_icons.set_selected_bulb(label)
@@ -260,13 +249,9 @@ class LifxFrame(ttk.Frame): # pylint: disable=too-many-ancestors
260249

261250
def save_keybind(self, light, keypress, color):
262251
""" Builds a new anonymous function changing light to color when keypress is entered. """
263-
264-
def lambda_factory(self, light, color):
265-
""" https://stackoverflow.com/questions/938429/scope-of-lambda-functions-and-their-parameters """
266-
return lambda *_, **__: self.device_map[light].set_color(color,
267-
duration=float(config["AverageColor"]["duration"]))
268-
269-
func = lambda_factory(self, light, color)
252+
# default args bind light/color now, not at call time
253+
func = lambda *_, light=light, color=color, **__: self.device_map[light].set_color(
254+
color, duration=float(config["AverageColor"]["duration"]))
270255
self.key_listener.register_function(keypress, func)
271256

272257
def delete_keybind(self, keycombo):

lifx_control_panel/frames.py

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from tkinter import ttk, font as font, messagebox, _setit
44
from typing import Union, List, Tuple, Dict, Mapping
55

6-
import mouse
76
import lifxlan
87
import win32api
98

@@ -544,20 +543,12 @@ def set_color(self, color, rapid=False):
544543
"Color changed to HSBK: %s", color
545544
) # Don't pollute log with rapid color changes
546545

547-
def update_label(self, key: int):
546+
def update_label(self):
548547
""" Update scale labels, formatted accordingly. """
549-
return [
550-
self.hsbk_labels[0].config(
551-
text=str(f"{360 * (self.hsbk[0].get() / 65535):.3g}")
552-
),
553-
self.hsbk_labels[1].config(
554-
text=str(f"{100 * (self.hsbk[1].get() / 65535):.3g}") + "%"
555-
),
556-
self.hsbk_labels[2].config(
557-
text=str(f"{100 * (self.hsbk[2].get() / 65535):.3g}") + "%"
558-
),
559-
self.hsbk_labels[3].config(text=str(self.hsbk[3].get()) + " K"),
560-
][key]
548+
self.hsbk_labels[0].config(text=f"{360 * (self.hsbk[0].get() / 65535):.3g}")
549+
self.hsbk_labels[1].config(text=f"{100 * (self.hsbk[1].get() / 65535):.3g}%")
550+
self.hsbk_labels[2].config(text=f"{100 * (self.hsbk[2].get() / 65535):.3g}%")
551+
self.hsbk_labels[3].config(text=f"{self.hsbk[3].get()} K")
561552

562553
def update_display(self, key: int):
563554
""" Update color swatches to match current device state """
@@ -632,8 +623,8 @@ def update_status_from_bulb(self, run_once=False):
632623
require_icon_update = True
633624
for key, _ in enumerate(self.hsbk):
634625
self.hsbk[key].set(hsbk[key])
635-
self.update_label(key)
636626
self.update_display(key)
627+
self.update_label()
637628
self.current_color.config(background=tuple2hex(hsbk_to_rgb(hsbk)))
638629

639630
if require_icon_update:
@@ -656,7 +647,7 @@ def eyedropper(self, *_, **__):
656647
lifxlan.sleep(0.001)
657648
# tkinter.Button state changed
658649
screen_img = get_screen_as_image()
659-
cursor_pos = mouse.get_position()
650+
cursor_pos = win32api.GetCursorPos()
660651
# Convert display coords to image coords
661652
cursor_pos = normalize_rectangles(
662653
get_display_rects() + [(cursor_pos[0], cursor_pos[1], 0, 0)]
@@ -752,7 +743,3 @@ def _get_light_info(self, target: lifxlan.Group) -> Tuple[int, Color]:
752743

753744
def update_status_from_bulb(self, run_once=False):
754745
return
755-
756-
757-
class MultiZoneFrame(LightFrame):
758-
pass
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import os
2+
import sys
3+
import unittest
4+
5+
# color_thread uses package-relative imports, so make the repo root importable
6+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
7+
8+
from lifx_control_panel.utilities.color_thread import (
9+
ColorCycle,
10+
get_monitor_bounds,
11+
normalize_rectangles,
12+
)
13+
from lifx_control_panel.utilities.utils import Color
14+
15+
16+
class TestNormalizeRectangles(unittest.TestCase):
17+
def test_shifts_origin_to_zero(self):
18+
rects = [(-1920, 0, 0, 1080), (0, 0, 1920, 1080)]
19+
self.assertEqual(
20+
normalize_rectangles(rects),
21+
[(0, 0, 1920, 1080), (1920, 0, 3840, 1080)],
22+
)
23+
24+
def test_already_normalized_is_unchanged(self):
25+
rects = [(0, 0, 800, 600)]
26+
self.assertEqual(normalize_rectangles(rects), rects)
27+
28+
29+
class TestGetMonitorBounds(unittest.TestCase):
30+
def test_uses_func_result_when_truthy(self):
31+
self.assertEqual(get_monitor_bounds(lambda: "[0, 0, 100, 100]"), "[0, 0, 100, 100]")
32+
33+
34+
class TestColorCycle(unittest.TestCase):
35+
def _tick(self, cycle):
36+
# Backdate the throttle so get_color advances immediately
37+
cycle.last_change = 0
38+
return cycle.get_color()
39+
40+
def test_respects_brightness(self):
41+
cycle = ColorCycle()
42+
cycle.initial_color = Color(0, 65535, 0, 3500)
43+
hsbk = self._tick(cycle)
44+
self.assertEqual(hsbk[2], 0) # zero input brightness stays dark
45+
46+
cycle.initial_color = Color(0, 65535, 65535, 3500)
47+
hsbk = self._tick(cycle)
48+
self.assertEqual(hsbk[2], 65535)
49+
50+
def test_hue_advances_and_wraps(self):
51+
cycle = ColorCycle()
52+
cycle.initial_color = Color(0, 65535, 65535, 3500)
53+
cycle.pos = 359
54+
self._tick(cycle)
55+
self.assertEqual(cycle.pos, 0)
56+
self._tick(cycle)
57+
self.assertEqual(cycle.pos, 1)
58+
59+
def test_throttles_within_interval(self):
60+
cycle = ColorCycle()
61+
cycle.initial_color = Color(0, 65535, 65535, 3500)
62+
self._tick(cycle)
63+
pos = cycle.pos
64+
cycle.get_color() # last_change is fresh, so no advance
65+
self.assertEqual(cycle.pos, pos)
66+
67+
68+
if __name__ == "__main__":
69+
unittest.main()

lifx_control_panel/test/dummy_devices.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
import os
5+
import sys
56
import time
67
import traceback
78
from tkinter import *
@@ -240,16 +241,16 @@ def set_infared(self, val: int):
240241
self.infared_brightness = val
241242

242243
def set_hue(self, hue, duration=0, rapid=False):
243-
self.color.hue = hue
244+
self.color = self.color._replace(hue=hue)
244245

245246
def set_brightness(self, brightness, duration=0, rapid=False):
246-
self.color.brightness = brightness
247+
self.color = self.color._replace(brightness=brightness)
247248

248249
def set_saturation(self, saturation, duration=0, rapid=False):
249-
self.color.saturation = saturation
250+
self.color = self.color._replace(saturation=saturation)
250251

251252
def set_colortemp(self, kelvin, duration=0, rapid=False):
252-
self.color.kelvin = kelvin
253+
self.color = self.color._replace(kelvin=kelvin)
253254

254255

255256
class MultiZoneDummy(DummyBulb):

lifx_control_panel/test/functional_test.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
Color,
44
hsbk_to_rgb,
55
hsv_to_rgb,
6+
kelvin_to_rgb,
67
tuple2hex,
78
str2list,
89
str2tuple,
@@ -28,14 +29,7 @@ def test_color(self):
2829
c2 = Color(65535, 65535, 65535, 9000)
2930
self._cmp_color(c2, 65535, 65535, 65535, 9000)
3031

31-
c3 = c1 + c2
32-
self._cmp_color(c3, 65535, 65535, 65535, 9000)
33-
34-
self.assertEqual(c3 - c2, c1)
35-
36-
self.assertEqual(str(c1), "[0, 0, 0, 0]")
37-
38-
c3[0] = 12345
32+
c3 = c2._replace(hue=12345)
3933
self._cmp_color(c3, 12345, 65535, 65535, 9000)
4034

4135
for i, v in enumerate(c3):
@@ -49,6 +43,33 @@ def test_conversion(self):
4943
hsv1 = hsv_to_rgb(*(0, 0, 0))
5044
self.assertEqual(hsv1, rgb1)
5145

46+
def test_hsbk_to_rgb_hues(self):
47+
# Pure hues at full saturation/brightness, neutral kelvin
48+
self.assertEqual(hsbk_to_rgb(Color(0, 65535, 65535, 3500)), (255, 0, 0))
49+
self.assertEqual(hsbk_to_rgb(Color(21845, 65535, 65535, 3500)), (0, 255, 0))
50+
self.assertEqual(hsbk_to_rgb(Color(43690, 65535, 65535, 3500)), (0, 0, 255))
51+
52+
def test_hsbk_to_rgb_brightness(self):
53+
# Half brightness halves the output
54+
self.assertEqual(hsbk_to_rgb(Color(0, 65535, 32768, 3500)), (127, 0, 0))
55+
56+
def test_hsbk_to_rgb_desaturated(self):
57+
# Zero saturation falls through to the kelvin white point
58+
self.assertEqual(
59+
hsbk_to_rgb(Color(0, 0, 65535, 6500)), kelvin_to_rgb(6500)
60+
)
61+
62+
def test_kelvin_to_rgb(self):
63+
# Known white points: warm is red-heavy, cool is blue-heavy
64+
self.assertEqual(kelvin_to_rgb(1500), (255, 108, 0))
65+
self.assertEqual(kelvin_to_rgb(3500), (255, 192, 140))
66+
self.assertEqual(kelvin_to_rgb(6500), (255, 254, 250))
67+
self.assertEqual(kelvin_to_rgb(9000), (209, 222, 255))
68+
# Every component stays in displayable range across the LIFX span
69+
for kelvin in range(1500, 9001, 500):
70+
for component in kelvin_to_rgb(kelvin):
71+
self.assertTrue(0 <= component <= 255, f"{kelvin}K -> {component}")
72+
5273
def test_str_conversion(self):
5374
rgb1 = (1, 2, 3)
5475
self.assertEqual(tuple2hex(rgb1), "#010203")

lifx_control_panel/ui/colorscale.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@ def __init__(
6161

6262
self.bind("<Configure>", lambda _: self._draw_gradient(val))
6363
self.bind("<ButtonPress-1>", self._on_click)
64-
# self.bind('<ButtonRelease-1>', self._on_release)
6564
self.bind("<B1-Motion>", self._on_move)
6665

6766
def _draw_gradient(self, val):

0 commit comments

Comments
 (0)