Mercurial
Command-based robotics framework by Dairy Foundation
What is Mercurial?
Mercurial is a command-based framework for FTC developed by Dairy Foundation. It provides a clean, modern way to organize robot code using commands, subsystems, and scheduled operations - similar to WPILib's command framework but designed specifically for FTC with reduced boilerplate.
Mercurial is part of the larger Dairy Foundation ecosystem, which includes:
- Core: Runtime and lifecycle management
- Util: Type-safe units and utilities
- Pasteurized: Advanced gamepad handling
- Mercurial: Command-based framework (this library)
Why Choose Mercurial?
Traditional FTC Code Problems:
- All logic mixed in OpMode classes
- Hard to reuse code between OpModes
- Difficult to test individual components
- Complex state management
Mercurial Solutions:
- Separation of concerns (commands vs subsystems)
- Reusable command compositions
- Unit-tested scheduler
- Lambda-based command creation
- Reduced boilerplate vs other frameworks
- Modern Kotlin-friendly design
Installation
Add to your build.gradle (Module: TeamCode):
dependencies {
// Mercurial (includes bundled dependencies)
implementation "dev.frozenmilk.mercurial:Mercurial:1.0.0"
// Required dependencies (if not auto-included)
implementation "dev.frozenmilk.dairy:Core:1.0.0"
implementation "dev.frozenmilk.dairy:Util:1.0.1"
}dependencies {
// Mercurial (includes bundled dependencies)
implementation("dev.frozenmilk.mercurial:Mercurial:1.0.0")
// Required dependencies (if not auto-included)
implementation("dev.frozenmilk.dairy:Core:1.0.0")
implementation("dev.frozenmilk.dairy:Util:1.0.1")
}Core Concepts
1. Commands
Commands represent actions your robot performs:
- Move arm to position
- Drive forward for distance
- Intake game element
- Wait for time
Commands can be:
- Sequential: Run one after another
- Parallel: Run at the same time
- Conditional: Run based on conditions
- Lambda-based: Quick inline definitions
2. Subsystems
Subsystems represent robot mechanisms:
- Drivetrain
- Arm
- Intake
- Shooter
Each subsystem:
- Owns specific hardware
- Prevents conflicting commands
- Provides methods for commands to use
3. Scheduler
The Scheduler manages command execution:
- Runs commands at the right time
- Handles interruptions
- Manages subsystem requirements
- Provides lifecycle management
Basic Usage
Creating a Subsystem
import dev.frozenmilk.mercurial.subsystems.Subsystem;
public class ArmSubsystem implements Subsystem {
private DcMotor armMotor;
private double targetPosition;
public ArmSubsystem(HardwareMap hardwareMap) {
armMotor = hardwareMap.get(DcMotor.class, "arm");
armMotor.setMode(DcMotor.RunMode.STOP_AND_RESET_ENCODER);
armMotor.setMode(DcMotor.RunMode.RUN_TO_POSITION);
}
@Override
public void periodic() {
// Called every loop - update telemetry, read sensors, etc.
armMotor.setTargetPosition((int) targetPosition);
armMotor.setPower(0.8);
}
// Command-callable methods
public void setPosition(double position) {
this.targetPosition = position;
}
public boolean atTarget() {
return Math.abs(armMotor.getCurrentPosition() - targetPosition) < 10;
}
public void stop() {
armMotor.setPower(0);
}
}import dev.frozenmilk.mercurial.subsystems.Subsystem
class ArmSubsystem(hardwareMap: HardwareMap) : Subsystem {
private val armMotor: DcMotor = hardwareMap.get(DcMotor::class.java, "arm")
private var targetPosition: Double = 0.0
init {
armMotor.mode = DcMotor.RunMode.STOP_AND_RESET_ENCODER
armMotor.mode = DcMotor.RunMode.RUN_TO_POSITION
}
override fun periodic() {
// Called every loop - update telemetry, read sensors, etc.
armMotor.targetPosition = targetPosition.toInt()
armMotor.power = 0.8
}
// Command-callable methods
fun setPosition(position: Double) {
targetPosition = position
}
fun atTarget(): Boolean {
return Math.abs(armMotor.currentPosition - targetPosition) < 10
}
fun stop() {
armMotor.power = 0.0
}
}Creating Commands
import dev.frozenmilk.mercurial.commands.Command;
import dev.frozenmilk.mercurial.commands.util.Wait;
public class ArmCommands {
private ArmSubsystem arm;
public ArmCommands(ArmSubsystem arm) {
this.arm = arm;
}
// Lambda-based instant command
public Command moveToHigh() {
return Command.once(() -> arm.setPosition(1000))
.requires(arm);
}
// Command that waits until arm reaches target
public Command moveToHighAndWait() {
return Command.once(() -> arm.setPosition(1000))
.andThen(Command.waitUntil(() -> arm.atTarget()))
.requires(arm);
}
// Sequential command chain
public Command scoreSample() {
return moveToHighAndWait()
.andThen(Command.wait(0.5)) // Wait to settle
.andThen(openClaw())
.andThen(Command.wait(0.3))
.andThen(moveToLow());
}
// Parallel commands
public Command intakeAndDrive() {
return Command.parallel(
startIntake(),
driveForward(24)
);
}
}import dev.frozenmilk.mercurial.commands.Command
import dev.frozenmilk.mercurial.commands.util.Wait
class ArmCommands(private val arm: ArmSubsystem) {
// Lambda-based instant command
fun moveToHigh(): Command {
return Command.once { arm.setPosition(1000.0) }
.requires(arm)
}
// Command that waits until arm reaches target
fun moveToHighAndWait(): Command {
return Command.once { arm.setPosition(1000.0) }
.andThen(Command.waitUntil { arm.atTarget() })
.requires(arm)
}
// Sequential command chain
fun scoreSample(): Command {
return moveToHighAndWait()
.andThen(Command.wait(0.5)) // Wait to settle
.andThen(openClaw())
.andThen(Command.wait(0.3))
.andThen(moveToLow())
}
// Parallel commands
fun intakeAndDrive(): Command {
return Command.parallel(
startIntake(),
driveForward(24.0)
)
}
}Using Commands in OpMode
import com.qualcomm.robotcore.eventloop.opmode.TeleOp;
import com.qualcomm.robotcore.eventloop.opmode.OpMode;
import dev.frozenmilk.mercurial.Mercurial;
import dev.frozenmilk.mercurial.commands.Command;
@TeleOp(name = "Mercurial TeleOp")
public class MercurialTeleOp extends OpMode {
private ArmSubsystem arm;
private ArmCommands armCommands;
private DriveSubsystem drive;
@Override
public void init() {
// Initialize subsystems
arm = new ArmSubsystem(hardwareMap);
drive = new DriveSubsystem(hardwareMap);
// Initialize commands
armCommands = new ArmCommands(arm);
// Register subsystems with Mercurial
Mercurial.register(arm);
Mercurial.register(drive);
}
@Override
public void loop() {
// Manual drive control
double drive = -gamepad1.left_stick_y;
double turn = gamepad1.right_stick_x;
this.drive.drive(drive, turn);
// Bind commands to buttons
if (gamepad1.a) {
// Schedule command - runs until complete
Mercurial.schedule(armCommands.moveToHigh());
}
if (gamepad1.b) {
Mercurial.schedule(armCommands.moveToLow());
}
if (gamepad1.x) {
// Schedule complex command sequence
Mercurial.schedule(armCommands.scoreSample());
}
// Mercurial automatically calls subsystem.periodic() and runs commands
}
}import com.qualcomm.robotcore.eventloop.opmode.TeleOp
import com.qualcomm.robotcore.eventloop.opmode.OpMode
import dev.frozenmilk.mercurial.Mercurial
import dev.frozenmilk.mercurial.commands.Command
@TeleOp(name = "Mercurial TeleOp")
class MercurialTeleOp : OpMode() {
private lateinit var arm: ArmSubsystem
private lateinit var armCommands: ArmCommands
private lateinit var drive: DriveSubsystem
override fun init() {
// Initialize subsystems
arm = ArmSubsystem(hardwareMap)
drive = DriveSubsystem(hardwareMap)
// Initialize commands
armCommands = ArmCommands(arm)
// Register subsystems with Mercurial
Mercurial.register(arm)
Mercurial.register(drive)
}
override fun loop() {
// Manual drive control
val driveValue = -gamepad1.left_stick_y.toDouble()
val turn = gamepad1.right_stick_x.toDouble()
drive.drive(driveValue, turn)
// Bind commands to buttons
if (gamepad1.a) {
// Schedule command - runs until complete
Mercurial.schedule(armCommands.moveToHigh())
}
if (gamepad1.b) {
Mercurial.schedule(armCommands.moveToLow())
}
if (gamepad1.x) {
// Schedule complex command sequence
Mercurial.schedule(armCommands.scoreSample())
}
// Mercurial automatically calls subsystem.periodic() and runs commands
}
}Advanced Features
Conditional Commands
// Run different commands based on condition
public Command smartScore() {
return Command.select(
() -> arm.getCurrentPosition() > 500, // Condition
moveToHigh(), // If true
moveToMid() // If false
);
}
// Run command only if condition is true
public Command safeScore() {
return Command.waitUntil(() -> sensor.isReady())
.andThen(score());
}// Run different commands based on condition
fun smartScore(): Command {
return Command.select(
{ arm.currentPosition > 500 }, // Condition
moveToHigh(), // If true
moveToMid() // If false
)
}
// Run command only if condition is true
fun safeScore(): Command {
return Command.waitUntil { sensor.isReady() }
.andThen(score())
}Parallel Racing and Deadlines
// Run commands in parallel, end when first finishes
public Command raceIntake() {
return Command.race(
spinIntake(), // Spin forever
Command.wait(2.0) // Timeout after 2 seconds
);
}
// Run commands in parallel, end when specific one finishes
public Command driveWithTimeout() {
return Command.deadline(
driveForward(48), // Main command (determines when to stop)
updateTelemetry(), // Side effect (runs while main runs)
monitorSensors() // Another side effect
);
}// Run commands in parallel, end when first finishes
fun raceIntake(): Command {
return Command.race(
spinIntake(), // Spin forever
Command.wait(2.0) // Timeout after 2 seconds
)
}
// Run commands in parallel, end when specific one finishes
fun driveWithTimeout(): Command {
return Command.deadline(
driveForward(48.0), // Main command (determines when to stop)
updateTelemetry(), // Side effect (runs while main runs)
monitorSensors() // Another side effect
)
}Command Decorators
// Run command repeatedly
Command repeatIntake = spinIntake().repeatedly();
// Run with timeout
Command timedDrive = driveForward(24).withTimeout(3.0);
// Run until condition
Command driveToLine = driveForward(100)
.until(() -> colorSensor.red() > 200);
// Add action before/after
Command scoringSequence = moveToHigh()
.beforeStarting(() -> telemetry.speak("Scoring"))
.andThen(() -> telemetry.speak("Complete"));// Run command repeatedly
val repeatIntake = spinIntake().repeatedly()
// Run with timeout
val timedDrive = driveForward(24.0).withTimeout(3.0)
// Run until condition
val driveToLine = driveForward(100.0)
.until { colorSensor.red() > 200 }
// Add action before/after
val scoringSequence = moveToHigh()
.beforeStarting { telemetry.speak("Scoring") }
.andThen { telemetry.speak("Complete") }Autonomous Example
@Autonomous(name = "Mercurial Auto")
public class MercurialAuto extends OpMode {
private DriveSubsystem drive;
private ArmSubsystem arm;
private IntakeSubsystem intake;
@Override
public void init() {
drive = new DriveSubsystem(hardwareMap);
arm = new ArmSubsystem(hardwareMap);
intake = new IntakeSubsystem(hardwareMap);
Mercurial.register(drive);
Mercurial.register(arm);
Mercurial.register(intake);
// Build autonomous sequence
Command auto = Command.sequential(
// Score preload
arm.moveToHigh(),
Command.wait(1.0),
intake.outtake(),
Command.wait(0.5),
arm.moveToGround(),
// Get sample 1
drive.driveForward(24),
Command.parallel(
intake.intake(),
arm.moveToIntake()
),
Command.wait(1.0),
// Score sample 1
drive.driveBackward(24),
arm.moveToHigh(),
Command.wait(1.0),
intake.outtake(),
// Park
arm.moveToTravel(),
drive.strafeRight(36),
drive.driveForward(12)
);
// Schedule it to run when OpMode starts
Mercurial.schedule(auto);
}
@Override
public void loop() {
// Mercurial handles everything automatically
telemetry.addData("Status", "Running autonomous");
telemetry.update();
}
}@Autonomous(name = "Mercurial Auto")
class MercurialAuto : OpMode() {
private lateinit var drive: DriveSubsystem
private lateinit var arm: ArmSubsystem
private lateinit var intake: IntakeSubsystem
override fun init() {
drive = DriveSubsystem(hardwareMap)
arm = ArmSubsystem(hardwareMap)
intake = IntakeSubsystem(hardwareMap)
Mercurial.register(drive)
Mercurial.register(arm)
Mercurial.register(intake)
// Build autonomous sequence
val auto = Command.sequential(
// Score preload
arm.moveToHigh(),
Command.wait(1.0),
intake.outtake(),
Command.wait(0.5),
arm.moveToGround(),
// Get sample 1
drive.driveForward(24.0),
Command.parallel(
intake.intake(),
arm.moveToIntake()
),
Command.wait(1.0),
// Score sample 1
drive.driveBackward(24.0),
arm.moveToHigh(),
Command.wait(1.0),
intake.outtake(),
// Park
arm.moveToTravel(),
drive.strafeRight(36.0),
drive.driveForward(12.0)
)
// Schedule it to run when OpMode starts
Mercurial.schedule(auto)
}
override fun loop() {
// Mercurial handles everything automatically
telemetry.addData("Status", "Running autonomous")
telemetry.update()
}
}Mercurial vs Other Frameworks
| Feature | Mercurial | NextFTC | FTCLib Commands |
|---|---|---|---|
| Learning Curve | Moderate | Low | Moderate |
| Boilerplate | Minimal | Medium | Higher |
| Lambda Commands | ✅ Yes | ✅ Yes | Limited |
| Kotlin Support | ✅ Excellent | ✅ Good | ⚠️ Java-focused |
| Command Composition | ✅ Rich | ✅ Good | ✅ Rich |
| Ecosystem | Dairy Foundation | Standalone | FTCLib Suite |
| Documentation | Growing | Extensive | Extensive |
| Community | Small | Growing | Large |
When to Choose Mercurial
Choose Mercurial if:
- You want modern, low-boilerplate code
- You prefer Kotlin or Kotlin-friendly Java
- You like lambda-based command definitions
- You value clean, composable command structures
- You're interested in the Dairy Foundation ecosystem
Choose something else if:
- You want the largest community (use FTCLib)
- You want the most documentation (use NextFTC)
- You prefer imperative programming (use plain OpModes)
- Your team is unfamiliar with functional concepts
Tips for Success
- Start Simple: Begin with one subsystem and a few commands
- Think in Actions: Break robot behavior into discrete commands
- Compose Commands: Build complex sequences from simple ones
- Use Lambda Commands: Quick inline definitions reduce code
- Test Subsystems: Test each subsystem independently first
- Read Dairy Docs: Check docs.dairy.foundation for updates
Common Patterns
Button Binding with Edge Detection
public class TeleOp extends OpMode {
private boolean lastA = false;
@Override
public void loop() {
// Edge detection - only trigger on button press, not hold
boolean currentA = gamepad1.a;
if (currentA && !lastA) {
Mercurial.schedule(armCommands.moveToHigh());
}
lastA = currentA;
}
}class TeleOp : OpMode() {
private var lastA = false
override fun loop() {
// Edge detection - only trigger on button press, not hold
val currentA = gamepad1.a
if (currentA && !lastA) {
Mercurial.schedule(armCommands.moveToHigh())
}
lastA = currentA
}
}Default Commands for Subsystems
@Override
public void init() {
arm = new ArmSubsystem(hardwareMap);
// Set default command - runs when no other command uses this subsystem
arm.setDefaultCommand(
Command.repeating(() -> {
double power = -gamepad2.left_stick_y;
arm.manualControl(power);
}).requires(arm)
);
}override fun init() {
arm = ArmSubsystem(hardwareMap)
// Set default command - runs when no other command uses this subsystem
arm.setDefaultCommand(
Command.repeating {
val power = -gamepad2.left_stick_y.toDouble()
arm.manualControl(power)
}.requires(arm)
)
}Resources
- Official Docs: docs.dairy.foundation
- GitHub: Dairy Foundation GitHub
- Maven Repository: Maven Central
Next Steps
- Add Mercurial to your
build.gradle - Create a simple subsystem (arm or intake)
- Write basic commands for it
- Test in a TeleOp OpMode
- Build command sequences for autonomous
- Explore Pasteurized for advanced gamepad handling
- Check Dairy Foundation docs for updates