Skip to content

Commit b3a68fa

Browse files
authored
Merge pull request #480 from JnyJny/features/led-parameter-support
feat: add LED parameter support for multi-LED devices
2 parents 3f4f053 + 2765eff commit b3a68fa

File tree

6 files changed

+228
-42
lines changed

6 files changed

+228
-42
lines changed

src/busylight/api/busylight_api.py

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,11 @@
2626
[Source](https://github.com/JnyJny/busylight.git)
2727
"""
2828

29-
# FastAPI Security Scheme
3029
busylightapi_security = HTTPBasic()
3130

3231

3332
class BusylightAPI(FastAPI):
3433
def __init__(self):
35-
# Get and save the debug flag
3634
debug = environ.get("BUSYLIGHT_DEBUG", False)
3735
logger.info(f"Debug: {debug}")
3836

@@ -49,13 +47,11 @@ def __init__(self):
4947
self.username = None
5048
self.password = None
5149

52-
# Get and save the CORS Access-Control-Allow-Origin header
5350
logger.info(
5451
"Set up CORS Access-Control-Allow-Origin header, if environment variable BUSYLIGHT_API_CORS_ORIGINS_LIST is set.",
5552
)
5653
self.origins = json_loads(environ.get("BUSYLIGHT_API_CORS_ORIGINS_LIST", "[]"))
5754

58-
# Validate that BUSYLIGHT_API_CORS_ORIGINS_LIST is a list of strings
5955
if (not isinstance(self.origins, list)) or any(
6056
not isinstance(item, str) for item in self.origins
6157
):
@@ -90,7 +86,6 @@ def lights(self) -> list[Light]:
9086
return self.controller.lights
9187

9288
def update(self) -> None:
93-
# Force refresh of lights in controller
9489
_ = self.controller.lights
9590

9691
def release(self) -> None:
@@ -111,10 +106,6 @@ async def apply_effect(self, effect: Effects, light_id: int = None) -> None:
111106
def get(self, path: str, **kwargs) -> Callable:
112107
self.endpoints.append(path)
113108

114-
# CORS allowed origins (for the Access-Control-Allow-Origin header)
115-
# are set through an environment variable BUSYLIGHT_API_CORS_ORIGINS_LIST
116-
# e.g.: export BUSYLIGHT_API_CORS_ORIGINS_LIST='["http://localhost", "http://localhost:8080"]'
117-
# (see https://fastapi.tiangolo.com/tutorial/cors/ for details)
118109
if self.origins:
119110
self.add_middleware(
120111
CORSMiddleware,
@@ -141,8 +132,6 @@ def authenticate_user(
141132
busylightapi = BusylightAPI()
142133

143134

144-
## Startup & Shutdown
145-
##
146135
@busylightapi.on_event("startup")
147136
async def startup() -> None:
148137
busylightapi.update()
@@ -157,8 +146,6 @@ async def shutdown() -> None:
157146
logger.debug("problem during shutdown: {error}")
158147

159148

160-
## Exception Handlers
161-
##
162149
@busylightapi.exception_handler(LightUnavailableError)
163150
async def light_unavailable_handler(
164151
request: Request,
@@ -207,8 +194,6 @@ async def color_lookup_error_handler(
207194
)
208195

209196

210-
## Middleware Handlers
211-
##
212197
@busylightapi.middleware("http")
213198
async def light_manager_update(request: Request, call_next: Callable) -> Response:
214199
"""Check for plug/unplug events and update the light manager."""
@@ -217,8 +202,6 @@ async def light_manager_update(request: Request, call_next: Callable) -> Respons
217202
return await call_next(request)
218203

219204

220-
## GET API Routes
221-
##
222205
@busylightapi.get("/", response_model=list[EndPoint])
223206
async def available_endpoints() -> list[dict[str, str]]:
224207
"""API endpoint listing.
@@ -303,6 +286,7 @@ async def light_on(
303286
light_id: int = Path(..., title="Numeric light identifier", ge=0),
304287
color: str = "green",
305288
dim: float = 1.0,
289+
led: int = 0,
306290
) -> dict[str, Any]:
307291
"""Turn on the specified light with the given `color`.
308292
@@ -311,17 +295,22 @@ async def light_on(
311295
312296
`color` can be a color name or a hexadecimal string e.g. "red",
313297
"#ff0000", "#f00", "0xff0000", "0xf00", "f00", "ff0000"
298+
299+
`led` parameter targets specific LEDs on multi-LED devices:
300+
- 0 = all LEDs (default)
301+
- 1+ = specific LED (1=first/top, 2=second/bottom, etc.)
314302
"""
315303
rgb = parse_color_string(color, dim)
316-
steady = Effects.for_name("steady")(rgb)
317-
await busylightapi.apply_effect(steady, light_id)
304+
305+
busylightapi.controller.by_index(light_id).turn_on(rgb, led=led)
318306

319307
return {
320308
"action": "on",
321309
"light_id": light_id,
322310
"color": color,
323311
"rgb": rgb,
324312
"dim": dim,
313+
"led": led,
325314
}
326315

327316

@@ -332,22 +321,28 @@ async def light_on(
332321
async def lights_on(
333322
color: str = "green",
334323
dim: float = 1.0,
324+
led: int = 0,
335325
) -> dict[str, Any]:
336326
"""Turn on all lights with the given `color`.
337327
338328
`color` can be a color name or a hexadecimal string e.g. "red",
339329
"#ff0000", "#f00", "0xff0000", "0xf00", "f00", "ff0000"
330+
331+
`led` parameter targets specific LEDs on multi-LED devices:
332+
- 0 = all LEDs (default)
333+
- 1+ = specific LED (1=first/top, 2=second/bottom, etc.)
340334
"""
341335
rgb = parse_color_string(color, dim)
342-
steady = Effects.for_name("steady")(rgb)
343-
await busylightapi.apply_effect(steady)
336+
337+
busylightapi.controller.all().turn_on(rgb, led=led)
344338

345339
return {
346340
"action": "on",
347341
"light_id": "all",
348342
"color": color,
349343
"rgb": rgb,
350344
"dim": dim,
345+
"led": led,
351346
}
352347

353348

@@ -397,6 +392,7 @@ async def blink_light(
397392
speed: Speed = Speed.Slow,
398393
dim: float = 1.0,
399394
count: int = 0,
395+
led: int = 0,
400396
) -> dict[str, Any]:
401397
"""Start blinking the specified light: color and off.
402398
@@ -407,12 +403,15 @@ async def blink_light(
407403
#ff0000, #f00, 0xff0000, 0xf00, f00, ff0000
408404
409405
`count` is the number of times to blink the light.
406+
407+
`led` parameter targets specific LEDs on multi-LED devices:
408+
- 0 = all LEDs (default)
409+
- 1+ = specific LED (1=first/top, 2=second/bottom, etc.)
410410
"""
411411
rgb = parse_color_string(color, dim)
412412

413-
# Use controller's fluent API
414413
selection = busylightapi.controller.by_index(light_id)
415-
selection.blink(rgb, count=count, speed=speed.name.lower())
414+
selection.blink(rgb, count=count, speed=speed.name.lower(), led=led)
416415

417416
return {
418417
"action": "blink",
@@ -422,6 +421,7 @@ async def blink_light(
422421
"speed": speed,
423422
"dim": dim,
424423
"count": count,
424+
"led": led,
425425
}
426426

427427

@@ -434,15 +434,19 @@ async def blink_lights(
434434
speed: Speed = Speed.Slow,
435435
dim: float = 1.0,
436436
count: int = 0,
437+
led: int = 0,
437438
) -> dict[str, Any]:
438439
"""Start blinking all the lights: red and off
439440
<p>Note: lights will not be synchronized.</p>
441+
442+
`led` parameter targets specific LEDs on multi-LED devices:
443+
- 0 = all LEDs (default)
444+
- 1+ = specific LED (1=first/top, 2=second/bottom, etc.)
440445
"""
441446
rgb = parse_color_string(color, dim)
442447

443-
# Use controller's fluent API
444448
selection = busylightapi.controller.all()
445-
selection.blink(rgb, count=count, speed=speed.name.lower())
449+
selection.blink(rgb, count=count, speed=speed.name.lower(), led=led)
446450

447451
return {
448452
"action": "blink",
@@ -452,6 +456,7 @@ async def blink_lights(
452456
"speed": speed,
453457
"dim": dim,
454458
"count": count,
459+
"led": led,
455460
}
456461

457462

src/busylight/controller.py

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,20 +69,29 @@ def __iter__(self) -> Iterator[Light]:
6969
"""Return an iterator over the lights in this selection."""
7070
return iter(self.lights)
7171

72-
def turn_on(self, color: tuple[int, int, int]) -> LightSelection:
72+
def turn_on(self, color: tuple[int, int, int], led: int = 0) -> LightSelection:
7373
"""Turn on all lights in the selection with the specified color.
7474
7575
:param color: RGB color tuple with values 0-255
76+
:param led: Target LED index. 0 affects all LEDs, 1+ targets specific LEDs
77+
78+
For devices with multiple LEDs (like Blink1 mk2), use led parameter to control
79+
individual LEDs. Single-LED devices ignore this parameter. LED indexing is
80+
device-specific but typically: 0=all, 1=first/top, 2=second/bottom, etc.
7681
7782
Example:
78-
Turn lights red::
83+
Turn all LEDs red::
84+
85+
selection.turn_on((255, 0, 0)) # led=0 default
86+
87+
Turn only top LED of Blink1 mk2 blue::
7988
80-
selection.turn_on((255, 0, 0))
89+
selection.turn_on((0, 0, 255), led=1)
8190
"""
8291

8392
for light in self.lights:
8493
try:
85-
light.on(color)
94+
light.on(color, led=led)
8695
except LightUnavailableError as error:
8796
logger.debug(f"Light unavailable during turn_on: {error}")
8897

@@ -115,25 +124,37 @@ def blink(
115124
color: tuple[int, int, int],
116125
count: int = 0,
117126
speed: str = "slow",
127+
led: int = 0,
118128
) -> LightSelection:
119129
"""Apply blink effect to all lights in the selection.
120130
121131
:param color: RGB color tuple with values 0-255
122132
:param count: Number of blinks. 0 means infinite blinking
123133
:param speed: Blink speed - "slow", "medium", or "fast"
134+
:param led: Target LED index. 0 affects all LEDs, 1+ targets specific LEDs
135+
136+
For devices with multiple LEDs, use led parameter to blink specific LEDs.
137+
Single-LED devices ignore this parameter.
124138
125139
Example:
126-
Blink lights green 5 times at fast speed::
140+
Blink all LEDs green 5 times at fast speed::
127141
128142
selection.blink((0, 255, 0), count=5, speed="fast")
143+
144+
Blink only bottom LED of Blink1 mk2::
145+
146+
selection.blink((255, 0, 0), led=2)
129147
"""
130148
try:
131149
speed_obj = Speed(speed)
132150
except ValueError:
133151
speed_obj = Speed.slow
134152

135-
effect = Effects.for_name("blink")(color, count=count)
136-
return self.apply_effect(effect, interval=speed_obj.duty_cycle)
153+
if led == 0:
154+
effect = Effects.for_name("blink")(color, count=count)
155+
return self.apply_effect(effect, interval=speed_obj.duty_cycle)
156+
else:
157+
return self._apply_led_blink(color, count, speed_obj.duty_cycle, led)
137158

138159
def apply_effect(
139160
self,
@@ -194,6 +215,68 @@ async def effect_task(target_light=light):
194215

195216
return self
196217

218+
def _apply_led_blink(
219+
self,
220+
color: tuple[int, int, int],
221+
count: int,
222+
interval: float,
223+
led: int,
224+
) -> LightSelection:
225+
"""Apply LED-aware blink effect to all lights in the selection.
226+
227+
:param color: RGB color tuple for the blink effect
228+
:param count: Number of blinks, 0 means infinite
229+
:param interval: Interval between blinks in seconds
230+
:param led: Target LED index for multi-LED devices
231+
"""
232+
233+
async def led_blink_supervisor():
234+
tasks = []
235+
for light in self.lights:
236+
light.cancel_tasks()
237+
238+
async def led_blink_task(target_light=light):
239+
blink_count = 0
240+
try:
241+
while count == 0 or blink_count < count:
242+
target_light.on(color, led=led)
243+
await asyncio.sleep(interval)
244+
target_light.on((0, 0, 0), led=led)
245+
await asyncio.sleep(interval)
246+
blink_count += 1
247+
finally:
248+
target_light.on((0, 0, 0), led=led)
249+
250+
task = light.add_task(
251+
name="led_blink",
252+
func=led_blink_task,
253+
priority=1,
254+
replace=True,
255+
)
256+
tasks.append(task)
257+
258+
if tasks:
259+
if count > 0:
260+
await asyncio.wait(tasks)
261+
else:
262+
try:
263+
await asyncio.wait(tasks)
264+
except KeyboardInterrupt:
265+
for task in tasks:
266+
task.cancel()
267+
raise
268+
269+
try:
270+
loop = asyncio.get_running_loop()
271+
asyncio.create_task(led_blink_supervisor())
272+
except RuntimeError:
273+
try:
274+
asyncio.run(led_blink_supervisor())
275+
except KeyboardInterrupt:
276+
pass
277+
278+
return self
279+
197280

198281
class LightController:
199282
"""Light controller with fluent interface for managing USB LED lights.

src/busylight/subcommands/blink.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,27 +56,41 @@ def blink_lights(
5656
show_default=True,
5757
help="Number of blinks. 0 means infinite.",
5858
),
59+
led: int = typer.Option(
60+
0,
61+
"--led",
62+
help="Target LED index (0=all LEDs, 1+=specific LED for multi-LED devices)",
63+
show_default=True,
64+
),
5965
) -> None:
6066
"""Create a blinking effect on selected lights.
6167
6268
:param ctx: Typer context containing global options and controller
6369
:param color: Color specification for the blink effect
6470
:param speed: Blink speed (slow, medium, or fast)
6571
:param count: Number of blinks. 0 means infinite blinking
72+
:param led: Target LED index for multi-LED devices
6673
6774
This command applies a blinking effect to the selected lights using
68-
the specified color, speed, and count. The effect runs asynchronously
69-
and can be interrupted with Ctrl+C.
75+
the specified color, speed, and count. For devices with multiple LEDs
76+
(like Blink1 mk2), use --led to target specific LEDs.
7077
78+
The effect runs asynchronously and can be interrupted with Ctrl+C.
7179
For infinite blinking (count=0), the command will continue until
7280
interrupted. For finite counts, the command exits after the specified
7381
number of blinks complete.
82+
83+
Example:
84+
Target specific LEDs::
85+
86+
busylight blink red --led 1 --count 3 # Top LED only
87+
busylight blink green --led 2 # Bottom LED infinite
7488
"""
7589
logger.info("Blinking lights with color: {}", color)
7690

7791
try:
7892
selection = get_light_selection(ctx)
79-
selection.blink(color, count=count, speed=speed.name.lower())
93+
selection.blink(color, count=count, speed=speed.name.lower(), led=led)
8094
except (KeyboardInterrupt, TimeoutError):
8195
selection = get_light_selection(ctx)
8296
selection.turn_off()

0 commit comments

Comments
 (0)