diff --git a/examples/robot/ExpansionHubSample/defaultautomode.py b/examples/robot/ExpansionHubSample/defaultautomode.py new file mode 100644 index 00000000..5bcc1a84 --- /dev/null +++ b/examples/robot/ExpansionHubSample/defaultautomode.py @@ -0,0 +1,31 @@ +# +# Copyright (c) FIRST and other WPILib contributors. +# Open Source Software; you can modify and/or share it under the terms of +# the WPILib BSD license file in the root directory of this project. +# + +from wpilib import PeriodicOpMode, Timer +from wpilib.opmoderobot import autonomous + + +@autonomous +class DefaultAutoMode(PeriodicOpMode): + def __init__(self, robot): + super().__init__() + self.robot = robot + self.timer = Timer() + + def start(self): + self.timer.reset() + self.timer.start() + + def periodic(self): + if self.timer.get() < 2.0: + self.robot.motor0.setThrottle(0.5) + self.robot.motor1.setThrottle(0.5) + elif self.timer.get() < 4.0: + self.robot.motor0.setThrottle(0.9) + self.robot.motor1.setThrottle(0.9) + else: + self.robot.motor0.setThrottle(0.0) + self.robot.motor1.setThrottle(0.0) diff --git a/examples/robot/ExpansionHubSample/defaulttelemode.py b/examples/robot/ExpansionHubSample/defaulttelemode.py new file mode 100644 index 00000000..94456c35 --- /dev/null +++ b/examples/robot/ExpansionHubSample/defaulttelemode.py @@ -0,0 +1,25 @@ +# +# Copyright (c) FIRST and other WPILib contributors. +# Open Source Software; you can modify and/or share it under the terms of +# the WPILib BSD license file in the root directory of this project. +# + +from wpilib import DefaultUserControls, PeriodicOpMode +from wpilib.opmoderobot import teleop + + +@teleop +class DefaultTeleMode(PeriodicOpMode): + def __init__(self, robot, user_controls: DefaultUserControls): + super().__init__() + self.robot = robot + self.user_controls = user_controls + + def periodic(self): + gamepad = self.user_controls.getGamepad(0) + self.robot.motor0.setThrottle(-gamepad.getLeftY()) + self.robot.motor1.setThrottle(-gamepad.getRightY()) + self.robot.motor2.setThrottle(-gamepad.getLeftX()) + self.robot.motor3.setThrottle(-gamepad.getRightX()) + self.robot.servo0.setPosition(gamepad.getLeftTriggerAxis()) + self.robot.servo1.setPosition(gamepad.getRightTriggerAxis()) diff --git a/examples/robot/ExpansionHubSample/robot.py b/examples/robot/ExpansionHubSample/robot.py new file mode 100644 index 00000000..24bc6074 --- /dev/null +++ b/examples/robot/ExpansionHubSample/robot.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +# +# Copyright (c) FIRST and other WPILib contributors. +# Open Source Software; you can modify and/or share it under the terms of +# the WPILib BSD license file in the root directory of this project. +# + +from wpilib import DefaultUserControls, ExpansionHubMotor, ExpansionHubServo +from wpilib.opmoderobot import OpModeRobot + + +class Robot(OpModeRobot, user_controls=DefaultUserControls): + def __init__(self): + super().__init__() + self.motor0 = ExpansionHubMotor(0, 0) + self.motor1 = ExpansionHubMotor(0, 1) + self.motor2 = ExpansionHubMotor(0, 2) + self.motor3 = ExpansionHubMotor(0, 3) + self.servo0 = ExpansionHubServo(0, 0) + self.servo1 = ExpansionHubServo(0, 1) diff --git a/examples/robot/examples.toml b/examples/robot/examples.toml index 29c5587c..75408c54 100644 --- a/examples/robot/examples.toml +++ b/examples/robot/examples.toml @@ -19,6 +19,7 @@ base = [ "ElevatorTrapezoidProfile", "Encoder", "EventLoop", + "ExpansionHubSample", "FlywheelBangBangController", "GettingStarted", "Gyro", diff --git a/subprojects/robotpy-wpilib/tests/test_opmode_robot.py b/subprojects/robotpy-wpilib/tests/test_opmode_robot.py index b21cfe7d..a6b84d37 100644 --- a/subprojects/robotpy-wpilib/tests/test_opmode_robot.py +++ b/subprojects/robotpy-wpilib/tests/test_opmode_robot.py @@ -1,9 +1,10 @@ +import importlib import pytest import threading from wpilib import simulation as wsim from wpimath.units import seconds -from wpilib.opmoderobot import OpModeRobot -from wpilib import OpMode, RobotState +from wpilib.opmoderobot import OpModeRobot, autonomous, teleop, utility +from wpilib import OpMode, RobotState, autonomous as top_level_autonomous from hal._wpiHal import RobotMode from wpiutil import Color @@ -62,6 +63,53 @@ def sim_timing_setup(): RobotState.clearOpModes() +def test_opmode_decorators_attach_metadata(): + @autonomous(group="Drive", description="Auto desc") + class AutoMode(OpMode): + pass + + metadata = AutoMode.__wpilib_opmode_metadata__ + assert metadata.mode == RobotMode.AUTONOMOUS + assert metadata.name == "AutoMode" + assert metadata.group == "Drive" + assert metadata.description == "Auto desc" + + +def test_opmode_decorator_rejects_multiple_modes(): + with pytest.raises(ValueError, match="multiple opmode decorators"): + + @teleop + @autonomous + class BadMode(OpMode): + pass + + +def test_opmode_decorator_rejects_non_opmode_class_eagerly(): + with pytest.raises(TypeError, match="must inherit from OpMode"): + + @autonomous + class NotAnOpMode: + pass + + +def test_opmode_decorator_preserves_explicit_metadata(): + @utility( + name="Arm Test", + group="Mechanisms", + description="tests arm", + textColor=Color.WHITE, + backgroundColor=Color.BLACK, + ) + class UtilityMode(OpMode): + pass + + metadata = UtilityMode.__wpilib_opmode_metadata__ + assert metadata.name == "Arm Test" + assert metadata.textColor == Color.WHITE + assert metadata.backgroundColor == Color.BLACK + assert top_level_autonomous is autonomous + + def test_add_op_mode(): class MyMockRobot(MockRobot): def __init__(self): @@ -187,3 +235,336 @@ def test_robot_periodic(periodic_robot_test_fixture): # Additional time steps should continue calling RobotPeriodic wsim.stepTiming(kPeriod) assert robot.periodic_count == 2 + + +def test_opmode_robot_auto_discovers_decorated_modules(tmp_path, monkeypatch): + pkg = tmp_path / "samplebot" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "robot.py").write_text("""\ +from wpilib.opmoderobot import OpModeRobot +class Robot(OpModeRobot): + def __init__(self): + super().__init__() +""") + (pkg / "default_auto_mode.py").write_text("""\ +from wpilib import PeriodicOpMode +from wpilib.opmoderobot import autonomous +@autonomous +class DefaultAutoMode(PeriodicOpMode): + pass +""") + (pkg / "default_tele_mode.py").write_text("""\ +from wpilib import PeriodicOpMode +from wpilib.opmoderobot import teleop +@teleop +class DefaultTeleMode(PeriodicOpMode): + pass +""") + + monkeypatch.syspath_prepend(str(tmp_path)) + module = importlib.import_module("samplebot.robot") + robot = module.Robot() + + options = wsim.DriverStationSim.getOpModeOptions() + assert {opt.name for opt in options} == {"DefaultAutoMode", "DefaultTeleMode"} + + +def test_opmode_robot_skips_non_candidate_files(tmp_path, monkeypatch): + pkg = tmp_path / "safeimportbot" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "robot.py").write_text("""\ +from wpilib.opmoderobot import OpModeRobot +class Robot(OpModeRobot): + def __init__(self): + super().__init__() +""") + (pkg / "default_auto_mode.py").write_text("""\ +from wpilib import PeriodicOpMode +from wpilib.opmoderobot import autonomous +@autonomous +class DefaultAutoMode(PeriodicOpMode): + pass +""") + (pkg / "helper.py").write_text("raise RuntimeError('should not import')\n") + nested = pkg / "support" + nested.mkdir() + (nested / "bad_auto.py").write_text("""\ +from wpilib import PeriodicOpMode +from wpilib.opmoderobot import autonomous +raise RuntimeError('should not import nested module outside opmodes') +@autonomous +class BadAuto(PeriodicOpMode): + pass +""") + + monkeypatch.syspath_prepend(str(tmp_path)) + module = importlib.import_module("safeimportbot.robot") + module.Robot() + + options = wsim.DriverStationSim.getOpModeOptions() + assert {opt.name for opt in options} == {"DefaultAutoMode"} + + +def test_opmode_robot_discovers_opmodes_package_recursively(tmp_path, monkeypatch): + pkg = tmp_path / "nestedopmodesbot" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "robot.py").write_text("""\ +from wpilib.opmoderobot import OpModeRobot +class Robot(OpModeRobot): + def __init__(self): + super().__init__() +""") + opmodes = pkg / "opmodes" + opmodes.mkdir() + (opmodes / "__init__.py").write_text("") + (opmodes / "drive.py").write_text("""\ +from wpilib import PeriodicOpMode +from wpilib.opmoderobot import teleop +@teleop +class DriveMode(PeriodicOpMode): + pass +""") + nested = opmodes / "test" + nested.mkdir() + (nested / "__init__.py").write_text("") + (nested / "servo.py").write_text("""\ +from wpilib import PeriodicOpMode +from wpilib.opmoderobot import utility +@utility +class ServoMode(PeriodicOpMode): + pass +""") + + monkeypatch.syspath_prepend(str(tmp_path)) + module = importlib.import_module("nestedopmodesbot.robot") + module.Robot() + + options = wsim.DriverStationSim.getOpModeOptions() + assert {opt.name for opt in options} == {"DriveMode", "ServoMode"} + + +def test_opmode_robot_fails_on_syntax_error_in_scan_tree(tmp_path, monkeypatch): + pkg = tmp_path / "brokenbot" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "robot.py").write_text("""\ +from wpilib.opmoderobot import OpModeRobot +class Robot(OpModeRobot): + def __init__(self): + super().__init__() +""") + (pkg / "broken.py").write_text("def nope(:\n") + + monkeypatch.syspath_prepend(str(tmp_path)) + module = importlib.import_module("brokenbot.robot") + + with pytest.raises(RuntimeError, match="broken.py"): + module.Robot() + + +def test_opmode_robot_fails_on_candidate_import_error(tmp_path, monkeypatch): + pkg = tmp_path / "importbrokenbot" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "robot.py").write_text("""\ +from wpilib.opmoderobot import OpModeRobot +class Robot(OpModeRobot): + def __init__(self): + super().__init__() +""") + (pkg / "bad_auto.py").write_text("""\ +from wpilib import PeriodicOpMode +from wpilib.opmoderobot import autonomous +raise RuntimeError('boom') +@autonomous +class BadAuto(PeriodicOpMode): + pass +""") + + monkeypatch.syspath_prepend(str(tmp_path)) + module = importlib.import_module("importbrokenbot.robot") + + with pytest.raises(RuntimeError, match="bad_auto.py"): + module.Robot() + + +def test_opmode_robot_rejects_duplicate_names_within_mode(tmp_path, monkeypatch): + pkg = tmp_path / "duplicatebot" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "robot.py").write_text("""\ +from wpilib.opmoderobot import OpModeRobot +class Robot(OpModeRobot): + def __init__(self): + super().__init__() +""") + (pkg / "drive_modes.py").write_text("""\ +from wpilib import PeriodicOpMode +from wpilib.opmoderobot import teleop +@teleop(name='Drive') +class DriveModeA(PeriodicOpMode): + pass +@teleop(name='Drive') +class DriveModeB(PeriodicOpMode): + pass +""") + + monkeypatch.syspath_prepend(str(tmp_path)) + module = importlib.import_module("duplicatebot.robot") + + with pytest.raises(ValueError, match="duplicate"): + module.Robot() + + +def test_opmode_robot_rejects_decorated_non_opmode_class(tmp_path, monkeypatch): + pkg = tmp_path / "typecheckbot" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "robot.py").write_text("""\ +from wpilib.opmoderobot import OpModeRobot +class Robot(OpModeRobot): + def __init__(self): + super().__init__() +""") + (pkg / "not_an_opmode.py").write_text("""\ +from wpilib.opmoderobot import autonomous +@autonomous +class NotAnOpMode: + pass +""") + + monkeypatch.syspath_prepend(str(tmp_path)) + module = importlib.import_module("typecheckbot.robot") + + with pytest.raises(RuntimeError, match="OpMode"): + module.Robot() + + +def test_expansion_hub_style_project_discovers_split_opmodes(tmp_path, monkeypatch): + pkg = tmp_path / "expansionhubsample" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "robot.py").write_text("""\ +from wpilib.opmoderobot import OpModeRobot +class Robot(OpModeRobot): + motor0 = None + def __init__(self): + super().__init__() +""") + (pkg / "defaultautomode.py").write_text("""\ +from wpilib import PeriodicOpMode +from wpilib.opmoderobot import autonomous +@autonomous +class DefaultAutoMode(PeriodicOpMode): + def __init__(self, robot): + self.robot = robot +""") + (pkg / "defaulttelemode.py").write_text("""\ +from wpilib import PeriodicOpMode +from wpilib.opmoderobot import teleop +@teleop +class DefaultTeleMode(PeriodicOpMode): + def __init__(self, robot): + self.robot = robot +""") + + monkeypatch.syspath_prepend(str(tmp_path)) + module = importlib.import_module("expansionhubsample.robot") + module.Robot() + + options = wsim.DriverStationSim.getOpModeOptions() + assert {opt.name for opt in options} == {"DefaultAutoMode", "DefaultTeleMode"} + + +def test_add_op_mode_injects_robot_and_user_controls_by_parameter_name( + tmp_path, monkeypatch +): + pkg = tmp_path / "controlsbot" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "robot.py").write_text("""\ +from wpilib import OpMode +from wpilib.opmoderobot import OpModeRobot + +class ExampleControls: + def __init__(self): + self.token = object() + +class ExampleMode(OpMode): + def __init__(self, user_controls, robot): + super().__init__() + self.user_controls = user_controls + self.robot = robot + +class Robot(OpModeRobot, user_controls=ExampleControls): + def __init__(self): + self.factories = [] + super().__init__() + self.addOpMode(ExampleMode, 0, 'ExampleMode') + + def addOpModeFactory(self, *args): + self.factories.append(args) + + def publishOpModes(self): + pass +""") + + monkeypatch.syspath_prepend(str(tmp_path)) + module = importlib.import_module("controlsbot.robot") + robot = module.Robot() + + opmode = robot.factories[0][0]() + assert opmode.robot is robot + assert opmode.user_controls is robot._user_controls + + +def test_add_op_mode_rejects_unsupported_constructor_parameter_name( + tmp_path, monkeypatch +): + pkg = tmp_path / "badcontrolsbot" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "robot.py").write_text("""\ +from wpilib import OpMode +from wpilib.opmoderobot import OpModeRobot + +class ExampleControls: + pass + +class BadMode(OpMode): + def __init__(self, controls): + super().__init__() + +class Robot(OpModeRobot, user_controls=ExampleControls): + def __init__(self): + super().__init__() +""") + + monkeypatch.syspath_prepend(str(tmp_path)) + module = importlib.import_module("badcontrolsbot.robot") + robot = module.Robot() + + with pytest.raises( + TypeError, match="unsupported constructor parameter name 'controls'" + ): + robot.addOpMode(module.BadMode, RobotMode.TELEOPERATED, "BadMode") + + +def test_robot_subclass_keyword_rejects_non_type_user_controls(tmp_path, monkeypatch): + pkg = tmp_path / "invalidcontrolsbot" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "robot.py").write_text("""\ +from wpilib.opmoderobot import OpModeRobot + +class Robot(OpModeRobot, user_controls=42): + pass +""") + + monkeypatch.syspath_prepend(str(tmp_path)) + + with pytest.raises(TypeError, match="user_controls must be a type"): + importlib.import_module("invalidcontrolsbot.robot") diff --git a/subprojects/robotpy-wpilib/wpilib/__init__.py b/subprojects/robotpy-wpilib/wpilib/__init__.py index da8a19ce..a0706fa2 100644 --- a/subprojects/robotpy-wpilib/wpilib/__init__.py +++ b/subprojects/robotpy-wpilib/wpilib/__init__.py @@ -234,9 +234,17 @@ del _init__wpilib -from .opmoderobot import OpModeRobot +from .opmoderobot import OpModeRobot, autonomous, teleop, utility +from .usercontrols import DefaultUserControls, UserControls -__all__ += ["OpModeRobot"] +__all__ += [ + "OpModeRobot", + "autonomous", + "teleop", + "utility", + "DefaultUserControls", + "UserControls", +] from .cameraserver import CameraServer from .deployinfo import getDeployData diff --git a/subprojects/robotpy-wpilib/wpilib/opmoderobot.py b/subprojects/robotpy-wpilib/wpilib/opmoderobot.py index f81a13a4..7973d080 100644 --- a/subprojects/robotpy-wpilib/wpilib/opmoderobot.py +++ b/subprojects/robotpy-wpilib/wpilib/opmoderobot.py @@ -1,12 +1,351 @@ +import ast +import importlib +import inspect +from dataclasses import dataclass from hal import RobotMode -from typing import Optional +from pathlib import Path +from typing import Any, Callable, Optional, TypeVar, overload from wpiutil import Color -__all__ = ["OpModeRobot"] +__all__ = ["OpModeRobot", "autonomous", "teleop", "utility"] from ._wpilib import OpModeRobotBase, OpMode +@dataclass(frozen=True) +class _OpModeMetadata: + mode: RobotMode + name: str | None + group: str | None + description: str | None + textColor: Color | None + backgroundColor: Color | None + + +_OpModeType = TypeVar("_OpModeType", bound=type[OpMode]) +_DECORATOR_NAMES = {"autonomous", "teleop", "utility"} +_SUPPORTED_CTOR_PARAM_NAMES = frozenset({"robot", "user_controls"}) + + +def _attach_opmode_metadata( + cls: _OpModeType, + *, + mode: RobotMode, + name: str | None = None, + group: str | None = None, + description: str | None = None, + textColor: Color | None = None, + backgroundColor: Color | None = None, +) -> _OpModeType: + if not issubclass(cls, OpMode): + raise TypeError( + f"Decorated class {cls.__module__}.{cls.__qualname__} must inherit from OpMode" + ) + + if hasattr(cls, "__wpilib_opmode_metadata__"): + raise ValueError("multiple opmode decorators are not allowed") + + cls.__wpilib_opmode_metadata__ = _OpModeMetadata( + mode=mode, + name=name or cls.__name__, + group=group, + description=description, + textColor=textColor, + backgroundColor=backgroundColor, + ) + return cls + + +def _make_opmode_decorator(mode: RobotMode, _cls=None, **kwargs): + def decorator(cls: _OpModeType) -> _OpModeType: + return _attach_opmode_metadata(cls, mode=mode, **kwargs) + + if _cls is None: + return decorator + return decorator(_cls) + + +@overload +def autonomous(cls: _OpModeType, /) -> _OpModeType: ... + + +@overload +def autonomous( + cls: None = None, + /, + *, + name: str | None = None, + group: str | None = None, + description: str | None = None, + textColor: Color | None = None, + backgroundColor: Color | None = None, +) -> Callable[[_OpModeType], _OpModeType]: ... + + +def autonomous(_cls=None, **kwargs): + """ + Decorator for automatic registration of autonomous opmode classes. + + May be used with or without arguments:: + + @autonomous + class DefaultAutoMode(PeriodicOpMode): + ... + + @autonomous(name="Two Ball", group="Autos", description="Example") + class TwoBallAuto(PeriodicOpMode): + ... + + ``name`` is shown as the selection name in the Driver Station and must be + unique across autonomous opmodes in the project. If omitted, the class name + is used. ``group`` controls Driver Station grouping and defaults to + ungrouped. ``description`` is optional. ``textColor`` and + ``backgroundColor`` are optional display colors; both must be provided to + have an effect. + """ + return _make_opmode_decorator(RobotMode.AUTONOMOUS, _cls, **kwargs) + + +@overload +def teleop(cls: _OpModeType, /) -> _OpModeType: ... + + +@overload +def teleop( + cls: None = None, + /, + *, + name: str | None = None, + group: str | None = None, + description: str | None = None, + textColor: Color | None = None, + backgroundColor: Color | None = None, +) -> Callable[[_OpModeType], _OpModeType]: ... + + +def teleop(_cls=None, **kwargs): + """ + Decorator for automatic registration of teleoperated opmode classes. + + May be used with or without arguments:: + + @teleop + class DefaultTeleMode(PeriodicOpMode): + ... + + @teleop(name="Drive", group="Main") + class DriveMode(PeriodicOpMode): + ... + + ``name`` is shown as the selection name in the Driver Station and must be + unique across teleoperated opmodes in the project. If omitted, the class + name is used. ``group`` controls Driver Station grouping and defaults to + ungrouped. ``description`` is optional. ``textColor`` and + ``backgroundColor`` are optional display colors; both must be provided to + have an effect. + """ + return _make_opmode_decorator(RobotMode.TELEOPERATED, _cls, **kwargs) + + +@overload +def utility(cls: _OpModeType, /) -> _OpModeType: ... + + +@overload +def utility( + cls: None = None, + /, + *, + name: str | None = None, + group: str | None = None, + description: str | None = None, + textColor: Color | None = None, + backgroundColor: Color | None = None, +) -> Callable[[_OpModeType], _OpModeType]: ... + + +def utility(_cls=None, **kwargs): + """ + Decorator for automatic registration of utility opmode classes. + + May be used with or without arguments:: + + @utility + class ServoTest(PeriodicOpMode): + ... + + @utility(name="Servo Test", group="Mechanisms") + class ServoTest(PeriodicOpMode): + ... + + ``name`` is shown as the selection name in the Driver Station and must be + unique across utility opmodes in the project. If omitted, the class name is + used. ``group`` controls Driver Station grouping and defaults to ungrouped. + ``description`` is optional. ``textColor`` and ``backgroundColor`` are + optional display colors; both must be provided to have an effect. + """ + return _make_opmode_decorator(RobotMode.UTILITY, _cls, **kwargs) + + +def _decorator_name(node: ast.expr) -> str | None: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Call): + return _decorator_name(node.func) + if isinstance(node, ast.Attribute): + return node.attr + return None + + +def _contains_opmode_decorator(path: Path) -> bool: + try: + source = path.read_text() + tree = ast.parse(source, filename=str(path)) + except Exception as exc: + raise RuntimeError( + f"Failed to parse opmode scan file {path.resolve()}: {exc}" + ) from exc + + for stmt in tree.body: + if not isinstance(stmt, ast.ClassDef): + continue + for decorator in stmt.decorator_list: + if _decorator_name(decorator) in _DECORATOR_NAMES: + return True + return False + + +def _is_scannable_python_file(path: Path) -> bool: + if path.suffix != ".py": + return False + if any(part.startswith(".") for part in path.parts): + return False + if "__pycache__" in path.parts: + return False + return True + + +def _iter_scan_files(root: Path): + for path in root.iterdir(): + if path.is_file() and _is_scannable_python_file(path): + yield path + + opmodes_dir = root / "opmodes" + if not opmodes_dir.is_dir(): + return + + for path in opmodes_dir.rglob("*.py"): + if _is_scannable_python_file(path): + yield path + + +def _module_name_from_path(robot_module, scan_root: Path, path: Path) -> str: + relative_path = path.relative_to(scan_root) + parts = list(relative_path.with_suffix("").parts) + if parts and parts[-1] == "__init__": + parts.pop() + + package = robot_module.__spec__.parent if robot_module.__spec__ is not None else "" + if package: + return ".".join([package, *parts]) if parts else package + return ".".join(parts) + + +def _resolve_opmode_constructor_kwargs( + opmodeCls: type, + robot: "OpModeRobot", + user_controls: Any | None, +) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + initializer = opmodeCls.__dict__.get("__init__") + if initializer is None: + return kwargs + + for parameter in list(inspect.signature(initializer).parameters.values())[1:]: + if parameter.kind in ( + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + ): + raise TypeError( + f"{opmodeCls.__module__}.{opmodeCls.__qualname__} constructor cannot use *args or **kwargs" + ) + + if parameter.name not in _SUPPORTED_CTOR_PARAM_NAMES: + raise TypeError( + f"{opmodeCls.__module__}.{opmodeCls.__qualname__} uses unsupported constructor parameter name {parameter.name!r}; " + "supported names are 'robot' and 'user_controls'" + ) + + if parameter.name == "robot": + kwargs[parameter.name] = robot + elif user_controls is not None: + kwargs[parameter.name] = user_controls + elif parameter.default is inspect.Parameter.empty: + raise TypeError( + f"{opmodeCls.__module__}.{opmodeCls.__qualname__} requires 'user_controls', " + f"but {type(robot).__module__}.{type(robot).__qualname__} did not declare user_controls" + ) + + return kwargs + + +def _discover_decorated_opmodes(robot: "OpModeRobot") -> None: + robot_module = inspect.getmodule(type(robot)) + if robot_module is None or getattr(robot_module, "__file__", None) is None: + raise RuntimeError( + f"Unable to resolve robot module source file for {type(robot).__module__}.{type(robot).__qualname__}" + ) + + scan_root = Path(robot_module.__file__).resolve().parent + discovered: set[type] = set() + registered_names: set[tuple[RobotMode, str]] = set() + + for path in _iter_scan_files(scan_root): + if not _contains_opmode_decorator(path): + continue + + module_name = _module_name_from_path(robot_module, scan_root, path) + try: + module = importlib.import_module(module_name) + except Exception as exc: + raise RuntimeError( + f"Failed to import opmode discovery candidate {path.resolve()}: {exc}" + ) from exc + for value in vars(module).values(): + metadata = getattr(value, "__wpilib_opmode_metadata__", None) + if metadata is None or not inspect.isclass(value): + continue + if value in discovered: + continue + if not issubclass(value, OpMode): + raise TypeError( + f"Decorated class {value.__module__}.{value.__qualname__} must inherit from OpMode" + ) + + name = metadata.name or value.__name__ + group = metadata.group or "" + description = metadata.description or "" + registration_key = (metadata.mode, name) + if registration_key in registered_names: + raise ValueError( + f"duplicate opmode registration for mode {metadata.mode} and name {name!r}" + ) + + registered_names.add(registration_key) + discovered.add(value) + robot.addOpMode( + value, + metadata.mode, + name, + group, + description, + metadata.textColor, + metadata.backgroundColor, + ) + + robot.publishOpModes() + + class OpModeRobot(OpModeRobotBase): """ OpModeRobot implements the opmode-based robot program framework. @@ -19,10 +358,63 @@ class OpModeRobot(OpModeRobotBase): selected. When no opmode is selected, nonePeriodic() is called. The driverStationConnected() function is called the first time the driver station connects to the robot. + + Decorated opmodes are auto-discovered from a limited set of Python modules + near the robot class: ``*.py`` files directly beside ``robot.py`` and + ``opmodes/**/*.py`` recursively under an ``opmodes`` subpackage. Any class + decorated with ``@autonomous``, ``@teleop``, or ``@utility`` in those files + is imported, validated as an ``OpMode`` subclass, registered automatically, + and then published to the Driver Station during robot initialization. + Selection names must be unique within each robot mode. + + Robot subclasses may declare a framework-managed user controls object using + the ``user_controls=...`` class keyword:: + + from wpilib import DefaultUserControls + + class Robot(OpModeRobot, user_controls=DefaultUserControls): + ... + + When declared, the controls class is instantiated once per robot and may be + injected into opmode constructors by naming a parameter ``user_controls``. + The robot instance may likewise be injected by naming a parameter ``robot``. + Supported opmode constructor shapes therefore include:: + + __init__(self) + __init__(self, robot) + __init__(self, user_controls) + __init__(self, robot, user_controls) + + Injection is strict by parameter name: any other constructor parameter name + is rejected with ``TypeError``. """ + __wpilib_user_controls_type__: type | None = None + + def __init_subclass__(cls, *, user_controls: type | None = None, **kwargs): + super().__init_subclass__(**kwargs) + if user_controls is not None and not isinstance(user_controls, type): + raise TypeError("user_controls must be a type") + if user_controls is not None: + cls.__wpilib_user_controls_type__ = user_controls + def __init__(self): super().__init__() + self._user_controls = self._make_user_controls_instance() + _discover_decorated_opmodes(self) + + def _make_user_controls_instance(self): + user_controls_type = type(self).__wpilib_user_controls_type__ + if user_controls_type is None: + return None + + try: + return user_controls_type() + except TypeError as exc: + raise TypeError( + f"{type(self).__module__}.{type(self).__qualname__} declared user_controls={user_controls_type.__module__}.{user_controls_type.__qualname__}, " + "but it could not be constructed without arguments" + ) from exc def addOpMode( self, @@ -42,8 +434,10 @@ def addOpMode( only one has no effect (if only one is provided, it will be ignored). :param opmodeCls: opmode class; must be a public, non-abstract subclass of OpMode - with a constructor that either takes no arguments or accepts a - single argument of this class's type (the latter is preferred). + with constructor parameters named only 'robot' and/or + 'user_controls'. These dependencies are injected by name. + Supported constructor forms are (), (robot), + (user_controls), and (robot, user_controls). :param mode: robot mode :param name: name of the operating mode :param group: group of the operating mode @@ -52,13 +446,12 @@ def addOpMode( :param backgroundColor: background color """ + constructor_kwargs = _resolve_opmode_constructor_kwargs( + opmodeCls, self, self._user_controls + ) + def makeOpModeInstance() -> OpMode: - # Try to instantiate with robot argument first - try: - return opmodeCls(self) # type: ignore - except TypeError: - # Fallback to no-argument constructor - return opmodeCls() # type: ignore + return opmodeCls(**constructor_kwargs) # type: ignore[arg-type] if textColor is None or backgroundColor is None: self.addOpModeFactory( diff --git a/subprojects/robotpy-wpilib/wpilib/usercontrols.py b/subprojects/robotpy-wpilib/wpilib/usercontrols.py new file mode 100644 index 00000000..fa9c66ba --- /dev/null +++ b/subprojects/robotpy-wpilib/wpilib/usercontrols.py @@ -0,0 +1,18 @@ +from ._wpilib import Gamepad + +__all__ = ["UserControls", "DefaultUserControls"] + + +class UserControls: + """Marker base class for framework-managed user controls objects.""" + + +class DefaultUserControls(UserControls): + """Provides one Gamepad per standard driver station port.""" + + def __init__(self): + self._gamepads = tuple(Gamepad(port) for port in range(6)) + + def getGamepad(self, port: int) -> Gamepad: + """Return the Gamepad instance for the specified driver station port.""" + return self._gamepads[port]