Add chute controller with homing and position unwrapping

Implements automated homing, potentiometer wraparound tracking, and position
control for FTC chute mechanism. Includes mock hardware for testing and
real hardware adapters for deployment.
This commit is contained in:
Eric Ratliff
2026-02-03 23:41:58 -06:00
parent ebccb21ed2
commit a69c2bf718
14 changed files with 765 additions and 46 deletions

View File

@@ -1,19 +1,24 @@
plugins {
java
}
dependencies {
// Testing (runs on PC without SDK)
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("org.mockito:mockito-core:5.5.0")
}
java {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
// Exclude FTC hardware files from PC compilation (they need Android SDK)
tasks.withType<JavaCompile> {
exclude("**/FtcMotor.java")
exclude("**/FtcPotentiometer.java")
exclude("**/opmodes/ChuteOpMode.java")
}
tasks.test {
useJUnitPlatform()
testLogging {
@@ -22,7 +27,6 @@ tasks.test {
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
}
// Task to deploy code to FTC SDK
tasks.register<Copy>("deployToSDK") {
group = "ftc"
@@ -40,7 +44,6 @@ tasks.register<Copy>("deployToSDK") {
println("✓ Code deployed to TeamCode")
}
}
// Task to build APK
tasks.register<Exec>("buildApk") {
group = "ftc"

View File

@@ -0,0 +1,122 @@
package org.firstinspires.ftc.teamcode.opmodes;
import com.qualcomm.robotcore.eventloop.opmode.LinearOpMode;
import com.qualcomm.robotcore.eventloop.opmode.TeleOp;
import com.qualcomm.robotcore.hardware.DcMotor;
import com.qualcomm.robotcore.hardware.AnalogInput;
import org.firstinspires.ftc.teamcode.subsystems.chute.Chute;
import org.firstinspires.ftc.teamcode.subsystems.chute.FtcMotor;
import org.firstinspires.ftc.teamcode.subsystems.chute.FtcPotentiometer;
import org.firstinspires.ftc.teamcode.subsystems.chute.ChuteController;
/**
* Example OpMode demonstrating chute control with real hardware.
*
* Hardware Config:
* - Motor: "chute_motor" (DcMotor)
* - Potentiometer: "chute_pot" (AnalogInput)
*/
@TeleOp(name = "Chute Control")
public class ChuteOpMode extends LinearOpMode {
private Chute chute;
// Position presets (in radians)
private static final double HOME = 0.0;
private static final double LOW = Math.PI;
private static final double MID = 2 * Math.PI;
private static final double HIGH = 3 * Math.PI;
private static final double MAX = 4 * Math.PI;
@Override
public void runOpMode() {
// Get hardware from config
DcMotor chuteMotor = hardwareMap.get(DcMotor.class, "chute_motor");
AnalogInput chutePot = hardwareMap.get(AnalogInput.class, "chute_pot");
// Create hardware adapters
FtcMotor motor = new FtcMotor(chuteMotor);
FtcPotentiometer pot = new FtcPotentiometer(chutePot);
// Create chute controller with real hardware
ChuteController controller = new ChuteController(motor, pot, MAX);
chute = new Chute(controller, motor, pot);
telemetry.addLine("Chute initialized");
telemetry.addLine("Press A to home");
telemetry.update();
waitForStart();
while (opModeIsActive()) {
// Update chute (50Hz)
chute.update(0.02);
// Control with gamepad
handleControls();
// Display status
updateTelemetry();
sleep(20); // 50Hz loop
}
}
private void handleControls() {
// Manual homing
if (gamepad1.a) {
chute.home();
}
// Emergency stop
if (gamepad1.b) {
chute.stop();
}
// Position presets
if (gamepad1.dpad_down) {
chute.setTargetPosition(HOME);
}
if (gamepad1.dpad_left) {
chute.setTargetPosition(LOW);
}
if (gamepad1.dpad_right) {
chute.setTargetPosition(MID);
}
if (gamepad1.dpad_up) {
chute.setTargetPosition(HIGH);
}
// Fine control with triggers
if (gamepad1.left_trigger > 0.1) {
double current = chute.getPosition();
chute.setTargetPosition(current - 0.1);
}
if (gamepad1.right_trigger > 0.1) {
double current = chute.getPosition();
chute.setTargetPosition(current + 0.1);
}
}
private void updateTelemetry() {
telemetry.addLine("=== Chute Status ===");
telemetry.addData("Homed", chute.isHomed() ? "YES" : "NO");
telemetry.addData("Moving", chute.isMoving() ? "YES" : "NO");
telemetry.addData("Position", "%.2f rad (%.0f°)",
chute.getPosition(), Math.toDegrees(chute.getPosition()));
if (chute.isHomed()) {
double percent = (chute.getPosition() / MAX) * 100;
telemetry.addData("Extension", "%.0f%%", percent);
}
telemetry.addLine();
telemetry.addLine("=== Controls ===");
telemetry.addLine("A: Home | B: Stop");
telemetry.addLine("D-Pad: Presets");
telemetry.addLine("Triggers: Fine adjust");
telemetry.update();
}
}

View File

@@ -0,0 +1,65 @@
package org.firstinspires.ftc.teamcode.subsystems.chute;
/**
* Simple chute subsystem.
*/
public class Chute {
private final ChuteController controller;
private final MockMotor motor;
private final MockPotentiometer pot;
/**
* Create chute with mock hardware (for testing).
*/
public Chute(double maxExtension) {
this.motor = new MockMotor();
this.pot = new MockPotentiometer();
this.controller = new ChuteController(motor, pot, maxExtension);
}
/**
* Create chute with real hardware (for robot).
* Use FtcMotor and FtcPotentiometer adapters.
*/
public Chute(ChuteController controller, MockMotor motor, MockPotentiometer pot) {
this.controller = controller;
this.motor = motor;
this.pot = pot;
}
public void update(double dt) {
controller.update(dt);
}
public void home() {
controller.home();
}
public void setTargetPosition(double position) {
controller.moveToPosition(position);
}
public void stop() {
controller.stop();
}
public double getPosition() {
return controller.getPosition();
}
public boolean isHomed() {
return controller.isHomed();
}
public boolean isMoving() {
return controller.isMoving();
}
public boolean isAtTarget() {
return isHomed() && !isMoving();
}
// For testing
public MockMotor getMotor() { return motor; }
public MockPotentiometer getPot() { return pot; }
}

View File

@@ -0,0 +1,197 @@
package org.firstinspires.ftc.teamcode.subsystems.chute;
/**
* Simple chute controller with homing and position control.
*/
public class ChuteController {
private final MockMotor motor;
private final MockPotentiometer pot;
private final double maxExtension;
private boolean homed = false;
private boolean homing = false;
private double homeVoltage = 0.0;
private double currentPosition = 0.0;
private int wrapCount = 0;
private double lastVoltage = 0.0;
private double targetPosition = 0.0;
private boolean moving = false;
private boolean needsToMove = false;
private int stableCount = 0;
private static final int STABLE_REQUIRED = 5;
private static final double TOLERANCE = 0.15; // Generous tolerance
/**
* Create a new chute controller.
* @param motor Motor that drives the chute
* @param pot Potentiometer that measures position
* @param maxExtension Maximum extension from home in radians
*/
public ChuteController(MockMotor motor, MockPotentiometer pot, double maxExtension) {
this.motor = motor;
this.pot = pot;
this.maxExtension = maxExtension;
}
/**
* Start homing sequence - drives down until pot stops changing, then captures reference voltage.
*/
public void home() {
motor.setPower(-0.5);
homed = false;
homing = true;
stableCount = 0;
lastVoltage = pot.getVoltage();
}
/**
* Move to target position. Automatically homes first if not already homed.
* @param target Target position in radians from home
*/
public void moveToPosition(double target) {
target = Math.max(0, Math.min(maxExtension, target));
targetPosition = target;
if (!homed) {
needsToMove = true;
if (!homing) {
home();
}
return;
}
moving = true;
needsToMove = false;
if (target > currentPosition) {
motor.setPower(0.7);
} else {
motor.setPower(-0.7);
}
}
/**
* Update controller state - call this every loop iteration.
* @param dt Time since last update in seconds
*/
public void update(double dt) {
motor.update(dt);
pot.updatePosition(motor.getPower() * dt);
double voltage = pot.getVoltage();
if (homing) {
updateHoming(voltage);
} else if (moving) {
updateMoving(voltage);
}
lastVoltage = voltage;
}
/**
* Update homing state - waits for stable pot readings then captures home voltage.
*/
private void updateHoming(double voltage) {
if (Math.abs(voltage - lastVoltage) < 0.01) {
stableCount++;
if (stableCount >= STABLE_REQUIRED) {
motor.setPower(0);
homeVoltage = voltage;
currentPosition = 0.0;
wrapCount = 0;
homed = true;
homing = false;
stableCount = 0;
if (needsToMove) {
needsToMove = false;
moving = true;
if (targetPosition > 0) {
motor.setPower(0.7);
}
}
}
} else {
stableCount = 0;
}
}
/**
* Update moving state - tracks wraps, calculates position, stops at target.
*/
private void updateMoving(double voltage) {
double delta = voltage - lastVoltage;
// Detect wraps - look for large jumps
if (Math.abs(delta) > 2.0) {
if (motor.getPower() > 0) {
// Moving up: voltage wraps from ~0 to pi (positive jump)
wrapCount++;
} else {
// Moving down: voltage wraps from pi to ~0 (negative jump)
wrapCount--;
}
}
// Calculate position: offset from home + wraps
double offset = voltage - homeVoltage;
if (offset < 0) offset += Math.PI; // Handle wraparound in offset
currentPosition = offset + (wrapCount * Math.PI);
if (currentPosition < 0) currentPosition = 0;
// Check if at target
if (Math.abs(currentPosition - targetPosition) < TOLERANCE) {
motor.setPower(0);
moving = false;
}
// Safety limit
if (currentPosition >= maxExtension) {
motor.setPower(0);
moving = false;
}
}
/**
* Check if chute has been homed.
* @return True if homing is complete
*/
public boolean isHomed() {
return homed;
}
/**
* Check if chute is currently moving to target.
* @return True if moving
*/
public boolean isMoving() {
return moving;
}
/**
* Get current position of chute.
* @return Position in radians from home
*/
public double getPosition() {
return currentPosition;
}
/**
* Get the home voltage reference captured during homing.
* @return Home voltage in range [0, pi]
*/
public double getHomeVoltage() {
return homeVoltage;
}
/**
* Emergency stop - immediately stops motor and cancels movement.
*/
public void stop() {
motor.setPower(0);
moving = false;
}
}

View File

@@ -0,0 +1,37 @@
package org.firstinspires.ftc.teamcode.subsystems.chute;
import com.qualcomm.robotcore.hardware.DcMotor;
/**
* Adapter that wraps a real FTC DcMotor and makes it look like a MockMotor.
* Use this to connect the chute controller to actual hardware.
*/
public class FtcMotor extends MockMotor {
private final DcMotor motor;
/**
* Create motor adapter.
* @param motor The real FTC motor from hardwareMap
*/
public FtcMotor(DcMotor motor) {
super();
this.motor = motor;
// Configure motor
motor.setZeroPowerBehavior(DcMotor.ZeroPowerBehavior.BRAKE);
motor.setMode(DcMotor.RunMode.RUN_WITHOUT_ENCODER);
}
@Override
public void setPower(double power) {
super.setPower(power); // Track power in parent
motor.setPower(power); // Send to real motor
}
@Override
public void update(double dt) {
// Real motor updates itself - we don't simulate
// But we still call parent to track internal position for mock purposes
super.update(dt);
}
}

View File

@@ -0,0 +1,44 @@
package org.firstinspires.ftc.teamcode.subsystems.chute;
import com.qualcomm.robotcore.hardware.AnalogInput;
/**
* Adapter that wraps a real FTC AnalogInput (potentiometer) and makes it look like a MockPotentiometer.
* Use this to connect the chute controller to actual hardware.
*/
public class FtcPotentiometer extends MockPotentiometer {
private final AnalogInput pot;
private final double maxVoltage;
/**
* Create potentiometer adapter.
* @param pot The real FTC analog input from hardwareMap
*/
public FtcPotentiometer(AnalogInput pot) {
super();
this.pot = pot;
this.maxVoltage = pot.getMaxVoltage(); // Usually 3.3V
}
@Override
public double getVoltage() {
// Read actual voltage from hardware
double rawVoltage = pot.getVoltage();
// Scale to [0, pi] range
// Assumes pot uses full voltage range (0 to maxVoltage)
return (rawVoltage / maxVoltage) * Math.PI;
}
@Override
public void updatePosition(double delta) {
// Real pot updates itself - ignore this
// (This method is only used in simulation)
}
@Override
public void setPosition(double pos) {
// Can't set real hardware position - ignore
// (This method is only used in simulation)
}
}

View File

@@ -0,0 +1,36 @@
package org.firstinspires.ftc.teamcode.subsystems.chute;
/**
* Simple mock motor.
*/
public class MockMotor {
private double power;
private double position;
private double speed = 1.0; // rad/s at full power
public MockMotor() {
this.power = 0.0;
this.position = 0.0;
}
public void setPower(double power) {
this.power = Math.max(-1.0, Math.min(1.0, power));
}
public double getPower() {
return power;
}
public void update(double dt) {
position += power * speed * dt;
if (position < 0) position = 0; // Hard stop at home
}
public double getPosition() {
return position;
}
public void setPosition(double pos) {
this.position = pos;
}
}

View File

@@ -0,0 +1,27 @@
package org.firstinspires.ftc.teamcode.subsystems.chute;
/**
* Simple mock potentiometer with wraparound at pi.
*/
public class MockPotentiometer {
private double position;
public MockPotentiometer() {
this.position = 0.0;
}
public double getVoltage() {
// Wrap to [0, pi]
double wrapped = position % Math.PI;
if (wrapped < 0) wrapped += Math.PI;
return wrapped;
}
public void setPosition(double pos) {
this.position = pos;
}
public void updatePosition(double delta) {
this.position += delta;
}
}

View File

@@ -1 +0,0 @@
# This file ensures the directory is tracked by git even when empty

View File

@@ -1,27 +0,0 @@
package robot.opmodes;
/**
* Basic OpMode for chute-drive
*
* This is a placeholder to demonstrate project structure.
* To use this with FTC SDK:
* 1. Run: weevil deploy chute-drive
* 2. Add FTC SDK imports (OpMode, TeleOp, etc.)
* 3. Extend OpMode and implement methods
*
* For local testing (without robot), write unit tests in src/test/java/robot/
* Run tests with: ./gradlew test
*
* Created by Weevil 1.1.0-rc1
* Template: basic
*/
public class BasicOpMode {
// This placeholder compiles without FTC SDK dependencies
// Replace with actual OpMode code when deploying to robot
public static void main(String[] args) {
System.out.println("chute-drive - Ready for deployment");
System.out.println("Run: weevil deploy chute-drive");
}
}

View File

@@ -1 +0,0 @@
# This file ensures the directory is tracked by git even when empty

View File

@@ -0,0 +1,229 @@
package org.firstinspires.ftc.teamcode.subsystems.chute;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Basic tests for chute system.
*/
public class ChuteTest {
// Time step for simulation: 20ms = 50Hz update rate (mimics real robot loop)
private static final double DT = 0.02;
// Tolerance for floating point comparisons (small acceptable error)
private static final double EPSILON = 0.01;
/**
* Verify potentiometer wraps from pi back to 0 (sawtooth wave behavior).
* Tests that pot correctly simulates 0→pi→0→pi pattern.
*/
@Test
public void testPotWraps() {
MockPotentiometer pot = new MockPotentiometer();
pot.setPosition(0.0);
assertEquals(0.0, pot.getVoltage(), EPSILON);
pot.setPosition(Math.PI / 2);
assertEquals(Math.PI / 2, pot.getVoltage(), EPSILON);
// Wrap at pi
pot.setPosition(Math.PI);
assertEquals(0.0, pot.getVoltage(), EPSILON);
pot.setPosition(2 * Math.PI);
assertEquals(0.0, pot.getVoltage(), EPSILON);
}
/**
* Test homing sequence: drive down until pot stops changing, then capture home voltage.
* Verifies motor stops at home position with zero power.
*/
@Test
public void testHoming() {
Chute chute = new Chute(10.0);
// Start somewhere
chute.getMotor().setPosition(0.5);
chute.getPot().setPosition(0.5);
chute.home();
// Run until homed
for (int i = 0; i < 200; i++) {
chute.update(DT);
if (chute.isHomed()) break;
}
assertTrue(chute.isHomed());
assertEquals(0.0, chute.getMotor().getPower(), EPSILON);
}
/**
* Test basic position control: home first, then move to target position.
* Verifies chute can accurately reach a commanded position.
*/
@Test
public void testMoveToPosition() {
Chute chute = new Chute(10.0);
// Home first
chute.getMotor().setPosition(0.2);
chute.getPot().setPosition(0.2);
chute.home();
for (int i = 0; i < 200; i++) {
chute.update(DT);
if (chute.isHomed()) break;
}
assertTrue(chute.isHomed(), "Should be homed");
// Move to target
chute.setTargetPosition(2.0);
for (int i = 0; i < 1000; i++) {
chute.update(DT);
if (chute.isAtTarget()) break;
}
assertTrue(chute.isAtTarget(), "Should reach target");
assertTrue(Math.abs(chute.getPosition() - 2.0) < 0.3,
"Position should be close to 2.0, got: " + chute.getPosition());
}
/**
* Test unwrap logic through multiple pot wraps (3.5 rotations).
* Verifies controller correctly tracks absolute position across multiple pi boundaries.
*/
@Test
public void testMultipleRotations() {
Chute chute = new Chute(15.0);
// Home
chute.getMotor().setPosition(0.2);
chute.getPot().setPosition(0.2);
chute.home();
for (int i = 0; i < 200; i++) {
chute.update(DT);
if (chute.isHomed()) break;
}
assertTrue(chute.isHomed(), "Should be homed");
// Move to 3.5 rotations (need more time)
double target = 3.5 * Math.PI;
chute.setTargetPosition(target);
for (int i = 0; i < 2000; i++) {
chute.update(DT);
if (chute.isAtTarget()) break;
}
// Should track through wraps
assertTrue(chute.getPosition() > 3.0 * Math.PI,
"Position should be > 3pi, got: " + chute.getPosition());
assertTrue(Math.abs(chute.getPosition() - target) < 0.5,
"Position should be near target, expected: " + target + ", got: " + chute.getPosition());
}
/**
* Test reassembly scenario: pot has different voltage at home after disassembly.
* Verifies homing captures new reference voltage and positioning still works correctly.
*/
@Test
public void testReassembly() {
Chute chute = new Chute(10.0);
// Pot at different voltage when at home
chute.getMotor().setPosition(0.0);
chute.getPot().setPosition(1.5);
chute.home();
for (int i = 0; i < 150; i++) {
chute.update(DT);
if (chute.isHomed()) break;
}
assertTrue(chute.isHomed(), "Should be homed");
// Should still work
chute.setTargetPosition(2.0);
for (int i = 0; i < 1000; i++) {
chute.update(DT);
if (chute.isAtTarget()) break;
}
assertTrue(chute.isAtTarget(), "Should reach target");
assertTrue(Math.abs(chute.getPosition() - 2.0) < 0.3,
"Position should be close to 2.0, got: " + chute.getPosition());
}
/**
* Test maximum extension limit enforcement.
* Verifies chute stops at max extension even when commanded to go beyond.
*/
@Test
public void testMaxExtensionLimit() {
Chute chute = new Chute(2.0); // Only 2 radians max
chute.getMotor().setPosition(0.1);
chute.getPot().setPosition(0.1);
chute.home();
for (int i = 0; i < 200; i++) {
chute.update(DT);
if (chute.isHomed()) break;
}
// Try to go beyond max
chute.setTargetPosition(5.0);
for (int i = 0; i < 1000; i++) {
chute.update(DT);
if (chute.isAtTarget()) break;
}
// Should stop at max
assertTrue(chute.getPosition() <= 2.0,
"Should not exceed max: " + chute.getPosition());
}
/**
* Test bidirectional movement: move up, then back down.
* Verifies unwrap logic works correctly in both directions.
*/
@Test
public void testBidirectionalMovement() {
Chute chute = new Chute(10.0);
chute.getMotor().setPosition(0.1);
chute.getPot().setPosition(0.1);
chute.home();
for (int i = 0; i < 200; i++) {
chute.update(DT);
if (chute.isHomed()) break;
}
// Move up
chute.setTargetPosition(3.0);
for (int i = 0; i < 1000; i++) {
chute.update(DT);
if (chute.isAtTarget()) break;
}
double upPos = chute.getPosition();
assertTrue(upPos > 2.5, "Should move up");
// Move back down
chute.setTargetPosition(1.0);
for (int i = 0; i < 1000; i++) {
chute.update(DT);
if (chute.isAtTarget()) break;
}
double downPos = chute.getPosition();
assertTrue(downPos < 1.5, "Should move back down");
assertTrue(downPos < upPos, "Down position should be less than up");
}
}

View File

@@ -1 +0,0 @@
# This file ensures the directory is tracked by git even when empty

View File

@@ -1,11 +0,0 @@
package robot;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class BasicTest {
@Test
void testBasic() {
assertTrue(true, "Basic test should pass");
}
}