Skip to content

Commit 13e487c

Browse files
committed
feat: expose remaining 0xB6 scribble/effect options in CLI (numeric effect id, per-LED blink, enter_mode, extended variant)
- Gap 1: accept numeric effect id 0-8 in -C scribble <effect>; named effects still work; out-of-range/garbage -> parser.error - Gap 2: per-LED blink suffix syntax <value>[/blinkmode[/blinkspeed]]; per-token blink overrides global blink=/blinkspeed= defaults - Gap 3: enter=true|false spec token controls async_set_scribble enter_mode; default true - Gap 4: --variant 0|1|2 optparse option overrides --colorchange for ExtendedCustomEffect option byte; threaded through processCustomArgs and parseArgs call site - Dispatch: pass raw int effect id (not ScribbleEffect()) so unnamed ids 3/4/6/7 don't raise ValueError; pass enter_mode from parsed dict - 23 new TDD tests covering all four gaps (parser-level); all 287 tests pass
1 parent cf302b1 commit 13e487c

2 files changed

Lines changed: 381 additions & 31 deletions

File tree

flux_led/fluxled.py

Lines changed: 129 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -546,39 +546,102 @@ def _parse_scribble_led_token(
546546
blink_mode: ScribbleBlinkMode,
547547
blink_speed: int,
548548
) -> ScribbleLED:
549-
"""Parse a single LED value token (without run-length prefix) into a ScribbleLED."""
550-
token_lower = token.lower()
549+
"""Parse a single LED value token into a ScribbleLED.
550+
551+
Accepts an optional per-token blink suffix using '/' as separator:
552+
``<value>[/<blinkmode>[/<blinkspeed>]]``
553+
554+
``<value>`` is a color name/hex/r,g,b, a white-level ``w<level>``, or
555+
``off``. ``<blinkmode>`` is ``none|slow|fast``; ``<blinkspeed>`` is
556+
0-100. A per-token blink suffix overrides the caller-supplied global
557+
blink_mode / blink_speed defaults.
558+
"""
559+
# Split off optional /blinkmode[/blinkspeed] suffix.
560+
# '/' cannot appear in color names (hex uses only hex digits; r,g,b uses
561+
# commas; color names are plain words), so splitting on '/' is unambiguous.
562+
parts = token.split("/")
563+
value_part = parts[0]
564+
if len(parts) > 1:
565+
# Per-token blink override present
566+
blink_part = parts[1].lower()
567+
if blink_part not in _SCRIBBLE_BLINK_NAMES:
568+
parser.error(
569+
f"Invalid blink mode '{parts[1]}' in token '{token}'. "
570+
f"Use none, slow, or fast."
571+
)
572+
raise SystemExit(1)
573+
blink_mode = _SCRIBBLE_BLINK_NAMES[blink_part]
574+
if len(parts) > 2:
575+
try:
576+
blink_speed = int(parts[2])
577+
except ValueError:
578+
parser.error(
579+
f"Invalid blink speed '{parts[2]}' in token '{token}'. "
580+
f"Must be an integer."
581+
)
582+
raise SystemExit(1)
583+
if len(parts) > 3:
584+
parser.error(f"Too many '/' separators in token '{token}'.")
585+
raise SystemExit(1)
586+
587+
token_lower = value_part.lower()
551588
if token_lower == "off":
552-
return ScribbleLED(rgb=None, white=None, blink_mode=blink_mode, blink_speed=blink_speed)
589+
return ScribbleLED(
590+
rgb=None, white=None, blink_mode=blink_mode, blink_speed=blink_speed
591+
)
553592
# White-level token: 'w' followed by DIGITS ONLY (e.g. w50). A token like
554593
# 'white' or 'wheat' is a color name, so it falls through to the color parser.
555594
white_match = _WHITE_LEVEL_RE.match(token_lower)
556595
if white_match:
557596
level = int(white_match.group(1))
558-
return ScribbleLED(rgb=None, white=level, blink_mode=blink_mode, blink_speed=blink_speed)
597+
return ScribbleLED(
598+
rgb=None, white=level, blink_mode=blink_mode, blink_speed=blink_speed
599+
)
559600
# Color token
560-
c = utils.color_object_to_tuple(token)
601+
c = utils.color_object_to_tuple(value_part)
561602
if c is None or len(c) < 3:
562603
parser.error(
563-
f"Invalid color token '{token}'. Use a color name, hex value, or r,g,b triple."
604+
f"Invalid color token '{value_part}'. Use a color name, hex value, or r,g,b triple."
564605
)
565606
raise SystemExit(1)
566-
return ScribbleLED(rgb=(c[0], c[1], c[2]), white=None, blink_mode=blink_mode, blink_speed=blink_speed)
607+
return ScribbleLED(
608+
rgb=(c[0], c[1], c[2]),
609+
white=None,
610+
blink_mode=blink_mode,
611+
blink_speed=blink_speed,
612+
)
567613

568614

569615
def _process_scribble_args(
570616
parser: OptionParser,
571617
args: Any,
572618
) -> dict[str, Any] | None:
573-
"""Parse scribble-mode args: ('scribble', <effect>, '<per-led-spec>')."""
619+
"""Parse scribble-mode args: ('scribble', <effect>, '<per-led-spec>').
620+
621+
``<effect>`` may be a named effect (static/flowing/twinkling/stars_wink/
622+
accumulate) OR a raw integer 0-8.
623+
"""
574624
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]
625+
if effect_name in _SCRIBBLE_EFFECT_NAMES:
626+
effect_id: int = _SCRIBBLE_EFFECT_NAMES[effect_name].value
627+
else:
628+
# Try numeric id
629+
try:
630+
effect_id = int(effect_name)
631+
except ValueError:
632+
parser.error(
633+
f"Unknown scribble effect '{effect_name}'. "
634+
f"Valid names: {', '.join(_SCRIBBLE_EFFECT_NAMES)}; "
635+
f"or a number 0-8."
636+
)
637+
return None
638+
if not 0 <= effect_id <= 8:
639+
parser.error(
640+
f"Scribble effect id {effect_id} out of range. "
641+
f"Valid names: {', '.join(_SCRIBBLE_EFFECT_NAMES)}; "
642+
f"or a number 0-8."
643+
)
644+
return None
582645

583646
spec_str = args[2].strip() if len(args) > 2 else ""
584647

@@ -588,6 +651,7 @@ def _process_scribble_args(
588651
direction = ExtendedCustomEffectDirection.LEFT_TO_RIGHT
589652
blink_mode = ScribbleBlinkMode.NONE
590653
blink_speed = 100
654+
enter_mode = True
591655

592656
# Split spec on whitespace; separate LED tokens from key=value tokens
593657
all_tokens = spec_str.split()
@@ -629,7 +693,17 @@ def _process_scribble_args(
629693
try:
630694
blink_speed = int(val)
631695
except ValueError:
632-
parser.error(f"Invalid blinkspeed value '{val}'. Must be an integer.")
696+
parser.error(
697+
f"Invalid blinkspeed value '{val}'. Must be an integer."
698+
)
699+
return None
700+
elif key == "enter":
701+
if val.lower() == "true":
702+
enter_mode = True
703+
elif val.lower() == "false":
704+
enter_mode = False
705+
else:
706+
parser.error(f"Invalid enter value '{val}'. Use true or false.")
633707
return None
634708
else:
635709
parser.error(f"Unknown scribble option '{key}'.")
@@ -655,11 +729,12 @@ def _process_scribble_args(
655729

656730
return {
657731
"mode": "scribble",
658-
"effect": effect.value,
732+
"effect": effect_id,
659733
"leds": leds,
660734
"direction": direction.value,
661735
"density": density,
662736
"speed": speed,
737+
"enter_mode": enter_mode,
663738
}
664739

665740

@@ -687,6 +762,7 @@ def processCustomArgs(
687762
density: int = 50,
688763
direction: str = "l2r",
689764
colorchange: bool = False,
765+
variant: int | None = None,
690766
) -> dict[str, Any] | None:
691767
"""Process custom pattern arguments.
692768
@@ -816,8 +892,14 @@ def processCustomArgs(
816892
parser.error(f"Invalid direction: {direction}. Use l2r or r2l")
817893
return None
818894

819-
# Option value
820-
option_value = 0x01 if colorchange else 0x00
895+
# Option value: --variant takes precedence; fall back to --colorchange.
896+
if variant is not None:
897+
if not 0 <= variant <= 2:
898+
parser.error(f"Invalid --variant {variant}. Must be 0, 1, or 2.")
899+
return None
900+
option_value = variant
901+
else:
902+
option_value = 0x01 if colorchange else 0x00
821903

822904
return {
823905
"mode": "extended",
@@ -982,9 +1064,9 @@ def parseArgs() -> tuple[Values, Any]:
9821064
+ "TYPE: jump, gradual, strobe (standard), or extended pattern names "
9831065
+ "(wave, meteor, breathe, etc. for 0xB6 devices), or 'segments' for static colors, "
9841066
+ "or 'scribble' for per-LED colors (0xB6 devices). "
985-
+ "SPEED is percent (0-100) (for scribble, use the effect name instead). "
1067+
+ "SPEED is percent (0-100) (for scribble, use the effect name or 0-8 id instead). "
9861068
+ "COLORLIST is a space-separated list of color names, hex values, or RGB triples. "
987-
+ "Use --density, --direction, --colorchange for extended pattern options.",
1069+
+ "Use --density, --direction, --colorchange, --variant 0|1|2 for extended pattern options.",
9881070
)
9891071
parser.add_option_group(mode_group)
9901072

@@ -1008,7 +1090,16 @@ def parseArgs() -> tuple[Values, Any]:
10081090
action="store_true",
10091091
dest="colorchange",
10101092
default=False,
1011-
help="Enable color change option for extended effect",
1093+
help="Enable color change option for extended effect (equivalent to --variant 1)",
1094+
)
1095+
other_group.add_option(
1096+
"--variant",
1097+
dest="variant",
1098+
default=None,
1099+
type="int",
1100+
metavar="VARIANT",
1101+
help="Extended effect option variant: 0, 1, or 2 (overrides --colorchange). "
1102+
"Use with -C <extended-pattern> SPEED COLORLIST.",
10121103
)
10131104

10141105
parser.add_option(
@@ -1105,9 +1196,9 @@ def parseArgs() -> tuple[Values, Any]:
11051196
print("\nSegment colors (-C segments, 0xB6 devices only):")
11061197
print(" segments - Set individual segment colors (up to 20)")
11071198
print("\nPer-LED scribble (-C scribble, 0xB6 devices only):")
1108-
print(" scribble <effect> \"<per-led-spec>\" - Set per-LED colors/blink")
1199+
print(' scribble <effect> "<per-led-spec>" - Set per-LED colors/blink')
11091200
print(" Effects: static, flowing, twinkling, stars_wink, accumulate")
1110-
print(" Example: -C scribble static \"10xred 70xoff\"")
1201+
print(' Example: -C scribble static "10xred 70xoff"')
11111202
sys.exit(0)
11121203

11131204
if options.listcolors:
@@ -1148,6 +1239,7 @@ def parseArgs() -> tuple[Values, Any]:
11481239
density=options.density,
11491240
direction=options.direction,
11501241
colorchange=options.colorchange,
1242+
variant=options.variant,
11511243
)
11521244

11531245
if options.color:
@@ -1344,15 +1436,16 @@ def buf_in(str: str) -> None:
13441436
elif mode == "scribble":
13451437
# Per-LED scribble configuration
13461438
if not bulb.supports_scribble:
1347-
buf_in(
1348-
f"{bulb.model} does not support the scribble (per-LED) feature."
1349-
)
1439+
buf_in(f"{bulb.model} does not support the scribble (per-LED) feature.")
13501440
else:
13511441
leds = custom["leds"]
1352-
effect = ScribbleEffect(custom["effect"])
1442+
# Pass the raw int effect id; the API accepts ScribbleEffect|int
1443+
# so unnamed ids (3, 4, 6, 7) work without raising ValueError.
1444+
effect_id_raw: int = custom["effect"]
13531445
direction = ExtendedCustomEffectDirection(custom["direction"])
13541446
density = custom["density"]
13551447
speed = custom["speed"]
1448+
scribble_enter_mode: bool = custom.get("enter_mode", True)
13561449
# Reconcile parsed LED count with the device's configured count:
13571450
# pad the tail with off-LEDs or truncate so the upload is exactly
13581451
# the device's LED count (avoids the API's hard count check).
@@ -1368,17 +1461,23 @@ def buf_in(str: str) -> None:
13681461
)
13691462
)
13701463
leds = _reconcile_scribble_led_count(leds, device_count)
1464+
# Build display name for the effect id
1465+
try:
1466+
effect_display = ScribbleEffect(effect_id_raw).name.lower()
1467+
except ValueError:
1468+
effect_display = str(effect_id_raw)
13711469
buf_in(
13721470
f"Setting scribble: {len(leds)} LEDs, "
1373-
f"effect={effect.name.lower()}, "
1471+
f"effect={effect_display}, "
13741472
f"speed={speed}, density={density}"
13751473
)
13761474
await bulb.async_set_scribble(
13771475
leds,
1378-
effect=effect,
1476+
effect=effect_id_raw,
13791477
direction=direction,
13801478
density=density,
13811479
speed=speed,
1480+
enter_mode=scribble_enter_mode,
13821481
)
13831482

13841483
elif options.preset is not None:

0 commit comments

Comments
 (0)