Skip to content

The BuildCommand DSL

Daniel Chen edited this page Oct 5, 2024 · 27 revisions

The buildCommand DSL is one of the core features of the KCommand library. With buildCommand, you can combine the benefits of both subclassed and inline commands to create much more expressive commands.

DSL stands for domain-specific language. Normally, domain-specific languages are entirely new languages designed for a singular purpose; however, the buildCommand DSL is pretty similar to the existing syntax of command groups. Instead of being an entirely new language, buildCommand simply uses the nested block syntax from DSLs to allow your code to be more expressive and clean.

The entry point of the buildCommand DSL is the buildCommand() function, which takes a couple of arguments:

  1. Name(String, optional): The name of the buildCommand(appears in logging). Equivalent to a withName() command call.
  2. Log(Boolean, defaults to false): Whether to log the individual commands of the buildCommand. By default, these will be smart dashboard logging calls; however, you can change this with the LoggedCommand.configure() call.
  3. Block(required): The body of the command builder DSL. This is a function object with the context of the CommandBuilder class, which powers the DSL. buildCommand returns a Command, which you can schedule(or map to triggers) however you want to.
val command: Command = buildCommand { 

}
val command2 = buildCommand("Command1", log = true) { }

The BuildCommand body

The buildCommand statement essentially acts like a SequentialCommandGroup: As all commands "added" within the block of the buildCommand are run sequentially, in the order that they are added.

To add commands to the buildCommand, use the "+" unary operator like so:

val command = buildCommand {
    +ArmCommand(2.0, arm)
    +drivetrain.methodThatReturnsCommand().withTimeout(2.0)
    +InstantCommand()
}

Run order: ArmCommand, drivetrain command, then InstantCommand.

Because buildCommand is powered by the SequentialCommandGroup class, subsystem requirements of individual commands are required throughout the whole buildCommand. To add extra requirements, use the require() method in the buildCommand body like so:

val command = buildCommand {
    require(arm, drivetrain, elevator)
    ...
}

BuildCommand scope methods

The buildCommand DSL also contains scope methods for adding inline commands(InstantCommand, ParallelRaceGroup), etc. All of these scope methods contain descriptive names for what they do; which means that ambiguity should be minimal. However, these docs will give you a comprehensive list of everything you can do.

Code blocks vs. CommandBuilder blocks

In the command builder, a "block" is considered to be the outer brackets of a method call. For instance, buildCommand{ ... }, runOnce{ ... }. There are 2 kinds of these blocks: code blocks and CommandBuilder blocks.

A code block is a piece of code that is run(one or more times) when the command is scheduled(and is never run when the buildCommand instance itself is created) The amount of times a code block runs depends on the scope method it is called from. For instance, the code block of runOnce{...} will only run once when the command is scheduled(like an InstantCommand within a SequentialCommandGroup). Here, you can use if statements, when statements, etc. without much risk. Treat code blocks as the lambda functions you pass into a RunCommand or InstantCommand.

On the other hand, a CommandBuilder block is a block that only serves to initialize the command itself. CommandBuilder blocks are run once when the buildCommand instance is created, and that's it. The job of a CommandBuilder block is to act as a scope where you can add commands to a command group, such as a ParallelCommandGroup or SequentialCommandGroup. For instance, the block of a buildCommand { ... } statement is a CommandBuilder block.

val test = buildCommand {
    println("hello init") // this statement is in a CommandBuilder block; thus, "hello init" is printed once when the buildCommand instance is created
    runOnce {
        println("hello command!") // this statement is in a code block; thus, "hello command" is printed when the buildCommand is scheduled.
    }
}

Scope methods with code blocks

From the name itself, it should be pretty clear what these do. Refer to the equivalent WPILib commands if you're experienced with the base WPILib commands library.

Scope Method Equivalent WPILib command
runOnce{ ... } InstantCommand
loop{ ... } RunCommand
loopForDuration(seconds){ ... } RunCommand.withTimeout()
loopUntil(booleanSupplier){ ... } RunCommand.until()
loopWhile(booleanSupplier){ ... } RunCommand.onlyWhile()
waitForever() (nothing)
wait(seconds) WaitCommand
waitUntil(booleanSupplier) WaitUntilCommand

Other important code block scope methods:

  1. onEnd{ ... } - Runs the code within the code block if the command is interrupted or if it ends natrually. Equivalent to the finallyDo modifier.
  2. stopIf { return@stopIf true } - Stops the entire buildCommand if the code block within stopIf returns true. The above 2 scope methods only work in the main buildCommand body(and not in any of the scope methods mentioned below).

Scope methods with CommandBuilder blocks

It should be pretty clear what each method does from the name itself. Refer to the equivalent WPILib commands if you're experienced with the base WPILib commands library.

Scope Method Equivalent WPILib command
parallel{ ... } ParallelCommandGroup
parallelRace{ ... } ParallelRaceGroup
Command.asDeadline() + parallel { } ParallelDeadlineGroup
runSequence{ ... } SequentialCommandGroup
runSequenceUntil(booleanSupplier){ ... } SequentialCommandGroup.until()
runSequenceWhile(booleanSupplier){ ... } SequentialCommandGroup.onlyWhile()
runSequenceForDuration(seconds){ ... } SequentialCommandGroup.withTimeout()

IMPORTANT NOTICE: Modifiers on code blocks

All code blocks and command builder blocks return their respective command(for instance, runOnce {} returns an InstantCommand, loop {} returns a RunCommand, etc. However, it is UNSAFE to call command methods such as alongWith, finallyDo, ignoringDisable, etc. on these blocks. By the time these blocks have been constructed, their respective commands have already been added to a list; and the above modifiers will simply generate new commands that will invalidate these old commands due to CommandScheduler.registerComposedCommands (which will invalidate these previous references).

Thus, you have to use the .and { } extension method(.modify{} in v1.0.0-Beta1) to apply these methods.

buildCommand {
    loop { doSomething() }
       .and{ withTimeout(5.0).ignoringDisable(true) } // v1.0.0-Beta2 and beyond

    loop { doSomething() }
       .modify{ it.withTimeout(5.0).ignoringDisable(true) } // v1.0.0-Beta1 syntax
}

Deadline-based parallel groups

Sometimes, you want a group of commands run in parallel to end as soon as a certain specified command(the deadline) ends. In WPILib, this is done with a ParallelDeadlineGroup. In KCommand, you would use the asDeadline() extension method on one of the commands in a parallel {} block, like so:

parallel {
    loopForDuration(5) {
        arm.run()
    }.asDeadline()

    +someCommand
    +otherCommand
}

New to v1.0.0-Beta3: You also can use asDeadline() on multiple commands in a parallel block. If you choose to do this, then the parallel command will simply end when all the deadlines end. Beta2 does not support this feature yet.

getOnceDuringRun

One feature that buildCommand does that is not offered in command groups is a getOnceDuringRun property:

val command = buildCommand {
    val armPosition by getOnceDuringRun { arm.angularPosition }
    var mutableProperty by getOnceDuringRun { 0.0 }
}

As you might have guessed, this makes it so that the armPosition property has it's value updated once every time the command is scheduled.

Can vs. Can-nots in buildCommands

  1. You cannot use while loops or long-running for loops anywhere in a buildCommand.
  2. Because command builder blocks only run once when the instance is created, if statements in there have to use constant conditions or values that are valid from the start to the finish of the robot program. Overall, prefer runSequenceIf statements(mentioned below). In code blocks, regular if statements are the recommendation.
  3. No explicit buildCommand receivers(this@buildCommand) are allowed.
  4. require(*subsystems) must be called in the main buildCommand body.

runSequenceIf

As a replacement for ConditionalCommands, buildCommand has neat syntax for if and else-if chains as shown below:

val command = buildCommand {
    runSequenceIf({someComputedCondition}){
         // command builder block; will run commands sequentially if the condition is met during runtime.
         +ArmCommand()
    }.orElseIf(booleanSupplier) {
         // command builder block; will run commands sequentially if the booleanSupplier is met.
    }.orElse {
         +ElevatorCommand()
    }
}

The boolean conditions are computed once the buildCommand reaches the runSequenceIf statement during runtime.

realRobotOnly and simOnly

These blocks make it so that the code within them only runs when the robot is real/a sim. Use these 2 blocks to add custom command logic to your real/sim robot modes.

Example buildCommand

val superDuperAuto = buildCommand("Super Duper Auto", log = true) {
    simOnly {
        loopForDuration(2) { println("Auto is within simulation") }
    }

    parallel {
        +AutoBuilder
            .followPath(PathPlannerPath.fromPathFile("StartupPath"))
            .asDeadline()

        loop {
            groundIntake.intake()
            shooter.setIdle()
        }

        runSequence {
            runOnce { println("More initialization") }

            loopUntil({someCondition}) {
                noteObserver.update()
            }
        }
    }

    runSequenceIf({goBack}){
        +AutoBuilder.followPath(PathPlannerPath.fromPathFile("SomeOtherPath"))
    }.orElse {
        runOnce { drivetrain.stop() }
    }
}