|
43 | 43 | import asyncio |
44 | 44 | import datetime |
45 | 45 | import logging |
| 46 | +import re |
46 | 47 | import sys |
47 | 48 | from optparse import OptionGroup, OptionParser, Values |
48 | 49 | from typing import Any, cast |
|
57 | 58 | TIMER_ACTION_ON, |
58 | 59 | ExtendedCustomEffectDirection, |
59 | 60 | ExtendedCustomEffectPattern, |
| 61 | + ScribbleBlinkMode, |
| 62 | + ScribbleEffect, |
| 63 | + ScribbleLED, |
60 | 64 | ) |
61 | 65 | from .pattern import PresetPattern |
62 | 66 | from .protocol import ProtocolLEDENETExtendedCustom |
@@ -125,6 +129,9 @@ def showUsageExamples() -> None: |
125 | 129 | Set custom segment colors (0xB6 devices) - set each segment individually: |
126 | 130 | %prog% 192.168.1.100 -C segments 0 "red green blue off off red green blue" |
127 | 131 |
|
| 132 | +Set per-LED scribble colors (0xB6 devices) - specify each LED individually: |
| 133 | + %prog% 192.168.1.100 -C scribble static "10xred 70xoff" |
| 134 | +
|
128 | 135 | Sync all bulb's clocks with this computer's: |
129 | 136 | %prog% -sS --setclock |
130 | 137 |
|
@@ -515,6 +522,165 @@ def processSetTimerArgsExtended( |
515 | 522 | raise SystemExit(1) # For type checker |
516 | 523 |
|
517 | 524 |
|
| 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 | + |
518 | 684 | def processCustomArgs( |
519 | 685 | parser: OptionParser, |
520 | 686 | args: Any, |
@@ -542,6 +708,12 @@ def processCustomArgs( |
542 | 708 | extended_pattern_names[p.name.lower()] = p.value |
543 | 709 |
|
544 | 710 | 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 | + |
545 | 717 | speed = int(args[1]) |
546 | 718 |
|
547 | 719 | # Check if this is a standard pattern |
@@ -808,8 +980,9 @@ def parseArgs() -> tuple[Values, Any]: |
808 | 980 | nargs=3, |
809 | 981 | help="Set custom pattern mode. " |
810 | 982 | + "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). " |
813 | 986 | + "COLORLIST is a space-separated list of color names, hex values, or RGB triples. " |
814 | 987 | + "Use --density, --direction, --colorchange for extended pattern options.", |
815 | 988 | ) |
@@ -931,6 +1104,10 @@ def parseArgs() -> tuple[Values, Any]: |
931 | 1104 | print(f" {p.name.lower().replace('_', ' '):<20} (ID: {p.value})") |
932 | 1105 | print("\nSegment colors (-C segments, 0xB6 devices only):") |
933 | 1106 | 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\"") |
934 | 1111 | sys.exit(0) |
935 | 1112 |
|
936 | 1113 | if options.listcolors: |
@@ -1164,6 +1341,46 @@ def buf_in(str: str) -> None: |
1164 | 1341 | buf_in(f"Setting custom segment colors: {len(custom['colors'])} segments") |
1165 | 1342 | await bulb.async_set_custom_segment_colors(custom["colors"]) |
1166 | 1343 |
|
| 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 | + |
1167 | 1384 | elif options.preset is not None: |
1168 | 1385 | buf_in( |
1169 | 1386 | f"Setting preset pattern: {PresetPattern.valtostr(options.preset[0])}, Speed={options.preset[1]}%" |
|
0 commit comments