diff --git a/robot/src/main/kotlin/dev/nextftc/robot/Exceptions.kt b/robot/src/main/kotlin/dev/nextftc/robot/Exceptions.kt new file mode 100644 index 0000000..fe3fa27 --- /dev/null +++ b/robot/src/main/kotlin/dev/nextftc/robot/Exceptions.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 NextFTC Team + * + * Use of this source code is governed by an BSD-3-clause + * license that can be found in the LICENSE.md file at the root of this repository or at + * https://opensource.org/license/bsd-3-clause. + */ + +package dev.nextftc.robot + +/** + * Base class for all exceptions thrown by NextFTC. + */ +open class NextFTCException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) + +/** + * Thrown when the [NextRobot] implementation in the user's project could not be found, + * instantiated, or unambiguously resolved by [RobotScanner]. + */ +class RobotScanException(message: String, cause: Throwable? = null) : NextFTCException(message, cause) diff --git a/robot/src/main/kotlin/dev/nextftc/robot/RobotLog.kt b/robot/src/main/kotlin/dev/nextftc/robot/RobotLog.kt new file mode 100644 index 0000000..17d27ef --- /dev/null +++ b/robot/src/main/kotlin/dev/nextftc/robot/RobotLog.kt @@ -0,0 +1,83 @@ +package dev.nextftc.robot + +import dev.frozenmilk.sinister.util.log.Logger +import com.qualcomm.robotcore.util.RobotLog as SDKRobotLog + +/** + * Logs to the robot log under the `NextFTC` tag. + * + * Use [Global] for messages that should also surface on the Driver Station. + */ +internal object RobotLog { + /** Logs [message] at debug level. */ + fun debug(message: String) { + Logger.d("NextFTC", message) + } + + /** Logs [message] at info level. */ + fun info(message: String) { + Logger.i("NextFTC", message) + } + + /** Logs [message] at warn level. */ + fun warn(message: String) { + Logger.w("NextFTC", message) + } + + /** Logs [message] at warn level, along with the stack trace of [throwable]. */ + fun warn(message: String, throwable: Throwable) { + Logger.w("NextFTC", message, throwable) + } + + /** Logs [message] at error level. */ + fun error(message: String) { + Logger.e("NextFTC", message) + } + + /** Logs [message] at error level, along with the stack trace of [throwable]. */ + fun error(message: String, throwable: Throwable) { + Logger.e("NextFTC", message, throwable) + } + + /** + * Messages shown to the driver on the Driver Station, prefixed with `NextFTC:`. + */ + object Global { + /** Replaces the global error message with [message]. */ + fun setError(message: String) { + this@RobotLog.error(message) + SDKRobotLog.setGlobalErrorMsg("NextFTC: $message") + } + + /** + * Replaces the global error message with [message], logging the stack trace of [throwable] + * alongside it. + */ + fun setError(message: String, throwable: Throwable) { + this@RobotLog.error(message, throwable) + SDKRobotLog.setGlobalErrorMsg("NextFTC: $message") + } + + /** + * Replaces the global error message with [message] and throws [exception] (defaulting to a [NextFTCException] with [message]). + */ + fun setErrorAndThrow( + message: String, + exception: NextFTCException = NextFTCException(message), + ): Nothing { + setError(message) + throw exception + } + + /** Clears the global error message, if any. */ + fun clearError() { + SDKRobotLog.clearGlobalErrorMsg() + } + + /** Adds [message] to the list of global warnings. */ + fun addWarning(message: String) { + warn(message) + SDKRobotLog.addGlobalWarningMessage("NextFTC: $message") + } + } +} diff --git a/robot/src/main/kotlin/dev/nextftc/robot/RobotScanner.kt b/robot/src/main/kotlin/dev/nextftc/robot/RobotScanner.kt index 361dff2..6b1e376 100644 --- a/robot/src/main/kotlin/dev/nextftc/robot/RobotScanner.kt +++ b/robot/src/main/kotlin/dev/nextftc/robot/RobotScanner.kt @@ -11,18 +11,17 @@ package dev.nextftc.robot import android.content.Context import com.qualcomm.ftccommon.FtcEventLoop import com.qualcomm.robotcore.eventloop.opmode.Disabled -import com.qualcomm.robotcore.util.RobotLog import dev.frozenmilk.sinister.Scanner import dev.frozenmilk.sinister.sdk.apphooks.OnCreateEventLoop import dev.frozenmilk.sinister.sdk.apphooks.OnCreateEventLoopScanner import dev.frozenmilk.sinister.targeting.SearchTarget import dev.frozenmilk.sinister.targeting.WideSearch -import dev.frozenmilk.sinister.util.log.Logger import dev.frozenmilk.util.graph.Graph import dev.frozenmilk.util.graph.rule.AdjacencyRule import dev.frozenmilk.util.graph.rule.dependsOn import java.lang.reflect.Modifier import kotlin.reflect.KClass +import kotlin.reflect.KVisibility import kotlin.reflect.full.hasAnnotation /** @@ -31,13 +30,9 @@ import kotlin.reflect.full.hasAnnotation * [dev.nextftc.robot.opmode.NextFTCOpModeScanner] can properly inject the robot instance into OpModes. */ internal object RobotScanner : Scanner { - internal var robotClass: KClass? = null + internal var allRobotClasses: MutableList> = mutableListOf() private var robotConstructor: (() -> NextRobot)? = null private var robotLoader: ClassLoader? = null - internal var robotInstance: NextRobot? = null - - var foundRobot = false - var foundMultiple = false override val loadAdjacencyRule: AdjacencyRule> = Scanner.INDEPENDENT and dependsOn(OnCreateEventLoopScanner) @@ -54,78 +49,86 @@ internal object RobotScanner : Scanner { val kcls = cls.kotlin if (kcls.hasAnnotation()) { - Logger.i("NextFTC", "Skipping disabled NextFTC robot class: $kcls") - RobotLog.setGlobalErrorMsg("Skipping disabled NextFTC robot class: $kcls") + RobotLog.info("Skipping disabled NextFTC robot class: $kcls") return } - Logger.i("NextFTC", "Found NextFTC robot class: $kcls") - RobotLog.setGlobalErrorMsg("Found NextFTC robot class: $kcls") + RobotLog.info("Found NextFTC robot class: $kcls") + allRobotClasses.add(cls.asSubclass(NextRobot::class.java).kotlin) + // Recorded for every candidate, not just usable ones, so that [beforeUnload] always clears the + // state this scan added. + robotLoader = loader val objectInstance = kcls.objectInstance if (objectInstance != null) { robotConstructor = { objectInstance as NextRobot } - robotClass = cls.asSubclass(NextRobot::class.java).kotlin - robotLoader = loader - - if (foundRobot) { - foundMultiple = true - } - foundRobot = true return } - val constructor = kcls.constructors.find { it.parameters.isEmpty() } + val constructor = kcls.constructors.find { + it.parameters.isEmpty() && it.visibility == KVisibility.PUBLIC + } if (constructor != null) { robotConstructor = { constructor.call() as NextRobot } - robotClass = cls.asSubclass(NextRobot::class.java).kotlin - robotLoader = loader - - if (foundRobot) { - foundMultiple = true - } - foundRobot = true return } - Logger.w( - "NextFTC", - buildString { - append("Unable to find appropriate constructor for $cls. ") - append("Ensure it is either a singleton object or has a public no-argument constructor.") - }, - ) - RobotLog.setGlobalErrorMsg( - "Unable to find appropriate constructor for $cls. " + - "Ensure it is either a singleton object or has a public no-argument constructor.", - ) + val message = "Unable to find appropriate constructor for $cls. " + + "Ensure it is either a singleton object or has a public no-argument constructor." + RobotLog.Global.addWarning(message) } override fun afterScan(loader: ClassLoader) { - check(!foundMultiple) { - "Found multiple NextFTC robot classes. Please ensure that there is only one in your project." + if (allRobotClasses.isEmpty()) { + RobotLog.Global.setError( + "No NextFTC robot class found. Ensure you have a class that implements NextRobot.", + ) + return } - check(foundRobot) { - "Unable to find a NextFTC robot class. Please ensure that there is one in your project " + - "(a class or object implementing NextRobot with a public no-argument constructor)." + + if (allRobotClasses.size > 1) { + RobotLog.Global.addWarning( + "Multiple NextFTC robot classes found: $allRobotClasses. " + + "Ensure you have only one class that implements NextRobot.", + ) + return } - Logger.i("NextFTC", "Found NextFTC robot class: $robotClass") - RobotLog.setGlobalErrorMsg("Found NextFTC robot class: $robotClass") + val robotClass = allRobotClasses[0] + val constructor = robotConstructor - check(robotConstructor != null) { "Robot constructor is null after scan" } - robotInstance = robotConstructor?.invoke() + if (constructor == null) { + RobotLog.Global.setError( + "No usable constructor was found for $robotClass, so it could not be created. " + + "Ensure it is either a singleton object or has a public no-argument constructor.", + ) + return + } + + RobotLog.info("Using NextFTC robot class: $robotClass") + RobotState.robotClass = robotClass + + RobotState.robotInstance = try { + constructor() + } catch (throwable: Throwable) { + RobotLog.Global.setError( + "Failed to create an instance of $robotClass. " + + "Check its constructor and property initializers for code that throws.", + throwable, + ) + RobotState.robotClass = null + return + } } override fun beforeUnload(loader: ClassLoader) { if (loader == robotLoader) { - foundRobot = false - foundMultiple = false - robotClass = null robotConstructor = null robotLoader = null - robotInstance = null + allRobotClasses.clear() + RobotState.robotClass = null + RobotState.robotInstance = null } } @@ -137,8 +140,13 @@ internal object RobotScanner : Scanner { * The robot instance is created using the constructor found by [RobotScanner]. */ object RobotState : OnCreateEventLoop { - val robot get() = RobotScanner.robotInstance ?: error("Robot instance not initialized") - internal val robotClass get() = RobotScanner.robotClass ?: error("Robot class not initialized") + internal var robotInstance: NextRobot? = null + internal var robotClass: KClass? = null + val robot: NextRobot + get() = robotInstance ?: RobotLog.Global.setErrorAndThrow( + "Robot instance not initialized", + RobotScanException("Robot instance not initialized"), + ) override fun onCreateEventLoop(context: Context, ftcEventLoop: FtcEventLoop) { ftcEventLoop.opModeManager.registerListener(DriverStationTelemetry) diff --git a/robot/src/main/kotlin/dev/nextftc/robot/opmode/NextFTCOpModeScanner.kt b/robot/src/main/kotlin/dev/nextftc/robot/opmode/NextFTCOpModeScanner.kt index cda9c32..d31f661 100644 --- a/robot/src/main/kotlin/dev/nextftc/robot/opmode/NextFTCOpModeScanner.kt +++ b/robot/src/main/kotlin/dev/nextftc/robot/opmode/NextFTCOpModeScanner.kt @@ -5,13 +5,14 @@ import dev.frozenmilk.sinister.sdk.opmodes.AnnotatedOpModeScanner import dev.frozenmilk.sinister.sdk.opmodes.OpModeScanner import dev.frozenmilk.sinister.targeting.SearchTarget import dev.frozenmilk.sinister.targeting.WideSearch -import dev.frozenmilk.sinister.util.log.Logger import dev.frozenmilk.util.graph.rule.dependsOn +import dev.nextftc.robot.RobotLog import dev.nextftc.robot.RobotScanner import dev.nextftc.robot.RobotState import org.firstinspires.ftc.robotcore.internal.opmode.OpModeMeta import java.lang.reflect.Modifier import kotlin.reflect.KClass +import kotlin.reflect.KVisibility import kotlin.reflect.full.findAnnotation import kotlin.reflect.full.hasAnnotation import kotlin.reflect.full.isSuperclassOf @@ -40,7 +41,7 @@ object NextFTCOpModeScanner : OpModeScanner() { val kcls = cls.kotlin as KClass if (kcls.hasAnnotation()) { - Logger.i("NextFTC", "Skipping disabled NextFTC OpMode class: $kcls") + RobotLog.info("Skipping disabled NextFTC OpMode class: $kcls") return } @@ -48,18 +49,22 @@ object NextFTCOpModeScanner : OpModeScanner() { is OpModeMetaCheckResult.FoundAnnotation -> { when (val constructorResult = opModeConstructorFromClass(kcls)) { is OpModeConstructorCheckResult.FoundConstructor -> { - Logger.i("NextFTC", "Found NextFTC OpMode class: $cls") + RobotLog.info("Found NextFTC OpMode class: $cls") registrationHelper.register(metaResult.meta) { BoundNextOpMode(constructorResult.constructor) } } is OpModeConstructorCheckResult.NoConstructorFound -> { - Logger.w("NextFTC", "No valid constructor found for NextFTC OpMode class: $cls") + RobotLog.Global.addWarning( + "No valid constructor found for NextFTC OpMode class $cls, so it was not " + + "registered. Ensure it has a public constructor that takes either no arguments " + + "or your NextRobot type.", + ) } } } is OpModeMetaCheckResult.NoAnnotationPresent -> { - Logger.w( - "NextFTC", - "No @NextAutonomous or @NextTeleop annotation found for NextFTC OpMode class: $cls", + RobotLog.Global.addWarning( + "No @NextAutonomous, @NextTeleop, or @NextUtility annotation found for NextFTC OpMode " + + "class $cls, so it was not registered.", ) } } @@ -78,12 +83,19 @@ sealed interface OpModeConstructorCheckResult { data class NoConstructorFound(val opModeName: String) : OpModeConstructorCheckResult } +/** + * The name an OpMode or robot class is displayed under when no name is configured. [KClass.simpleName] + * is null for classes that have no source-level name, so fall back to the binary name. + */ +internal val KClass<*>.displayName: String + get() = simpleName ?: java.name + internal fun opModeMetaFromClass(cls: KClass<*>): OpModeMetaCheckResult { val autonomous = cls.findAnnotation() if (autonomous != null) { return OpModeMetaCheckResult.FoundAnnotation( OpModeMeta.Builder().setFlavor(OpModeMeta.Flavor.AUTONOMOUS) - .setName(autonomous.name.ifEmpty { cls.simpleName!! }) + .setName(autonomous.name.ifEmpty { cls.displayName }) .setGroup(autonomous.group.ifEmpty { "NextFTC Auto" }) .setTransitionTarget(autonomous.preselectTeleop) .setSource(OpModeMeta.Source.ANDROID_STUDIO) @@ -95,7 +107,7 @@ internal fun opModeMetaFromClass(cls: KClass<*>): OpModeMetaCheckResult { if (teleop != null) { return OpModeMetaCheckResult.FoundAnnotation( OpModeMeta.Builder().setFlavor(OpModeMeta.Flavor.TELEOP) - .setName(teleop.name.ifEmpty { cls.simpleName!! }) + .setName(teleop.name.ifEmpty { cls.displayName }) .setGroup(teleop.group.ifEmpty { "NextFTC Teleop" }) .setSource(OpModeMeta.Source.ANDROID_STUDIO) .build(), @@ -106,31 +118,36 @@ internal fun opModeMetaFromClass(cls: KClass<*>): OpModeMetaCheckResult { if (utility != null) { return OpModeMetaCheckResult.FoundAnnotation( OpModeMeta.Builder().setFlavor(OpModeMeta.Flavor.UTILITY) - .setName(utility.name.ifEmpty { cls.simpleName!! }) + .setName(utility.name.ifEmpty { cls.displayName }) .setDescription(utility.description.ifEmpty { null }) .setSource(OpModeMeta.Source.ANDROID_STUDIO) .build(), ) } - return OpModeMetaCheckResult.NoAnnotationPresent(cls.simpleName!!) + return OpModeMetaCheckResult.NoAnnotationPresent(cls.displayName) } internal fun opModeConstructorFromClass(cls: KClass): OpModeConstructorCheckResult { - if (RobotScanner.foundRobot) { - val constructor = cls.constructors.find { it.parameters.size == 1 } - if (constructor != null) { - val paramType = constructor.parameters[0].type.classifier as KClass<*> - if (paramType.isSuperclassOf(RobotState.robotClass)) { - return OpModeConstructorCheckResult.FoundConstructor { constructor.call(RobotState.robot) } - } + val robotClass = RobotState.robotClass + + if (robotClass != null) { + val oneArg = cls.constructors.find { + it.parameters.size == 1 && it.visibility == KVisibility.PUBLIC + } + val paramType = oneArg?.parameters?.single()?.type?.classifier as? KClass<*> + if (oneArg != null && paramType != null && paramType.isSuperclassOf(robotClass)) { + return OpModeConstructorCheckResult.FoundConstructor { oneArg.call(RobotState.robot) } } } - val constructor = cls.constructors.find { it.parameters.isEmpty() } - if (constructor != null) { - return OpModeConstructorCheckResult.FoundConstructor { constructor.call() } + val noArg = cls.constructors.find { + it.parameters.isEmpty() && it.visibility == KVisibility.PUBLIC + } + + if (noArg != null) { + return OpModeConstructorCheckResult.FoundConstructor { noArg.call() } } - return OpModeConstructorCheckResult.NoConstructorFound(cls.simpleName!!) + return OpModeConstructorCheckResult.NoConstructorFound(cls.displayName) } diff --git a/robot/src/main/kotlin/dev/nextftc/robot/opmode/NextOpMode.kt b/robot/src/main/kotlin/dev/nextftc/robot/opmode/NextOpMode.kt index 9c37c30..77d7cc9 100644 --- a/robot/src/main/kotlin/dev/nextftc/robot/opmode/NextOpMode.kt +++ b/robot/src/main/kotlin/dev/nextftc/robot/opmode/NextOpMode.kt @@ -3,7 +3,9 @@ package dev.nextftc.robot.opmode import com.qualcomm.robotcore.eventloop.opmode.LinearOpMode import com.qualcomm.robotcore.hardware.Gamepad import com.qualcomm.robotcore.hardware.HardwareMap +import dev.nextftc.robot.NextFTCException import dev.nextftc.robot.NextRobot +import dev.nextftc.robot.RobotLog import org.firstinspires.ftc.robotcore.external.Telemetry as SdkTelemetry /** @@ -29,16 +31,16 @@ abstract class NextOpMode internal constructor(internal val hooks: MutableList requireActive(value: T?, name: String): T = value ?: throw NextFTCException( + "Cannot access $name because this OpMode was not started by NextFTC. NextFTC OpModes are " + + "constructed automatically when the OpMode is run; they cannot be instantiated directly.", + ) } } @@ -70,8 +77,10 @@ internal class BoundNextOpMode(val opModeConstructor: () -> NextOpMode) : Linear NextOpMode.activeTelemetry = this.telemetry NextOpMode.activeHardwareMap = this.hardwareMap + var opMode: NextOpMode? = null + try { - val opMode = opModeConstructor() + opMode = opModeConstructor() opMode.hooks.forEach(OpModeHook::afterConstruction) while (opModeInInit()) { @@ -90,8 +99,17 @@ internal class BoundNextOpMode(val opModeConstructor: () -> NextOpMode) : Linear } opMode.hooks.forEach(OpModeHook::beforeEnd) opMode.end() - opMode.hooks.forEach(OpModeHook::afterEnd) } finally { + // even if the OpMode didn't finish execution we still want to clean up the hooks + // and clear the static references to the SDK objects + opMode?.hooks?.forEach { hook -> + try { + hook.afterEnd() + } catch (throwable: Throwable) { + RobotLog.warn("${hook::class.displayName} threw while cleaning up after the OpMode.", throwable) + } + } + NextOpMode.activeGamepad1 = null NextOpMode.activeGamepad2 = null NextOpMode.activeTelemetry = null diff --git a/robot/src/main/kotlin/dev/nextftc/robot/opmode/OpModeHook.kt b/robot/src/main/kotlin/dev/nextftc/robot/opmode/OpModeHook.kt index 55f91cc..ed47108 100644 --- a/robot/src/main/kotlin/dev/nextftc/robot/opmode/OpModeHook.kt +++ b/robot/src/main/kotlin/dev/nextftc/robot/opmode/OpModeHook.kt @@ -115,11 +115,12 @@ internal object MotorHook : OpModeHook { * ensure fresh hardware data per loop while minimizing I/O overhead. */ object BulkReadHook : OpModeHook { - private val lynxHubs: List by lazy { - RobotController.hardwareMap.getAll(LynxModule::class.java) - } + // Resolved per OpMode rather than lazily once: holding LynxModule instances past the end of an + // OpMode leaves them stale across reconfigurations and Sloth reloads. + private var lynxHubs: List = emptyList() override fun beforeStart() { + lynxHubs = RobotController.hardwareMap.getAll(LynxModule::class.java) lynxHubs.forEach { it.bulkCachingMode = LynxModule.BulkCachingMode.MANUAL } } @@ -127,6 +128,10 @@ object BulkReadHook : OpModeHook { override fun afterPeriodic() = clearBulkReadCache() + override fun afterEnd() { + lynxHubs = emptyList() + } + private fun clearBulkReadCache() { lynxHubs.forEach { it.clearBulkCache() } }