Skip to content

Commit cf302b1

Browse files
committed
feat: add CLI scribble (per-LED) support for 0xB6
- Add processCustomArgs 'scribble' branch (before speed=int(args[1])) with _process_scribble_args helper and _parse_scribble_led_token - Parse effect names (static/flowing/twinkling/stars_wink/accumulate) - Expand run-length syntax: NxCOLOR, w<level>, off, bare color - Parse optional key=value trailing tokens: speed, density, dir, blink, blinkspeed - Bake global blink into each ScribbleLED at parse time - Add dispatch block (mode=="scribble") calling bulb.async_set_scribble - Update --custom help text and showUsageExamples/--listpresets with scribble example - Add 10 CLI parsing tests covering run-length, white, off, malformed, kv args
1 parent 516e1e1 commit cf302b1

2 files changed

Lines changed: 435 additions & 3 deletions

File tree

flux_led/fluxled.py

Lines changed: 219 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import asyncio
4444
import datetime
4545
import logging
46+
import re
4647
import sys
4748
from optparse import OptionGroup, OptionParser, Values
4849
from typing import Any, cast
@@ -57,6 +58,9 @@
5758
TIMER_ACTION_ON,
5859
ExtendedCustomEffectDirection,
5960
ExtendedCustomEffectPattern,
61+
ScribbleBlinkMode,
62+
ScribbleEffect,
63+
ScribbleLED,
6064
)
6165
from .pattern import PresetPattern
6266
from .protocol import ProtocolLEDENETExtendedCustom
@@ -125,6 +129,9 @@ def showUsageExamples() -> None:
125129
Set custom segment colors (0xB6 devices) - set each segment individually:
126130
%prog% 192.168.1.100 -C segments 0 "red green blue off off red green blue"
127131
132+
Set per-LED scribble colors (0xB6 devices) - specify each LED individually:
133+
%prog% 192.168.1.100 -C scribble static "10xred 70xoff"
134+
128135
Sync all bulb's clocks with this computer's:
129136
%prog% -sS --setclock
130137
@@ -515,6 +522,165 @@ def processSetTimerArgsExtended(
515522
raise SystemExit(1) # For type checker
516523

517524

525+
_SCRIBBLE_EFFECT_NAMES: dict[str, ScribbleEffect] = {
526+
"static": ScribbleEffect.STATIC,
527+
"flowing": ScribbleEffect.FLOWING,
528+
"twinkling": ScribbleEffect.TWINKLING_STARS,
529+
"stars_wink": ScribbleEffect.STARS_WINK,
530+
"accumulate": ScribbleEffect.ACCUMULATE,
531+
}
532+
533+
_SCRIBBLE_BLINK_NAMES: dict[str, ScribbleBlinkMode] = {
534+
"none": ScribbleBlinkMode.NONE,
535+
"slow": ScribbleBlinkMode.SLOW,
536+
"fast": ScribbleBlinkMode.FAST,
537+
}
538+
539+
_RUN_LENGTH_RE = re.compile(r"^(?:(\d+)x)?(.+)$")
540+
_WHITE_LEVEL_RE = re.compile(r"^w(\d+)$")
541+
542+
543+
def _parse_scribble_led_token(
544+
token: str,
545+
parser: OptionParser,
546+
blink_mode: ScribbleBlinkMode,
547+
blink_speed: int,
548+
) -> ScribbleLED:
549+
"""Parse a single LED value token (without run-length prefix) into a ScribbleLED."""
550+
token_lower = token.lower()
551+
if token_lower == "off":
552+
return ScribbleLED(rgb=None, white=None, blink_mode=blink_mode, blink_speed=blink_speed)
553+
# White-level token: 'w' followed by DIGITS ONLY (e.g. w50). A token like
554+
# 'white' or 'wheat' is a color name, so it falls through to the color parser.
555+
white_match = _WHITE_LEVEL_RE.match(token_lower)
556+
if white_match:
557+
level = int(white_match.group(1))
558+
return ScribbleLED(rgb=None, white=level, blink_mode=blink_mode, blink_speed=blink_speed)
559+
# Color token
560+
c = utils.color_object_to_tuple(token)
561+
if c is None or len(c) < 3:
562+
parser.error(
563+
f"Invalid color token '{token}'. Use a color name, hex value, or r,g,b triple."
564+
)
565+
raise SystemExit(1)
566+
return ScribbleLED(rgb=(c[0], c[1], c[2]), white=None, blink_mode=blink_mode, blink_speed=blink_speed)
567+
568+
569+
def _process_scribble_args(
570+
parser: OptionParser,
571+
args: Any,
572+
) -> dict[str, Any] | None:
573+
"""Parse scribble-mode args: ('scribble', <effect>, '<per-led-spec>')."""
574+
effect_name = args[1].lower() if len(args) > 1 else "static"
575+
if effect_name not in _SCRIBBLE_EFFECT_NAMES:
576+
parser.error(
577+
f"Unknown scribble effect '{effect_name}'. "
578+
f"Valid: {', '.join(_SCRIBBLE_EFFECT_NAMES)}."
579+
)
580+
return None
581+
effect = _SCRIBBLE_EFFECT_NAMES[effect_name]
582+
583+
spec_str = args[2].strip() if len(args) > 2 else ""
584+
585+
# Defaults for optional key=value tokens
586+
density = 80
587+
speed = 100
588+
direction = ExtendedCustomEffectDirection.LEFT_TO_RIGHT
589+
blink_mode = ScribbleBlinkMode.NONE
590+
blink_speed = 100
591+
592+
# Split spec on whitespace; separate LED tokens from key=value tokens
593+
all_tokens = spec_str.split()
594+
led_tokens: list[str] = []
595+
for tok in all_tokens:
596+
if "=" in tok:
597+
key, _, val = tok.partition("=")
598+
key = key.lower().strip()
599+
val = val.strip()
600+
if key == "density":
601+
try:
602+
density = int(val)
603+
except ValueError:
604+
parser.error(f"Invalid density value '{val}'. Must be an integer.")
605+
return None
606+
elif key == "speed":
607+
try:
608+
speed = int(val)
609+
except ValueError:
610+
parser.error(f"Invalid speed value '{val}'. Must be an integer.")
611+
return None
612+
elif key == "dir":
613+
if val.lower() in ("l2r", "left", "ltr"):
614+
direction = ExtendedCustomEffectDirection.LEFT_TO_RIGHT
615+
elif val.lower() in ("r2l", "right", "rtl"):
616+
direction = ExtendedCustomEffectDirection.RIGHT_TO_LEFT
617+
else:
618+
parser.error(f"Invalid dir value '{val}'. Use l2r or r2l.")
619+
return None
620+
elif key == "blink":
621+
bl = val.lower()
622+
if bl not in _SCRIBBLE_BLINK_NAMES:
623+
parser.error(
624+
f"Invalid blink value '{val}'. Use none, slow, or fast."
625+
)
626+
return None
627+
blink_mode = _SCRIBBLE_BLINK_NAMES[bl]
628+
elif key == "blinkspeed":
629+
try:
630+
blink_speed = int(val)
631+
except ValueError:
632+
parser.error(f"Invalid blinkspeed value '{val}'. Must be an integer.")
633+
return None
634+
else:
635+
parser.error(f"Unknown scribble option '{key}'.")
636+
return None
637+
else:
638+
led_tokens.append(tok)
639+
640+
# Expand run-length tokens into ScribbleLED list
641+
leds: list[ScribbleLED] = []
642+
for tok in led_tokens:
643+
m = _RUN_LENGTH_RE.match(tok)
644+
if not m:
645+
parser.error(f"Malformed LED token '{tok}'.")
646+
return None
647+
count_str, value_part = m.group(1), m.group(2)
648+
count = int(count_str) if count_str else 1
649+
led = _parse_scribble_led_token(value_part, parser, blink_mode, blink_speed)
650+
leds.extend([led] * count)
651+
652+
if not leds:
653+
parser.error("At least one LED entry is required for scribble mode.")
654+
return None
655+
656+
return {
657+
"mode": "scribble",
658+
"effect": effect.value,
659+
"leds": leds,
660+
"direction": direction.value,
661+
"density": density,
662+
"speed": speed,
663+
}
664+
665+
666+
def _reconcile_scribble_led_count(
667+
leds: list[ScribbleLED],
668+
device_count: int | None,
669+
) -> list[ScribbleLED]:
670+
"""Reconcile a parsed LED list against the device's configured LED count.
671+
672+
If device_count is None (state unknown), return leds unchanged. Otherwise
673+
pad the tail with off-LEDs or truncate so the result is exactly
674+
device_count entries.
675+
"""
676+
if device_count is None or len(leds) == device_count:
677+
return leds
678+
if len(leds) > device_count:
679+
return leds[:device_count]
680+
padding = [ScribbleLED(rgb=None, white=None)] * (device_count - len(leds))
681+
return leds + padding
682+
683+
518684
def processCustomArgs(
519685
parser: OptionParser,
520686
args: Any,
@@ -542,6 +708,12 @@ def processCustomArgs(
542708
extended_pattern_names[p.name.lower()] = p.value
543709

544710
pattern_type = args[0].lower()
711+
712+
# Check if this is "scribble" mode (must come before speed = int(args[1])
713+
# because args[1] is an effect name, not a numeric speed)
714+
if pattern_type == "scribble":
715+
return _process_scribble_args(parser, args)
716+
545717
speed = int(args[1])
546718

547719
# Check if this is a standard pattern
@@ -808,8 +980,9 @@ def parseArgs() -> tuple[Values, Any]:
808980
nargs=3,
809981
help="Set custom pattern mode. "
810982
+ "TYPE: jump, gradual, strobe (standard), or extended pattern names "
811-
+ "(wave, meteor, breathe, etc. for 0xB6 devices), or 'segments' for static colors. "
812-
+ "SPEED is percent (0-100). "
983+
+ "(wave, meteor, breathe, etc. for 0xB6 devices), or 'segments' for static colors, "
984+
+ "or 'scribble' for per-LED colors (0xB6 devices). "
985+
+ "SPEED is percent (0-100) (for scribble, use the effect name instead). "
813986
+ "COLORLIST is a space-separated list of color names, hex values, or RGB triples. "
814987
+ "Use --density, --direction, --colorchange for extended pattern options.",
815988
)
@@ -931,6 +1104,10 @@ def parseArgs() -> tuple[Values, Any]:
9311104
print(f" {p.name.lower().replace('_', ' '):<20} (ID: {p.value})")
9321105
print("\nSegment colors (-C segments, 0xB6 devices only):")
9331106
print(" segments - Set individual segment colors (up to 20)")
1107+
print("\nPer-LED scribble (-C scribble, 0xB6 devices only):")
1108+
print(" scribble <effect> \"<per-led-spec>\" - Set per-LED colors/blink")
1109+
print(" Effects: static, flowing, twinkling, stars_wink, accumulate")
1110+
print(" Example: -C scribble static \"10xred 70xoff\"")
9341111
sys.exit(0)
9351112

9361113
if options.listcolors:
@@ -1164,6 +1341,46 @@ def buf_in(str: str) -> None:
11641341
buf_in(f"Setting custom segment colors: {len(custom['colors'])} segments")
11651342
await bulb.async_set_custom_segment_colors(custom["colors"])
11661343

1344+
elif mode == "scribble":
1345+
# Per-LED scribble configuration
1346+
if not bulb.supports_scribble:
1347+
buf_in(
1348+
f"{bulb.model} does not support the scribble (per-LED) feature."
1349+
)
1350+
else:
1351+
leds = custom["leds"]
1352+
effect = ScribbleEffect(custom["effect"])
1353+
direction = ExtendedCustomEffectDirection(custom["direction"])
1354+
density = custom["density"]
1355+
speed = custom["speed"]
1356+
# Reconcile parsed LED count with the device's configured count:
1357+
# pad the tail with off-LEDs or truncate so the upload is exactly
1358+
# the device's LED count (avoids the API's hard count check).
1359+
device_count = bulb.led_count
1360+
if device_count is not None and len(leds) != device_count:
1361+
buf_in(
1362+
f"Warning: {len(leds)} LED entries provided but device has "
1363+
f"{device_count} LEDs; "
1364+
+ (
1365+
"padding tail with off."
1366+
if len(leds) < device_count
1367+
else "truncating."
1368+
)
1369+
)
1370+
leds = _reconcile_scribble_led_count(leds, device_count)
1371+
buf_in(
1372+
f"Setting scribble: {len(leds)} LEDs, "
1373+
f"effect={effect.name.lower()}, "
1374+
f"speed={speed}, density={density}"
1375+
)
1376+
await bulb.async_set_scribble(
1377+
leds,
1378+
effect=effect,
1379+
direction=direction,
1380+
density=density,
1381+
speed=speed,
1382+
)
1383+
11671384
elif options.preset is not None:
11681385
buf_in(
11691386
f"Setting preset pattern: {PresetPattern.valtostr(options.preset[0])}, Speed={options.preset[1]}%"

0 commit comments

Comments
 (0)