diff --git a/docs/magicbot.rst b/docs/magicbot.rst index 6806f06..868b931 100644 --- a/docs/magicbot.rst +++ b/docs/magicbot.rst @@ -51,3 +51,39 @@ State machines :exclude-members: members :undoc-members: :show-inheritance: + +Commands +~~~~~~~~ + +.. automodule:: magicbot.commands + :members: + :undoc-members: + :show-inheritance: + +``magicbot.commands.CommandRunner`` is a MagicBot component that executes +stored ``commands2.Command`` instances using keep-alive semantics. + +Call ``self.cmd.run(command)`` each loop that a stored command instance should +remain active. If a command is not re-requested on a later loop, it is ended as +interrupted when it is interruptible. + +Important usage constraints: + +- construct command instances once and store them on the robot/component +- do not create a new command inside ``execute()`` or a state method each loop +- components, state machines, and robot code may all call ``run(command)`` +- multiple different commands may run concurrently +- this is not ``commands2.CommandScheduler`` and does not perform full + scheduler-style requirement arbitration + +Example:: + + class Shooter: + cmd: magicbot.commands.CommandRunner + + def setup(self): + self.fire = FireCommand(...) + + def execute(self): + if should_fire(): + self.cmd.run(self.fire) diff --git a/magicbot/commands.py b/magicbot/commands.py new file mode 100644 index 0000000..6608c56 --- /dev/null +++ b/magicbot/commands.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from typing import Callable + +import commands2 + + +class CommandRunner: + """ + A MagicBot component that executes ``commands2.Command`` instances using + keep-alive semantics. + + Call :meth:`run` to start a stored command instance. Once started, a + command continues running on each later loop until it reports finished. + + Multiple different commands may be active concurrently. Repeated + :meth:`run` calls for the same command instance in a single loop are + deduplicated. + + This component intentionally does not expose or depend on + ``commands2.CommandScheduler``. + """ + + robot: object + + def __init__(self) -> None: + self._requested: dict[commands2.Command, None] = {} + self._active: dict[commands2.Command, None] = {} + + def run(self, command: commands2.Command) -> None: + """ + Request that a stored command instance start running. + """ + self._requested.setdefault(command, None) + + def _discard(self, command: commands2.Command) -> None: + self._requested.pop(command, None) + self._active.pop(command, None) + + def _call(self, command: commands2.Command, func: Callable[[], object]) -> bool: + try: + func() + except Exception: + self._discard(command) + self.robot.onException() + return False + return True + + def execute(self) -> None: + new_commands = [ + command for command in self._requested if command not in self._active + ] + for command in new_commands: + if self._call(command, command.initialize): + self._active[command] = None + + commands_to_execute = [ + command for command in self._requested if command in self._active + ] + commands_to_execute.extend( + command for command in self._active if command not in self._requested + ) + + finished: list[commands2.Command] = [] + for command in commands_to_execute: + if not self._call(command, command.execute): + continue + if command.isFinished(): + if self._call(command, lambda command=command: command.end(False)): + finished.append(command) + + for command in finished: + self._active.pop(command, None) + + self._requested.clear() diff --git a/magicbot/magicrobot.py b/magicbot/magicrobot.py index 22df302..e28d7e8 100644 --- a/magicbot/magicrobot.py +++ b/magicbot/magicrobot.py @@ -692,7 +692,7 @@ def _create_components(self) -> None: self._components = components def _collect_injectables(self) -> dict[str, Any]: - injectables = {} + injectables = {"robot": self} cls = type(self) for n in dir(self): diff --git a/pyproject.toml b/pyproject.toml index 2ef1836..cfd0267 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ classifiers = [ dependencies = [ "toposort", "wpilib>=2026.1.1,<2027", + "robotpy-commands-v2>=2026.1.1,<2027", ] [[project.authors]] diff --git a/tests/test_magicbot_commands.py b/tests/test_magicbot_commands.py new file mode 100644 index 0000000..ce669af --- /dev/null +++ b/tests/test_magicbot_commands.py @@ -0,0 +1,293 @@ +from unittest.mock import Mock + +import commands2 +import magicbot +import magicbot.commands + + +class RecordingCommand(commands2.Command): + def __init__(self, *, finished_after: int | None = None) -> None: + super().__init__() + self.finished_after = finished_after + self.initialize_calls = 0 + self.execute_calls = 0 + self.end_calls: list[bool] = [] + + def initialize(self): + self.initialize_calls += 1 + + def execute(self): + self.execute_calls += 1 + + def isFinished(self) -> bool: + return ( + self.finished_after is not None + and self.execute_calls >= self.finished_after + ) + + def end(self, interrupted: bool): + self.end_calls.append(interrupted) + + +class OrderedRecordingCommand(RecordingCommand): + def __init__( + self, name: str, events: list[tuple[str, str]], *, hash_value: int + ) -> None: + super().__init__() + self.name = name + self.events = events + self.hash_value = hash_value + + def __hash__(self) -> int: + return self.hash_value + + def initialize(self): + super().initialize() + self.events.append(("initialize", self.name)) + + def execute(self): + super().execute() + self.events.append(("execute", self.name)) + + +class RaiseOnInitialize(commands2.Command): + def initialize(self): + raise RuntimeError("initialize boom") + + +class RaiseOnExecute(commands2.Command): + def execute(self): + raise RuntimeError("execute boom") + + +class RaiseOnEnd(commands2.Command): + def __init__(self): + super().__init__() + self.executed = False + + def execute(self): + self.executed = True + + def isFinished(self) -> bool: + return self.executed + + def end(self, interrupted: bool): + raise RuntimeError("end boom") + + +class RecordingRobot(magicbot.MagicRobot): + def createObjects(self): + pass + + def onException(self, forceReport: bool = False) -> None: + self.on_exception_calls += 1 + + +class UsesCommand: + cmd: "magicbot.commands.CommandRunner" + + def setup(self): + self.command = RecordingCommand() + self.should_run = False + + def execute(self): + if self.should_run: + self.cmd.run(self.command) + + +class AlsoUsesCommand: + cmd: "magicbot.commands.CommandRunner" + + def setup(self): + self.command = RecordingCommand() + self.should_run = False + + def execute(self): + if self.should_run: + self.cmd.run(self.command) + + +class ComponentRobot(RecordingRobot): + cmd: "magicbot.commands.CommandRunner" + first: UsesCommand + second: AlsoUsesCommand + + def createObjects(self): + self.on_exception_calls = 0 + self._automodes = Mock() + self._automodes.modes = {} + + +def make_runner(): + robot = RecordingRobot() + robot.on_exception_calls = 0 + runner = magicbot.commands.CommandRunner() + runner.robot = robot + return robot, runner + + +def make_component_robot() -> ComponentRobot: + robot = ComponentRobot() + robot.createObjects() + robot._create_components() + return robot + + +def test_command_runner_initializes_executes_and_finishes_once(): + _, runner = make_runner() + command = RecordingCommand(finished_after=2) + + runner.run(command) + runner.execute() + + assert command.initialize_calls == 1 + assert command.execute_calls == 1 + assert command.end_calls == [] + + runner.run(command) + runner.execute() + + assert command.initialize_calls == 1 + assert command.execute_calls == 2 + assert command.end_calls == [False] + + +def test_command_runner_keeps_alive_requested_command(): + _, runner = make_runner() + command = RecordingCommand() + + runner.run(command) + runner.execute() + runner.run(command) + runner.execute() + + assert command.initialize_calls == 1 + assert command.execute_calls == 2 + assert command.end_calls == [] + + +def test_command_runner_keeps_running_until_finished_without_run_call(): + _, runner = make_runner() + command = RecordingCommand(finished_after=2) + + runner.run(command) + runner.execute() + runner.execute() + + assert command.initialize_calls == 1 + assert command.execute_calls == 2 + assert command.end_calls == [False] + + +def test_command_runner_deduplicates_same_command_requested_twice(): + _, runner = make_runner() + command = RecordingCommand() + + runner.run(command) + runner.run(command) + runner.execute() + + assert command.initialize_calls == 1 + assert command.execute_calls == 1 + assert command.end_calls == [] + + +def test_command_runner_executes_multiple_commands_concurrently(): + _, runner = make_runner() + first = RecordingCommand() + second = RecordingCommand() + + runner.run(first) + runner.run(second) + runner.execute() + + assert first.initialize_calls == 1 + assert first.execute_calls == 1 + assert second.initialize_calls == 1 + assert second.execute_calls == 1 + + +def test_command_runner_executes_commands_in_run_call_order(): + _, runner = make_runner() + events: list[tuple[str, str]] = [] + first = OrderedRecordingCommand("first", events, hash_value=1) + second = OrderedRecordingCommand("second", events, hash_value=8) + + runner.run(first) + runner.run(second) + runner.execute() + + assert events == [ + ("initialize", "first"), + ("initialize", "second"), + ("execute", "first"), + ("execute", "second"), + ] + + events.clear() + + runner.run(second) + runner.run(first) + runner.execute() + + assert events == [("execute", "second"), ("execute", "first")] + + +def test_command_runner_delegates_initialize_exception_to_robot(): + robot, runner = make_runner() + command = RaiseOnInitialize() + + runner.run(command) + runner.execute() + + assert robot.on_exception_calls == 1 + + +def test_command_runner_delegates_execute_exception_to_robot(): + robot, runner = make_runner() + command = RaiseOnExecute() + + runner.run(command) + runner.execute() + + assert robot.on_exception_calls == 1 + + +def test_command_runner_delegates_end_exception_to_robot(): + robot, runner = make_runner() + command = RaiseOnEnd() + + runner.run(command) + runner.execute() + + assert robot.on_exception_calls == 1 + + +def test_command_runner_works_as_magicbot_component(): + robot = make_component_robot() + + robot.first.should_run = True + robot.first.execute() + robot.cmd.execute() + + assert robot.first.command.initialize_calls == 1 + assert robot.first.command.execute_calls == 1 + + +def test_command_runner_supports_basic_command_group(): + _, runner = make_runner() + first = RecordingCommand(finished_after=1) + second = RecordingCommand(finished_after=1) + group = commands2.cmd.sequence(first, second) + + runner.run(group) + runner.execute() + runner.run(group) + runner.execute() + + assert first.initialize_calls == 1 + assert first.execute_calls == 1 + assert first.end_calls == [False] + assert second.initialize_calls == 1 + assert second.execute_calls == 1 + assert second.end_calls == [False]