diff --git a/README.md b/README.md index 5a4d852..ba983fc 100644 --- a/README.md +++ b/README.md @@ -31,14 +31,18 @@ pybot version pybot list templates pybot list components -# Create a project in the robot directory -pybot new robot --template magicbot +# Change to the directory where you want the project +mkdir robot +cd robot + +# Create a project in the current directory +pybot new magicbot # or -pybot new robot --template command +pybot new command # or -pybot new robot --template xrp +pybot new xrp # or -pybot new robot --template sysid +pybot new sysid # Add components to an existing MagicBot project pybot add component gyro @@ -47,6 +51,9 @@ pybot add component logger pybot add component tankdrive ``` +Running `pybot new` without a template creates a MagicBot project in the current +directory. + The MagicBot starter template targets Python 3.14 and RobotPy 2026. Its RobotPy simulation test is the MVP compatibility proxy for roboRIO operation; physical deployment remains unverified until a roboRIO is available. diff --git a/src/pybot_sdk/cli.py b/src/pybot_sdk/cli.py index 19a07ee..7aae9b5 100644 --- a/src/pybot_sdk/cli.py +++ b/src/pybot_sdk/cli.py @@ -156,13 +156,8 @@ def build_parser() -> argparse.ArgumentParser: commands.add_parser("update", help="install the latest pybot-sdk release") new = commands.add_parser("new", help="create a RobotPy project") new.add_argument( - "project_directory", + "template", nargs="?", - default=Path("."), - type=Path, - ) - new.add_argument( - "--template", choices=TEMPLATE_NAMES, default="magicbot", ) @@ -200,8 +195,9 @@ def main(argv: list[str] | None = None) -> int: elif args.command == "update": return update_sdk() elif args.command == "new": + destination = Path.cwd() try: - create_project(args.project_directory, args.template) + create_project(destination, args.template) except (OSError, ValueError) as error: print(f"pybot: {error}", file=sys.stderr) return 2 @@ -212,7 +208,7 @@ def main(argv: list[str] | None = None) -> int: "command": "Command Based Robot", "sysid": "SysId Robot", } - print(f"Created a {labels[args.template]} project at {args.project_directory}") + print(f"Created a {labels[args.template]} project at {destination}") return 0 elif args.command == "add" and args.add_command == "component": try: diff --git a/src/pybot_sdk/components/ledstrip.py b/src/pybot_sdk/components/ledstrip.py index 25a6b93..f1420e2 100644 --- a/src/pybot_sdk/components/ledstrip.py +++ b/src/pybot_sdk/components/ledstrip.py @@ -5,7 +5,7 @@ For a strip connected to a CTRE CANdle:: - from components.ledstrip import LedColor, LedMode, LedStrip + from components.ledstrip import LedStrip from magicbot import MagicRobot class Robot(MagicRobot): @@ -17,12 +17,12 @@ def createObjects(self): self.led_strip_candle_can_id = 1 def teleopPeriodic(self): - self.led_strip.color = LedColor.BLUE - self.led_strip.mode = LedMode.CHASE + self.led_strip.color = "blue" + self.led_strip.mode = "chase" For a strip connected to a roboRIO PWM port:: - from components.ledstrip import LedColor, LedMode, LedStrip + from components.ledstrip import LedStrip from magicbot import MagicRobot class Robot(MagicRobot): @@ -34,124 +34,86 @@ def createObjects(self): self.led_strip_pwm_port = 0 def teleopPeriodic(self): - self.led_strip.color = LedColor.GREEN - self.led_strip.mode = LedMode.BLINK + self.led_strip.color = "green" + self.led_strip.mode = "blink" ``led_count`` is the number of LEDs on the strip. Use ``use_candle`` to choose the CANdle; set ``candle_can_id`` for its CAN ID or ``pwm_port`` for the roboRIO port. A roboRIO addressable LED strip connects to a PWM header, not a -DIO port. Set ``color`` and ``mode`` from your robot code to choose what the -strip displays. Available colors and patterns are listed in ``LedColor`` and -``LedMode``. +DIO port. Set ``color`` and ``mode`` to string names from the dictionaries on +``LedStrip``. Unknown names display black with the strip off. """ import random -from enum import Enum +from collections.abc import Callable import wpilib - -class LedColor(Enum): - """Colors to display. Each value is a red, green, blue tuple from 0 to 255.""" - - RED = (255, 0, 0) - ORANGE = (255, 80, 0) - YELLOW = (255, 200, 0) - GREEN = (0, 255, 0) - CYAN = (0, 255, 255) - BLUE = (0, 0, 255) - PURPLE = (150, 0, 255) - PINK = (255, 20, 120) - WHITE = (255, 255, 255) - - -class LedMode(Enum): - """Patterns to display on the strip.""" - - OFF = 0 # all LEDs dark - ON = 1 # all LEDs solid color - BLINK = 2 # whole strip flashes on and off - SPARKLE = 3 # random LEDs twinkle and fade - CHASE = 4 # a small group of LEDs runs along the strip and wraps around - KNIGHTRIDER = 5 # one bright dot bounces back and forth with a fading tail - - Pixel = tuple[int, int, int] Frame = list[Pixel] -BLACK: Pixel = (0, 0, 0) - - -def scale(pixel: Pixel, brightness: float) -> Pixel: - """Dim a pixel. Brightness is from 0.0 (dark) to 1.0 (full).""" - r, g, b = pixel - return (int(r * brightness), int(g * brightness), int(b * brightness)) - - -class _RoboRioBackend: - """LED strip wired to a roboRIO PWM port using WPILib's AddressableLED.""" - - def __init__(self, port: int, led_count: int): - self.led = wpilib.AddressableLED(port) - self.led.setLength(led_count) - self.buffer = [wpilib.AddressableLED.LEDData(0, 0, 0) for _ in range(led_count)] - self.led.setData(self.buffer) - self.led.start() - - def show(self, frame: Frame) -> None: - for i, (r, g, b) in enumerate(frame): - self.buffer[i].setRGB(r, g, b) - self.led.setData(self.buffer) - - -class _CandleBackend: - """LED strip connected to a CTRE CANdle using Phoenix 6.""" - - ONBOARD_LEDS = 8 # The strip starts after the LEDs built into the CANdle. - - def __init__(self, can_id: int, led_count: int): - from phoenix6.controls import SolidColor - from phoenix6.hardware import CANdle - from phoenix6.signals import RGBWColor - - self._SolidColor = SolidColor - self._RGBWColor = RGBWColor - self.candle = CANdle(can_id) - self.last_frame = [None] * led_count - - def show(self, frame: Frame) -> None: - for i, pixel in enumerate(frame): - if pixel == self.last_frame[i]: - continue - self.last_frame[i] = pixel - index = i + self.ONBOARD_LEDS - r, g, b = pixel - request = self._SolidColor(index, index).with_color( - self._RGBWColor(r, g, b) - ) - self.candle.set_control(request) class LedStrip: """Control an addressable LED strip from MagicBot.""" + # Injected variables led_count: int use_candle: bool = False pwm_port: int = 0 candle_can_id: int = 0 - BLINK_PERIOD = 0.5 - SPARKLE_CHANCE = 0.05 - SPARKLE_FADE = 0.75 - SPARKLE_UPDATE_TIME = 0.04 - CHASE_LENGTH = 4 - CHASE_SPEED = 20 - KNIGHT_TAIL_LENGTH = 6 - KNIGHT_SPEED = 30 - def __init__(self) -> None: - """Start with a white strip turned off.""" - self.color = LedColor.WHITE - self.mode = LedMode.OFF + """Start with a black strip turned off.""" + self.color = "black" + self.mode = "off" + self.COLORS: dict[str, Pixel] = { + "black": (0, 0, 0), + "red": (255, 0, 0), + "orange": (255, 80, 0), + "yellow": (255, 200, 0), + "green": (0, 255, 0), + "cyan": (0, 255, 255), + "blue": (0, 0, 255), + "purple": (150, 0, 255), + "pink": (255, 20, 120), + "white": (255, 255, 255), + } + self.modes: dict[str, Callable[[Pixel, float], Frame]] = { + "off": self._frame_off, + "on": self._frame_on, + "blink": self._frame_blink, + "sparkle": self._frame_sparkle, + "chase": self._frame_chase, + "knightrider": self._frame_knightrider, + } + self.BLINK_PERIOD = 0.5 + self.SPARKLE_CHANCE = 0.05 + self.SPARKLE_FADE = 0.75 + self.SPARKLE_UPDATE_TIME = 0.04 + self.CHASE_LENGTH = 4 + self.CHASE_SPEED = 20 + self.KNIGHT_TAIL_LENGTH = 6 + self.KNIGHT_SPEED = 30 + + @property + def color(self) -> str: + """Return the selected color name.""" + return self._color + + @color.setter + def color(self, color: str) -> None: + """Store color names in lowercase.""" + self._color = color.lower() + + @property + def mode(self) -> str: + """Return the selected mode name.""" + return self._mode + + @mode.setter + def mode(self, mode: str) -> None: + """Store mode names in lowercase.""" + self._mode = mode.lower() def setup(self) -> None: """Create the hardware object after MagicBot has set the configuration.""" @@ -164,40 +126,24 @@ def setup(self) -> None: self._sparkle_levels = [0.0] * self.led_count self._last_sparkle_time = 0.0 - def set_color(self, color: LedColor) -> None: - """Choose the color used by the pattern.""" - self.color = color - - def set_mode(self, mode: LedMode) -> None: - """Choose the pattern shown on the strip.""" - self.mode = mode - def execute(self) -> None: """Build and send the next frame of LEDs.""" # Using elapsed time keeps animation speed steady if loop timing varies. now = wpilib.Timer.getFPGATimestamp() - base = self.color.value - - if self.mode == LedMode.ON: - frame = self._frame_on(base) - elif self.mode == LedMode.BLINK: - frame = self._frame_blink(base, now) - elif self.mode == LedMode.SPARKLE: - frame = self._frame_sparkle(base, now) - elif self.mode == LedMode.CHASE: - frame = self._frame_chase(base, now) - elif self.mode == LedMode.KNIGHTRIDER: - frame = self._frame_knightrider(base, now) - else: - frame = self._frame_off() - + base = self.COLORS.get(self.color, self.COLORS["black"]) + frame = self.modes.get(self.mode, self._frame_off)(base, now) self._hardware.show(frame) - def _frame_off(self) -> Frame: + def _frame_off(self, base: Pixel, now: float) -> Frame: """Every LED dark.""" - return [BLACK] * self.led_count + return [self.COLORS["black"]] * self.led_count + + def _scale(self, pixel: Pixel, brightness: float) -> Pixel: + """Dim a pixel. Brightness is from 0.0 (dark) to 1.0 (full).""" + r, g, b = pixel + return (int(r * brightness), int(g * brightness), int(b * brightness)) - def _frame_on(self, base: Pixel) -> Frame: + def _frame_on(self, base: Pixel, now: float) -> Frame: """Every LED the same solid color.""" return [base] * self.led_count @@ -206,8 +152,8 @@ def _frame_blink(self, base: Pixel, now: float) -> Frame: # The remainder repeats the time from zero to BLINK_PERIOD. time_in_cycle = now % self.BLINK_PERIOD if time_in_cycle < self.BLINK_PERIOD / 2: - return self._frame_on(base) - return self._frame_off() + return self._frame_on(base, now) + return self._frame_off(base, now) def _frame_sparkle(self, base: Pixel, now: float) -> Frame: """Random LEDs flash to full brightness, then fade out.""" @@ -219,11 +165,11 @@ def _frame_sparkle(self, base: Pixel, now: float) -> Frame: if random.random() < self.SPARKLE_CHANCE: self._sparkle_levels[i] = 1.0 - return [scale(base, level) for level in self._sparkle_levels] + return [self._scale(base, level) for level in self._sparkle_levels] def _frame_chase(self, base: Pixel, now: float) -> Frame: """Move a group of LEDs down the strip and wrap it to the start.""" - frame = self._frame_off() + frame = self._frame_off(base, now) # Modulo sends the animation back to LED 0 after it reaches the end. start = int(now * self.CHASE_SPEED) % self.led_count @@ -233,10 +179,10 @@ def _frame_chase(self, base: Pixel, now: float) -> Frame: def _frame_knightrider(self, base: Pixel, now: float) -> Frame: """Move a bright dot back and forth with a fading tail.""" - frame = self._frame_off() + frame = self._frame_off(base, now) last_led = self.led_count - 1 if last_led < 1: - return self._frame_on(base) + return self._frame_on(base, now) # A trip goes from the first LED to the last, then back again. trip_length = 2 * last_led @@ -255,5 +201,49 @@ def _frame_knightrider(self, base: Pixel, now: float) -> Frame: index = head - direction * i if 0 <= index <= last_led: brightness = 1.0 - i / self.KNIGHT_TAIL_LENGTH - frame[index] = scale(base, brightness) + frame[index] = self._scale(base, brightness) return frame + + +class _RoboRioBackend: + """LED strip wired to a roboRIO PWM port using WPILib's AddressableLED.""" + + def __init__(self, port: int, led_count: int): + self.led = wpilib.AddressableLED(port) + self.led.setLength(led_count) + self.buffer = [wpilib.AddressableLED.LEDData(0, 0, 0) for _ in range(led_count)] + self.led.setData(self.buffer) + self.led.start() + + def show(self, frame: Frame) -> None: + for i, (r, g, b) in enumerate(frame): + self.buffer[i].setRGB(r, g, b) + self.led.setData(self.buffer) + + +class _CandleBackend: + """LED strip connected to a CTRE CANdle using Phoenix 6.""" + + ONBOARD_LEDS = 8 # The strip starts after the LEDs built into the CANdle. + + def __init__(self, can_id: int, led_count: int): + from phoenix6.controls import SolidColor + from phoenix6.hardware import CANdle + from phoenix6.signals import RGBWColor + + self._SolidColor = SolidColor + self._RGBWColor = RGBWColor + self.candle = CANdle(can_id) + self.last_frame = [None] * led_count + + def show(self, frame: Frame) -> None: + for i, pixel in enumerate(frame): + if pixel == self.last_frame[i]: + continue + self.last_frame[i] = pixel + index = i + self.ONBOARD_LEDS + r, g, b = pixel + request = self._SolidColor(index, index).with_color( + self._RGBWColor(r, g, b) + ) + self.candle.set_control(request) diff --git a/tests/test_cli.py b/tests/test_cli.py index bf6ebec..441e736 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -131,11 +131,12 @@ def test_list_templates_prints_supported_templates(capsys) -> None: ] -def test_new_creates_runnable_magicbot_project(tmp_path: Path) -> None: +def test_new_creates_runnable_magicbot_project(tmp_path: Path, monkeypatch) -> None: """Generate all starter assets and exercise map validation and generation.""" - destination = tmp_path / "robot_project" + monkeypatch.chdir(tmp_path) - assert main(["new", str(destination), "--template", "magicbot"]) == 0 + assert main(["new", "magicbot"]) == 0 + destination = tmp_path assert (destination / "robot.py").is_file() assert (destination / "constants.py").is_file() assert (destination / "autonomous" / "__init__.py").is_file() @@ -143,29 +144,35 @@ def test_new_creates_runnable_magicbot_project(tmp_path: Path) -> None: assert (destination / ".github" / "workflows" / "robotpy.yml").is_file() -def test_new_supports_paths_with_spaces(tmp_path: Path) -> None: - """Accept destination paths containing spaces.""" - destination = tmp_path / "new robot" +def test_new_defaults_to_magicbot_in_current_directory( + tmp_path: Path, monkeypatch +) -> None: + """Generate the default MagicBot template in the current directory.""" + monkeypatch.chdir(tmp_path) - assert main(["new", str(destination)]) == 0 - assert (destination / "robot.py").is_file() + assert main(["new"]) == 0 + assert (tmp_path / "robot.py").is_file() -def test_new_creates_xrp_project(tmp_path: Path) -> None: +def test_new_creates_xrp_project(tmp_path: Path, monkeypatch) -> None: """Generate the XRP starter assets.""" - destination = tmp_path / "xrp_robot" + monkeypatch.chdir(tmp_path) - assert main(["new", str(destination), "--template", "xrp"]) == 0 + assert main(["new", "xrp"]) == 0 + destination = tmp_path assert (destination / "robot.py").is_file() assert (destination / "components" / "xrptankdrive.py").is_file() assert (destination / "autonomous" / "do_nothing.py").is_file() -def test_new_creates_single_file_sysid_project(tmp_path: Path, capsys) -> None: +def test_new_creates_single_file_sysid_project( + tmp_path: Path, monkeypatch, capsys +) -> None: """Generate the single-file TimedRobot SysId template.""" - destination = tmp_path / "sysid_robot" + monkeypatch.chdir(tmp_path) - assert main(["new", str(destination), "--template", "sysid"]) == 0 + assert main(["new", "sysid"]) == 0 + destination = tmp_path assert {path.name for path in destination.iterdir()} == { "README.md", "requirements.txt", diff --git a/tests/test_ledstrip.py b/tests/test_ledstrip.py new file mode 100644 index 0000000..b827e84 --- /dev/null +++ b/tests/test_ledstrip.py @@ -0,0 +1,68 @@ +"""Tests for the packaged LED strip component.""" + +import sys +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +from types import ModuleType, SimpleNamespace + + +def _load_ledstrip_module(monkeypatch): + """Load the component against a focused WPILib fake.""" + wpilib = ModuleType("wpilib") + wpilib.Timer = SimpleNamespace(getFPGATimestamp=lambda: 0.0) + monkeypatch.setitem(sys.modules, "wpilib", wpilib) + + component_path = ( + Path(__file__).resolve().parents[1] + / "src" + / "pybot_sdk" + / "components" + / "ledstrip.py" + ) + spec = spec_from_file_location("ledstrip_component", component_path) + assert spec is not None + assert spec.loader is not None + module = module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _execute(strip) -> list[tuple[int, int, int]]: + """Capture and return the frame sent to the LED hardware.""" + frames = [] + strip.led_count = 3 + strip._hardware = SimpleNamespace(show=frames.append) + strip.execute() + return frames[0] + + +def test_ledstrip_uses_string_color_and_mode(monkeypatch) -> None: + """Uppercase color and mode names are normalized before use.""" + module = _load_ledstrip_module(monkeypatch) + strip = module.LedStrip() + strip.color = "BLUE" + strip.mode = "ON" + + assert strip.color == "blue" + assert strip.mode == "on" + assert _execute(strip) == [(0, 0, 255)] * 3 + + +def test_ledstrip_uses_black_for_unsupported_color(monkeypatch) -> None: + """An unknown color falls back to black.""" + module = _load_ledstrip_module(monkeypatch) + strip = module.LedStrip() + strip.color = "not-a-color" + strip.mode = "on" + + assert _execute(strip) == [strip.COLORS["black"]] * 3 + + +def test_ledstrip_uses_off_for_unsupported_mode(monkeypatch) -> None: + """An unknown mode falls back to off.""" + module = _load_ledstrip_module(monkeypatch) + strip = module.LedStrip() + strip.color = "blue" + strip.mode = "not-a-mode" + + assert _execute(strip) == [strip.COLORS["black"]] * 3