From aacf4359ba4fa568b542bdfb04c69f444226156d Mon Sep 17 00:00:00 2001 From: Zach Harel Date: Tue, 10 Nov 2026 13:23:14 -0500 Subject: [PATCH 1/3] separate event loops, robot log, and structs to util module Signed-off-by: Zach Harel --- README.md | 1 + build.gradle.kts | 1 + control/build.gradle.kts | 1 + .../dev/nextftc/hardware/RobotController.kt | 4 +- .../dev/nextftc/hardware/RobotLogSink.kt | 19 +++++ .../nextftc/hardware/actuators/NextMotor.kt | 2 +- .../nextftc/hardware/lynx/NextLynxModule.kt | 7 +- .../sensors/NextColorDistanceSensor.kt | 2 +- .../hardware/sensors/NextDistanceSensor.kt | 2 +- .../kotlin/dev/nextftc/robot/GlobalLog.kt | 47 +++++++++++ .../main/kotlin/dev/nextftc/robot/RobotLog.kt | 83 ------------------- .../kotlin/dev/nextftc/robot/RobotScanner.kt | 24 ++++-- .../robot/opmode/NextFTCOpModeScanner.kt | 11 +-- .../dev/nextftc/robot/opmode/NextOpMode.kt | 4 +- .../nextftc/robot/triggers/CommandGamepad.kt | 2 +- .../nextftc/robot/triggers/RangeTrigger.kt | 2 +- .../dev/nextftc/robot/triggers/Trigger.kt | 2 +- settings.gradle.kts | 1 + .../telemetry/ftc/DriverStationBackend.kt | 2 +- telemetry/Module.md | 12 ++- telemetry/build.gradle.kts | 1 + .../kotlin/dev/nextftc/telemetry/Telemetry.kt | 2 +- .../dev/nextftc/telemetry/TelemetryBackend.kt | 2 +- .../nextftc/telemetry/TelemetryRegistry.kt | 4 +- .../dev/nextftc/telemetry/TelemetryTable.kt | 4 +- .../dev/nextftc/telemetry/TelemetryType.kt | 2 +- .../telemetry/backend/MockTelemetryBackend.kt | 2 +- .../dev/nextftc/telemetry/nt/NT4Backend.kt | 2 +- .../dev/nextftc/telemetry/nt/NT4Server.kt | 2 +- .../nextftc/telemetry/wpilog/WPILogBackend.kt | 2 +- .../nextftc/telemetry/wpilog/WPILogWriter.kt | 2 +- .../nextftc/telemetry/sim/LiveSimulator.kt | 2 +- .../nextftc/telemetry/TelemetryTableTest.kt | 4 +- .../telemetry/backend/NT4BackendTest.kt | 2 +- .../telemetry/backend/WPILogBackendTest.kt | 2 +- .../dev/nextftc/telemetry/nt/NT4ServerTest.kt | 2 +- .../struct/StructSchemaRegistryTest.kt | 9 +- .../telemetry/wpilog/WPILogWriterTest.kt | 5 +- util/Module.md | 18 ++++ util/build.gradle.kts | 29 +++++++ .../kotlin/dev/nextftc}/util/EventLoop.kt | 2 +- util/src/main/kotlin/dev/nextftc/util/Log.kt | 50 +++++++++++ .../nextftc/util}/struct/StructAnnotations.kt | 2 +- .../dev/nextftc/util}/struct/StructSchema.kt | 2 +- .../util}/struct/StructSchemaRegistry.kt | 2 +- .../nextftc/util}/javatypes/JavaTypes.java | 10 +-- .../util}/struct/JavaStructSchemaTest.kt | 4 +- .../nextftc/util}/struct/StructSchemaTest.kt | 2 +- 48 files changed, 250 insertions(+), 151 deletions(-) create mode 100644 hardware/src/main/kotlin/dev/nextftc/hardware/RobotLogSink.kt create mode 100644 robot/src/main/kotlin/dev/nextftc/robot/GlobalLog.kt delete mode 100644 robot/src/main/kotlin/dev/nextftc/robot/RobotLog.kt create mode 100644 util/Module.md create mode 100644 util/build.gradle.kts rename {hardware/src/main/kotlin/dev/nextftc/hardware => util/src/main/kotlin/dev/nextftc}/util/EventLoop.kt (95%) create mode 100644 util/src/main/kotlin/dev/nextftc/util/Log.kt rename {telemetry/src/main/kotlin/dev/nextftc/telemetry => util/src/main/kotlin/dev/nextftc/util}/struct/StructAnnotations.kt (98%) rename {telemetry/src/main/kotlin/dev/nextftc/telemetry => util/src/main/kotlin/dev/nextftc/util}/struct/StructSchema.kt (99%) rename {telemetry/src/main/kotlin/dev/nextftc/telemetry => util/src/main/kotlin/dev/nextftc/util}/struct/StructSchemaRegistry.kt (99%) rename {telemetry/src/test/java/dev/nextftc/telemetry => util/src/test/java/dev/nextftc/util}/javatypes/JavaTypes.java (95%) rename {telemetry/src/test/kotlin/dev/nextftc/telemetry => util/src/test/kotlin/dev/nextftc/util}/struct/JavaStructSchemaTest.kt (98%) rename {telemetry/src/test/kotlin/dev/nextftc/telemetry => util/src/test/kotlin/dev/nextftc/util}/struct/StructSchemaTest.kt (99%) diff --git a/README.md b/README.md index ce82a25b..1791401f 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ to [kdoc.nextftc.dev](https://kdoc.nextftc.dev/). | Module | Description | |----------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------| +| [`util`](util/Module.md) | Pure-JVM pieces shared by every module: WPILib struct schemas, an `EventLoop`, and a `Log` facade. | | [`units`](units/Module.md) | Type-safe physical units (distance, time, mass, force, voltage, angle, etc.) modeled after WPILib's units library. | | [`linalg`](linalg/Module.md) | A Kotlin wrapper around EJML with `Nat` phantom types, so matrix/vector dimension mismatches are caught at compile time. | | [`control`](control/Module.md) | Feedback controllers (PID, LQR), feedforward models, signal filters, motion profiling, and typed 2D geometry (`Pose2d`, `Rotation2d`, etc.). | diff --git a/build.gradle.kts b/build.gradle.kts index 386f038d..d3281461 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,6 +7,7 @@ plugins { } dependencies { + dokka(project(":util")) dokka(project(":units")) dokka(project(":linalg")) dokka(project(":control")) diff --git a/control/build.gradle.kts b/control/build.gradle.kts index 4fa0535c..25a20fd7 100644 --- a/control/build.gradle.kts +++ b/control/build.gradle.kts @@ -5,6 +5,7 @@ plugins { description = "A WPIMath inspired library for controls and other math classes and functions." dependencies { + api(project(":util")) api(project(":units")) api(project(":linalg")) diff --git a/hardware/src/main/kotlin/dev/nextftc/hardware/RobotController.kt b/hardware/src/main/kotlin/dev/nextftc/hardware/RobotController.kt index 08d6b7ce..5f920e39 100644 --- a/hardware/src/main/kotlin/dev/nextftc/hardware/RobotController.kt +++ b/hardware/src/main/kotlin/dev/nextftc/hardware/RobotController.kt @@ -18,12 +18,13 @@ import com.qualcomm.robotcore.hardware.HardwareMap import com.qualcomm.robotcore.hardware.configuration.LynxConstants import dev.frozenmilk.sinister.sdk.apphooks.OnCreateEventLoop import dev.nextftc.hardware.lynx.NextLynxModule -import dev.nextftc.hardware.util.EventLoop import dev.nextftc.hardware.util.LazyHardware import dev.nextftc.units.celsius import dev.nextftc.units.measuretypes.Temperature import dev.nextftc.units.measuretypes.Voltage import dev.nextftc.units.volts +import dev.nextftc.util.EventLoop +import dev.nextftc.util.Log import org.firstinspires.ftc.robotcore.external.navigation.TempUnit import org.firstinspires.ftc.robotcore.external.navigation.VoltageUnit import java.util.Collections @@ -152,6 +153,7 @@ object RobotController : OnCreateEventLoop, OpModeManagerNotifier.Notifications * @param ftcEventLoop Active FTC event loop. */ override fun onCreateEventLoop(context: Context, ftcEventLoop: FtcEventLoop) { + Log.sink = RobotLogSink appContext = context eventLoop = ftcEventLoop ftcEventLoop.opModeManager.registerListener(this) diff --git a/hardware/src/main/kotlin/dev/nextftc/hardware/RobotLogSink.kt b/hardware/src/main/kotlin/dev/nextftc/hardware/RobotLogSink.kt new file mode 100644 index 00000000..15eac1a5 --- /dev/null +++ b/hardware/src/main/kotlin/dev/nextftc/hardware/RobotLogSink.kt @@ -0,0 +1,19 @@ +package dev.nextftc.hardware + +import dev.frozenmilk.sinister.util.log.Logger +import dev.nextftc.util.LogLevel +import dev.nextftc.util.LogSink + +/** Sends [dev.nextftc.util.Log] messages to the robot log under the `NextFTC` tag. */ +object RobotLogSink : LogSink { + private const val TAG = "NextFTC" + + override fun log(level: LogLevel, message: String, throwable: Throwable?) { + when (level) { + LogLevel.DEBUG -> Logger.d(TAG, message) + LogLevel.INFO -> Logger.i(TAG, message) + LogLevel.WARN -> if (throwable != null) Logger.w(TAG, message, throwable) else Logger.w(TAG, message) + LogLevel.ERROR -> if (throwable != null) Logger.e(TAG, message, throwable) else Logger.e(TAG, message) + } + } +} diff --git a/hardware/src/main/kotlin/dev/nextftc/hardware/actuators/NextMotor.kt b/hardware/src/main/kotlin/dev/nextftc/hardware/actuators/NextMotor.kt index a8f2cf3c..ea2cc63f 100644 --- a/hardware/src/main/kotlin/dev/nextftc/hardware/actuators/NextMotor.kt +++ b/hardware/src/main/kotlin/dev/nextftc/hardware/actuators/NextMotor.kt @@ -23,7 +23,7 @@ import dev.nextftc.hardware.RobotController import dev.nextftc.hardware.lynx.NextLynxModule import dev.nextftc.hardware.util.AnalogFeedback import dev.nextftc.hardware.util.Caching -import dev.nextftc.hardware.util.EventLoop +import dev.nextftc.util.EventLoop import dev.nextftc.hardware.util.LazyHardware import dev.nextftc.units.amperes import dev.nextftc.units.measuretypes.Angle diff --git a/hardware/src/main/kotlin/dev/nextftc/hardware/lynx/NextLynxModule.kt b/hardware/src/main/kotlin/dev/nextftc/hardware/lynx/NextLynxModule.kt index 79d7d7e0..8f2afdc8 100644 --- a/hardware/src/main/kotlin/dev/nextftc/hardware/lynx/NextLynxModule.kt +++ b/hardware/src/main/kotlin/dev/nextftc/hardware/lynx/NextLynxModule.kt @@ -16,13 +16,13 @@ import com.qualcomm.robotcore.hardware.DigitalChannel import com.qualcomm.robotcore.hardware.DigitalChannelImpl import com.qualcomm.robotcore.hardware.ServoImplEx import com.qualcomm.robotcore.hardware.configuration.typecontainers.ServoConfigurationType -import com.qualcomm.robotcore.util.RobotLog import dev.nextftc.hardware.RobotController import dev.nextftc.hardware.util.LazyHardware import dev.nextftc.units.celsius import dev.nextftc.units.measuretypes.Temperature import dev.nextftc.units.measuretypes.Voltage import dev.nextftc.units.volts +import dev.nextftc.util.Log import org.firstinspires.ftc.robotcore.external.navigation.TempUnit import org.firstinspires.ftc.robotcore.external.navigation.VoltageUnit @@ -60,10 +60,9 @@ class NextLynxModule internal constructor(initializer: () -> LynxModule, @JvmFie val key = kind to port val first = usedPorts[key] if (first != null) { - RobotLog.ww( - "NextLynxModule", - first, + Log.warn( "Port conflict on $type: ${kind.name.lowercase()} port $port is used by more than one device", + first, ) return } diff --git a/hardware/src/main/kotlin/dev/nextftc/hardware/sensors/NextColorDistanceSensor.kt b/hardware/src/main/kotlin/dev/nextftc/hardware/sensors/NextColorDistanceSensor.kt index 73e73af7..bb9ed706 100644 --- a/hardware/src/main/kotlin/dev/nextftc/hardware/sensors/NextColorDistanceSensor.kt +++ b/hardware/src/main/kotlin/dev/nextftc/hardware/sensors/NextColorDistanceSensor.kt @@ -16,7 +16,7 @@ import dev.nextftc.hardware.RobotController import dev.nextftc.hardware.lynx.NextLynxModule import dev.nextftc.hardware.sensors.colors.ColorProfile import dev.nextftc.hardware.sensors.colors.NextColor -import dev.nextftc.hardware.util.EventLoop +import dev.nextftc.util.EventLoop import dev.nextftc.hardware.util.LazyHardware import org.firstinspires.ftc.robotcore.external.navigation.DistanceUnit diff --git a/hardware/src/main/kotlin/dev/nextftc/hardware/sensors/NextDistanceSensor.kt b/hardware/src/main/kotlin/dev/nextftc/hardware/sensors/NextDistanceSensor.kt index 4fb0b320..2821f350 100644 --- a/hardware/src/main/kotlin/dev/nextftc/hardware/sensors/NextDistanceSensor.kt +++ b/hardware/src/main/kotlin/dev/nextftc/hardware/sensors/NextDistanceSensor.kt @@ -12,7 +12,7 @@ import com.qualcomm.hardware.lynx.LynxI2cColorRangeSensor import com.qualcomm.robotcore.hardware.DistanceSensor import dev.nextftc.hardware.RobotController import dev.nextftc.hardware.lynx.NextLynxModule -import dev.nextftc.hardware.util.EventLoop +import dev.nextftc.util.EventLoop import dev.nextftc.hardware.util.LazyHardware import org.firstinspires.ftc.robotcore.external.navigation.DistanceUnit diff --git a/robot/src/main/kotlin/dev/nextftc/robot/GlobalLog.kt b/robot/src/main/kotlin/dev/nextftc/robot/GlobalLog.kt new file mode 100644 index 00000000..0e153e5e --- /dev/null +++ b/robot/src/main/kotlin/dev/nextftc/robot/GlobalLog.kt @@ -0,0 +1,47 @@ +package dev.nextftc.robot + +import dev.nextftc.util.Log +import com.qualcomm.robotcore.util.RobotLog as SDKRobotLog + +/** + * Messages shown to the driver on the Driver Station, prefixed with `NextFTC:`. Each is also written + * to the robot log through [Log]. + */ +internal object GlobalLog { + /** Replaces the global error message with [message]. */ + fun setError(message: String) { + Log.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) { + Log.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) { + Log.warn(message) + SDKRobotLog.addGlobalWarningMessage("NextFTC: $message") + } +} diff --git a/robot/src/main/kotlin/dev/nextftc/robot/RobotLog.kt b/robot/src/main/kotlin/dev/nextftc/robot/RobotLog.kt deleted file mode 100644 index 17d27ef9..00000000 --- a/robot/src/main/kotlin/dev/nextftc/robot/RobotLog.kt +++ /dev/null @@ -1,83 +0,0 @@ -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 40ba3635..83fa628d 100644 --- a/robot/src/main/kotlin/dev/nextftc/robot/RobotScanner.kt +++ b/robot/src/main/kotlin/dev/nextftc/robot/RobotScanner.kt @@ -17,6 +17,8 @@ import dev.frozenmilk.sinister.targeting.TeamCodeSearch import dev.frozenmilk.util.graph.Graph import dev.frozenmilk.util.graph.rule.AdjacencyRule import dev.frozenmilk.util.graph.rule.dependsOn +import dev.nextftc.hardware.RobotLogSink +import dev.nextftc.util.Log import java.lang.reflect.Modifier import kotlin.reflect.KClass import kotlin.reflect.KVisibility @@ -39,6 +41,10 @@ internal object RobotScanner : Scanner { override val targets: SearchTarget = TeamCodeSearch() + init { + Log.sink = RobotLogSink + } + override fun scan(loader: ClassLoader, cls: Class<*>) { val modifiers = cls.modifiers if (!Modifier.isPublic(modifiers) || Modifier.isAbstract(modifiers)) return @@ -48,11 +54,11 @@ internal object RobotScanner : Scanner { val kcls = cls.kotlin if (kcls.hasAnnotation()) { - RobotLog.info("Skipping disabled NextFTC robot class: $kcls") + Log.info("Skipping disabled NextFTC robot class: $kcls") return } - RobotLog.info("Found NextFTC robot class: $kcls") + Log.info("Found NextFTC robot class: $kcls") candidates.add(RobotCandidate(cls.asSubclass(NextRobot::class.java).kotlin, constructorFor(kcls))) } @@ -68,7 +74,7 @@ internal object RobotScanner : Scanner { } if (constructor != null) return { constructor.call() as NextRobot } - RobotLog.Global.addWarning( + GlobalLog.addWarning( "Unable to find appropriate constructor for $kcls. " + "Ensure it is either a singleton object or has a public no-argument constructor.", ) @@ -93,7 +99,7 @@ internal object RobotScanner : Scanner { } if (candidates.size > 1) { - RobotLog.Global.addWarning( + GlobalLog.addWarning( "Multiple NextFTC robot classes found: ${candidates.map { it.kClass }}. " + "Ensure you have only one class that implements NextRobot.", ) @@ -121,7 +127,7 @@ internal object RobotScanner : Scanner { return } - RobotLog.info("Using NextFTC robot class: $kClass") + Log.info("Using NextFTC robot class: $kClass") RobotState.robotClass = kClass RobotState.robotInstance = instance } @@ -137,9 +143,9 @@ internal object RobotScanner : Scanner { private fun reportError(message: String, throwable: Throwable? = null) { reportedError = true if (throwable == null) { - RobotLog.Global.setError(message) + GlobalLog.setError(message) } else { - RobotLog.Global.setError(message, throwable) + GlobalLog.setError(message, throwable) } } @@ -147,7 +153,7 @@ internal object RobotScanner : Scanner { private fun clearReportedError() { if (!reportedError) return reportedError = false - RobotLog.Global.clearError() + GlobalLog.clearError() } private data class RobotCandidate(val kClass: KClass, val constructor: (() -> NextRobot)?) @@ -161,7 +167,7 @@ object RobotState { internal var robotInstance: NextRobot? = null internal var robotClass: KClass? = null val robot: NextRobot - get() = robotInstance ?: RobotLog.Global.setErrorAndThrow( + get() = robotInstance ?: GlobalLog.setErrorAndThrow( "Robot instance not initialized", RobotScanException("Robot instance not initialized"), ) 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 4f9fdf6b..0df7e680 100644 --- a/robot/src/main/kotlin/dev/nextftc/robot/opmode/NextFTCOpModeScanner.kt +++ b/robot/src/main/kotlin/dev/nextftc/robot/opmode/NextFTCOpModeScanner.kt @@ -6,9 +6,10 @@ import dev.frozenmilk.sinister.sdk.opmodes.OpModeScanner import dev.frozenmilk.sinister.targeting.SearchTarget import dev.frozenmilk.sinister.targeting.WideSearch import dev.frozenmilk.util.graph.rule.dependsOn -import dev.nextftc.robot.RobotLog +import dev.nextftc.robot.GlobalLog import dev.nextftc.robot.RobotScanner import dev.nextftc.robot.RobotState +import dev.nextftc.util.Log import org.firstinspires.ftc.robotcore.internal.opmode.OpModeMeta import java.lang.reflect.Modifier import kotlin.reflect.KClass @@ -41,7 +42,7 @@ object NextFTCOpModeScanner : OpModeScanner() { val kcls = cls.kotlin as KClass if (kcls.hasAnnotation()) { - RobotLog.info("Skipping disabled NextFTC OpMode class: $kcls") + Log.info("Skipping disabled NextFTC OpMode class: $kcls") return } val enableBlaze = BlazeOpMode::class.java.isAssignableFrom(cls) @@ -50,13 +51,13 @@ object NextFTCOpModeScanner : OpModeScanner() { is OpModeMetaCheckResult.FoundAnnotation -> { when (val constructorResult = opModeConstructorFromClass(kcls)) { is OpModeConstructorCheckResult.FoundConstructor -> { - RobotLog.info("Found NextFTC OpMode class: $cls") + Log.info("Found NextFTC OpMode class: $cls") registrationHelper.register(metaResult.meta) { BoundNextOpMode(constructorResult.constructor, enableBlaze) } } is OpModeConstructorCheckResult.NoConstructorFound -> { - RobotLog.Global.addWarning( + GlobalLog.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.", @@ -65,7 +66,7 @@ object NextFTCOpModeScanner : OpModeScanner() { } } is OpModeMetaCheckResult.NoAnnotationPresent -> { - RobotLog.Global.addWarning( + GlobalLog.addWarning( "No @NextAutonomous, @NextTeleop, or @NextUtility annotation found for NextFTC OpMode " + "class $cls, so it was not registered.", ) 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 2740af68..f0f39383 100644 --- a/robot/src/main/kotlin/dev/nextftc/robot/opmode/NextOpMode.kt +++ b/robot/src/main/kotlin/dev/nextftc/robot/opmode/NextOpMode.kt @@ -6,7 +6,7 @@ import com.qualcomm.robotcore.hardware.HardwareMap import dev.nextftc.hardware.RobotController import dev.nextftc.robot.NextFTCException import dev.nextftc.robot.NextRobot -import dev.nextftc.robot.RobotLog +import dev.nextftc.util.Log import org.firstinspires.ftc.robotcore.external.Telemetry as SdkTelemetry /** @@ -131,7 +131,7 @@ internal class BoundNextOpMode(val opModeConstructor: () -> NextOpMode, val blaz try { hook.afterEnd() } catch (throwable: Throwable) { - RobotLog.warn("${hook::class.displayName} threw while cleaning up after the OpMode.", throwable) + Log.warn("${hook::class.displayName} threw while cleaning up after the OpMode.", throwable) } } diff --git a/robot/src/main/kotlin/dev/nextftc/robot/triggers/CommandGamepad.kt b/robot/src/main/kotlin/dev/nextftc/robot/triggers/CommandGamepad.kt index 3034fcd5..002414fa 100644 --- a/robot/src/main/kotlin/dev/nextftc/robot/triggers/CommandGamepad.kt +++ b/robot/src/main/kotlin/dev/nextftc/robot/triggers/CommandGamepad.kt @@ -1,7 +1,7 @@ package dev.nextftc.robot.triggers import com.qualcomm.robotcore.hardware.Gamepad -import dev.nextftc.hardware.util.EventLoop +import dev.nextftc.util.EventLoop /** * A wrapper around the standard FTC [Gamepad] that exposes its buttons as command-based [Trigger]s. diff --git a/robot/src/main/kotlin/dev/nextftc/robot/triggers/RangeTrigger.kt b/robot/src/main/kotlin/dev/nextftc/robot/triggers/RangeTrigger.kt index a3eb681c..d63b3cfc 100644 --- a/robot/src/main/kotlin/dev/nextftc/robot/triggers/RangeTrigger.kt +++ b/robot/src/main/kotlin/dev/nextftc/robot/triggers/RangeTrigger.kt @@ -1,6 +1,6 @@ package dev.nextftc.robot.triggers -import dev.nextftc.hardware.util.EventLoop +import dev.nextftc.util.EventLoop /** * A wrapper around an analog gamepad input (e.g. a trigger or joystick) that exposes diff --git a/robot/src/main/kotlin/dev/nextftc/robot/triggers/Trigger.kt b/robot/src/main/kotlin/dev/nextftc/robot/triggers/Trigger.kt index e8bde267..085b6ebf 100644 --- a/robot/src/main/kotlin/dev/nextftc/robot/triggers/Trigger.kt +++ b/robot/src/main/kotlin/dev/nextftc/robot/triggers/Trigger.kt @@ -16,7 +16,7 @@ import com.pedropathing.ivy.Command import com.pedropathing.ivy.Scheduler import dev.nextftc.control.filters.Debouncer import dev.nextftc.control.filters.EdgeCounterFilter -import dev.nextftc.hardware.util.EventLoop +import dev.nextftc.util.EventLoop /** * This class provides an easy way to link commands to conditions. diff --git a/settings.gradle.kts b/settings.gradle.kts index 5c63a4c4..077aef64 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -27,6 +27,7 @@ dependencyResolutionManagement { } rootProject.name = "NextFTC Suite" +include(":util") include(":units") include(":linalg") include(":control") diff --git a/telemetry-ftc/src/main/kotlin/dev/nextftc/telemetry/ftc/DriverStationBackend.kt b/telemetry-ftc/src/main/kotlin/dev/nextftc/telemetry/ftc/DriverStationBackend.kt index 62f1e67e..5d934a7c 100644 --- a/telemetry-ftc/src/main/kotlin/dev/nextftc/telemetry/ftc/DriverStationBackend.kt +++ b/telemetry-ftc/src/main/kotlin/dev/nextftc/telemetry/ftc/DriverStationBackend.kt @@ -11,7 +11,7 @@ package dev.nextftc.telemetry.ftc import dev.nextftc.telemetry.TelemetryBackend import dev.nextftc.telemetry.TelemetryEntry import dev.nextftc.telemetry.TelemetryType -import dev.nextftc.telemetry.struct.StructSchema +import dev.nextftc.util.struct.StructSchema import java.util.concurrent.ConcurrentHashMap import org.firstinspires.ftc.robotcore.external.Telemetry as SdkTelemetry diff --git a/telemetry/Module.md b/telemetry/Module.md index ad99593f..7e8fbaaa 100644 --- a/telemetry/Module.md +++ b/telemetry/Module.md @@ -2,7 +2,7 @@ Logging and live telemetry for FTC robot code, in formats AdvantageScope understands. It has no FTC SDK or Android dependency, so it runs in unit tests or a desktop tool as easily as on a Robot -Controller, and it doesn't depend on the other NextFTC modules. +Controller. Its only NextFTC dependency is `util`, which supplies the struct schemas. Robot code logs to `Telemetry`, a table, or a typed handle, and the values go to whatever backends are registered: a WPILOG file, a NetworkTables 4 server, or a mock for tests. A model of WPILib's @@ -73,10 +73,8 @@ a "Conflicting types" error for values of another, so the server refuses the new warns. A name can be published again with the type it had, which is what each OpMode does when its backend publishes the same topics again. -# Package dev.nextftc.telemetry.struct +# Struct schemas -`StructSchema`, the description of a WPILib struct (type name, fixed size, schema text, nested -types, and a packer), and `structSchema`, a builder for defining one by hand. -`StructSchemaRegistry` builds schemas by reflecting over a class's properties, or a Java class's -public fields, getters, or record components, with `StructName`, `StructIgnore`, `StructSize` and -`StructOrder` to adjust the result. The same schema works for both the log writer and the server. +Struct schemas (`StructSchema`, `StructSchemaRegistry` and the `Struct*` annotations) come from +`dev.nextftc.util.struct` in the `util` module. The same schema works for both the log writer and +the server. diff --git a/telemetry/build.gradle.kts b/telemetry/build.gradle.kts index edb5df01..c63e1923 100644 --- a/telemetry/build.gradle.kts +++ b/telemetry/build.gradle.kts @@ -7,6 +7,7 @@ description = "WPILib struct schemas." dependencies { + api(project(":util")) implementation(libs.kotlin.reflect) api(libs.ktor.server.core) implementation(libs.ktor.server.cio) diff --git a/telemetry/src/main/kotlin/dev/nextftc/telemetry/Telemetry.kt b/telemetry/src/main/kotlin/dev/nextftc/telemetry/Telemetry.kt index 438624a3..be84d9b3 100644 --- a/telemetry/src/main/kotlin/dev/nextftc/telemetry/Telemetry.kt +++ b/telemetry/src/main/kotlin/dev/nextftc/telemetry/Telemetry.kt @@ -8,7 +8,7 @@ package dev.nextftc.telemetry -import dev.nextftc.telemetry.struct.StructSchema +import dev.nextftc.util.struct.StructSchema import kotlin.reflect.KClass /** diff --git a/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryBackend.kt b/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryBackend.kt index 59e7a238..3252c18b 100644 --- a/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryBackend.kt +++ b/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryBackend.kt @@ -8,7 +8,7 @@ package dev.nextftc.telemetry -import dev.nextftc.telemetry.struct.StructSchema +import dev.nextftc.util.struct.StructSchema /** * Where the values of one path end up. diff --git a/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryRegistry.kt b/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryRegistry.kt index afe93bc0..414b88cc 100644 --- a/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryRegistry.kt +++ b/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryRegistry.kt @@ -8,8 +8,8 @@ package dev.nextftc.telemetry -import dev.nextftc.telemetry.struct.StructSchema -import dev.nextftc.telemetry.struct.StructSchemaRegistry +import dev.nextftc.util.struct.StructSchema +import dev.nextftc.util.struct.StructSchemaRegistry import java.util.Collections import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList diff --git a/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryTable.kt b/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryTable.kt index 4bc54ee1..910fba9f 100644 --- a/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryTable.kt +++ b/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryTable.kt @@ -8,7 +8,7 @@ package dev.nextftc.telemetry -import dev.nextftc.telemetry.struct.StructSchema +import dev.nextftc.util.struct.StructSchema import java.util.concurrent.ConcurrentHashMap import kotlin.reflect.KClass @@ -77,7 +77,7 @@ fun interface TelemetryLoggable { * ## Struct values * * Any other object is logged as a struct, with its schema reflected from its class by the registry's - * [TelemetryRegistry.structs]. See [dev.nextftc.telemetry.struct.StructSchemaRegistry] for what is + * [TelemetryRegistry.structs]. See [dev.nextftc.util.struct.StructSchemaRegistry] for what is * supported, and use [topic] with a hand-written [StructSchema] for a class that can't be reflected * or to log faster. * diff --git a/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryType.kt b/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryType.kt index a37ee88c..eb24bfb2 100644 --- a/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryType.kt +++ b/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryType.kt @@ -8,7 +8,7 @@ package dev.nextftc.telemetry -import dev.nextftc.telemetry.struct.StructSchema +import dev.nextftc.util.struct.StructSchema /** * What a telemetry path holds: one of the [ValueType]s, or a [StructType]. diff --git a/telemetry/src/main/kotlin/dev/nextftc/telemetry/backend/MockTelemetryBackend.kt b/telemetry/src/main/kotlin/dev/nextftc/telemetry/backend/MockTelemetryBackend.kt index 188c528a..9e26c08a 100644 --- a/telemetry/src/main/kotlin/dev/nextftc/telemetry/backend/MockTelemetryBackend.kt +++ b/telemetry/src/main/kotlin/dev/nextftc/telemetry/backend/MockTelemetryBackend.kt @@ -11,7 +11,7 @@ package dev.nextftc.telemetry.backend import dev.nextftc.telemetry.TelemetryBackend import dev.nextftc.telemetry.TelemetryEntry import dev.nextftc.telemetry.TelemetryType -import dev.nextftc.telemetry.struct.StructSchema +import dev.nextftc.util.struct.StructSchema /** * Keeps everything it is given, for tests that want to check what robot code logged. diff --git a/telemetry/src/main/kotlin/dev/nextftc/telemetry/nt/NT4Backend.kt b/telemetry/src/main/kotlin/dev/nextftc/telemetry/nt/NT4Backend.kt index 88f70aae..630464a0 100644 --- a/telemetry/src/main/kotlin/dev/nextftc/telemetry/nt/NT4Backend.kt +++ b/telemetry/src/main/kotlin/dev/nextftc/telemetry/nt/NT4Backend.kt @@ -13,7 +13,7 @@ import dev.nextftc.telemetry.TelemetryBackend import dev.nextftc.telemetry.TelemetryEntry import dev.nextftc.telemetry.TelemetryType import dev.nextftc.telemetry.ValueType -import dev.nextftc.telemetry.struct.StructSchema +import dev.nextftc.util.struct.StructSchema import java.util.concurrent.ConcurrentHashMap /** diff --git a/telemetry/src/main/kotlin/dev/nextftc/telemetry/nt/NT4Server.kt b/telemetry/src/main/kotlin/dev/nextftc/telemetry/nt/NT4Server.kt index 2027f689..a4c683bc 100644 --- a/telemetry/src/main/kotlin/dev/nextftc/telemetry/nt/NT4Server.kt +++ b/telemetry/src/main/kotlin/dev/nextftc/telemetry/nt/NT4Server.kt @@ -8,7 +8,7 @@ package dev.nextftc.telemetry.nt -import dev.nextftc.telemetry.struct.StructSchema +import dev.nextftc.util.struct.StructSchema import dev.nextftc.telemetry.toJsonObject import io.ktor.http.HttpStatusCode import io.ktor.server.application.install diff --git a/telemetry/src/main/kotlin/dev/nextftc/telemetry/wpilog/WPILogBackend.kt b/telemetry/src/main/kotlin/dev/nextftc/telemetry/wpilog/WPILogBackend.kt index eed1d478..a6e272b5 100644 --- a/telemetry/src/main/kotlin/dev/nextftc/telemetry/wpilog/WPILogBackend.kt +++ b/telemetry/src/main/kotlin/dev/nextftc/telemetry/wpilog/WPILogBackend.kt @@ -13,7 +13,7 @@ import dev.nextftc.telemetry.TelemetryBackend import dev.nextftc.telemetry.TelemetryEntry import dev.nextftc.telemetry.TelemetryType import dev.nextftc.telemetry.ValueType -import dev.nextftc.telemetry.struct.StructSchema +import dev.nextftc.util.struct.StructSchema import dev.nextftc.telemetry.toJsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put diff --git a/telemetry/src/main/kotlin/dev/nextftc/telemetry/wpilog/WPILogWriter.kt b/telemetry/src/main/kotlin/dev/nextftc/telemetry/wpilog/WPILogWriter.kt index 568aada0..8874628a 100644 --- a/telemetry/src/main/kotlin/dev/nextftc/telemetry/wpilog/WPILogWriter.kt +++ b/telemetry/src/main/kotlin/dev/nextftc/telemetry/wpilog/WPILogWriter.kt @@ -8,7 +8,7 @@ package dev.nextftc.telemetry.wpilog -import dev.nextftc.telemetry.struct.StructSchema +import dev.nextftc.util.struct.StructSchema import java.io.BufferedOutputStream import java.io.Closeable import java.io.Flushable diff --git a/telemetry/src/simulator/kotlin/dev/nextftc/telemetry/sim/LiveSimulator.kt b/telemetry/src/simulator/kotlin/dev/nextftc/telemetry/sim/LiveSimulator.kt index 1a49ca67..247af39c 100644 --- a/telemetry/src/simulator/kotlin/dev/nextftc/telemetry/sim/LiveSimulator.kt +++ b/telemetry/src/simulator/kotlin/dev/nextftc/telemetry/sim/LiveSimulator.kt @@ -16,7 +16,7 @@ import dev.nextftc.telemetry.TelemetryWarningHandler import dev.nextftc.telemetry.arrayTopic import dev.nextftc.telemetry.nt.NT4Backend import dev.nextftc.telemetry.nt.NT4Server -import dev.nextftc.telemetry.struct.StructName +import dev.nextftc.util.struct.StructName import dev.nextftc.telemetry.topic import dev.nextftc.telemetry.withUnit import dev.nextftc.telemetry.wpilog.WPILogBackend diff --git a/telemetry/src/test/kotlin/dev/nextftc/telemetry/TelemetryTableTest.kt b/telemetry/src/test/kotlin/dev/nextftc/telemetry/TelemetryTableTest.kt index f82327bd..cf36fce5 100644 --- a/telemetry/src/test/kotlin/dev/nextftc/telemetry/TelemetryTableTest.kt +++ b/telemetry/src/test/kotlin/dev/nextftc/telemetry/TelemetryTableTest.kt @@ -9,8 +9,8 @@ package dev.nextftc.telemetry import dev.nextftc.telemetry.backend.MockTelemetryBackend -import dev.nextftc.telemetry.struct.StructName -import dev.nextftc.telemetry.struct.structSchema +import dev.nextftc.util.struct.StructName +import dev.nextftc.util.struct.structSchema import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.shouldBe diff --git a/telemetry/src/test/kotlin/dev/nextftc/telemetry/backend/NT4BackendTest.kt b/telemetry/src/test/kotlin/dev/nextftc/telemetry/backend/NT4BackendTest.kt index 70f585c8..4cde87f1 100644 --- a/telemetry/src/test/kotlin/dev/nextftc/telemetry/backend/NT4BackendTest.kt +++ b/telemetry/src/test/kotlin/dev/nextftc/telemetry/backend/NT4BackendTest.kt @@ -21,7 +21,7 @@ import dev.nextftc.telemetry.nt.nextValue import dev.nextftc.telemetry.nt.sendText import dev.nextftc.telemetry.nt.string import dev.nextftc.telemetry.nt.subscribeMessage -import dev.nextftc.telemetry.struct.structSchema +import dev.nextftc.util.struct.structSchema import dev.nextftc.telemetry.withProperties import dev.nextftc.telemetry.withUnit import io.kotest.core.spec.style.FunSpec diff --git a/telemetry/src/test/kotlin/dev/nextftc/telemetry/backend/WPILogBackendTest.kt b/telemetry/src/test/kotlin/dev/nextftc/telemetry/backend/WPILogBackendTest.kt index b6ebfeb4..016e2b48 100644 --- a/telemetry/src/test/kotlin/dev/nextftc/telemetry/backend/WPILogBackendTest.kt +++ b/telemetry/src/test/kotlin/dev/nextftc/telemetry/backend/WPILogBackendTest.kt @@ -10,7 +10,7 @@ package dev.nextftc.telemetry.backend import dev.nextftc.telemetry.TelemetryRegistry import dev.nextftc.telemetry.ValueType -import dev.nextftc.telemetry.struct.structSchema +import dev.nextftc.util.struct.structSchema import dev.nextftc.telemetry.withUnit import dev.nextftc.telemetry.wpilog.ParsedLog import dev.nextftc.telemetry.wpilog.WPILogBackend diff --git a/telemetry/src/test/kotlin/dev/nextftc/telemetry/nt/NT4ServerTest.kt b/telemetry/src/test/kotlin/dev/nextftc/telemetry/nt/NT4ServerTest.kt index 0a5e8379..731e948a 100644 --- a/telemetry/src/test/kotlin/dev/nextftc/telemetry/nt/NT4ServerTest.kt +++ b/telemetry/src/test/kotlin/dev/nextftc/telemetry/nt/NT4ServerTest.kt @@ -8,7 +8,7 @@ package dev.nextftc.telemetry.nt -import dev.nextftc.telemetry.struct.structSchema +import dev.nextftc.util.struct.structSchema import dev.nextftc.telemetry.wpilog.WPILogWriter import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec diff --git a/telemetry/src/test/kotlin/dev/nextftc/telemetry/struct/StructSchemaRegistryTest.kt b/telemetry/src/test/kotlin/dev/nextftc/telemetry/struct/StructSchemaRegistryTest.kt index 4dce6004..49d51b53 100644 --- a/telemetry/src/test/kotlin/dev/nextftc/telemetry/struct/StructSchemaRegistryTest.kt +++ b/telemetry/src/test/kotlin/dev/nextftc/telemetry/struct/StructSchemaRegistryTest.kt @@ -9,6 +9,12 @@ package dev.nextftc.telemetry.struct import dev.nextftc.telemetry.wpilog.WPILogWriter +import dev.nextftc.util.struct.StructIgnore +import dev.nextftc.util.struct.StructName +import dev.nextftc.util.struct.StructSchema +import dev.nextftc.util.struct.StructSchemaRegistry +import dev.nextftc.util.struct.StructSize +import dev.nextftc.util.struct.structSchema import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContainExactly @@ -47,7 +53,8 @@ private data class Primitives( private data class Ignoring(val x: Double, @StructIgnore val label: String) private class Computed(val x: Double) { - @StructIgnore val doubled get() = x * 2 + @StructIgnore + val doubled get() = x * 2 } private data class WithArray(@StructSize(3) val values: DoubleArray) diff --git a/telemetry/src/test/kotlin/dev/nextftc/telemetry/wpilog/WPILogWriterTest.kt b/telemetry/src/test/kotlin/dev/nextftc/telemetry/wpilog/WPILogWriterTest.kt index 1a20f8b2..e08bacbd 100644 --- a/telemetry/src/test/kotlin/dev/nextftc/telemetry/wpilog/WPILogWriterTest.kt +++ b/telemetry/src/test/kotlin/dev/nextftc/telemetry/wpilog/WPILogWriterTest.kt @@ -8,7 +8,8 @@ package dev.nextftc.telemetry.wpilog -import dev.nextftc.telemetry.struct.structSchema +import dev.nextftc.util.struct.StructSchema +import dev.nextftc.util.struct.structSchema import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContainExactly @@ -289,7 +290,7 @@ class WPILogWriterTest : test("a schema that packs fewer bytes than it declares is rejected") { val lying = - object : dev.nextftc.telemetry.struct.StructSchema { + object : StructSchema { override val typeName = "Lying" override val size = 8 override val schema = "float64 x" diff --git a/util/Module.md b/util/Module.md new file mode 100644 index 00000000..09b3d4ea --- /dev/null +++ b/util/Module.md @@ -0,0 +1,18 @@ +# Module NextFTC Util + +Small pieces shared by the other NextFTC modules. It has no FTC SDK or Android dependency, so every +module, including the pure-JVM ones, can depend on it. + +# Package dev.nextftc.util + +`EventLoop`, a lightweight loop that polls bound actions, and `Log`, a logging facade. `Log` prints +warnings and errors to standard error until something installs a `LogSink`; `hardware`'s +`RobotController` installs one that writes to the Robot Controller log. + +# Package dev.nextftc.util.struct + +`StructSchema`, the description of a WPILib struct (type name, fixed size, schema text, nested +types, and a packer), and `structSchema`, a builder for defining one by hand. +`StructSchemaRegistry` builds schemas by reflecting over a class's properties, or a Java class's +public fields, getters, or record components, with `StructName`, `StructIgnore`, `StructSize` and +related annotations to adjust the result. diff --git a/util/build.gradle.kts b/util/build.gradle.kts new file mode 100644 index 00000000..586c631e --- /dev/null +++ b/util/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + id("dev.nextftc.suite.kotlin-jvm") +} + +description = + "Pure-JVM utilities shared across NextFTC: WPILib struct schemas, an event loop, and a logging facade." + +dependencies { + implementation(libs.kotlin.reflect) + + testImplementation(libs.bundles.kotest) +} + +nextFTCPublishing { + displayName = "NextFTC Util" + logoPath = "../assets/logo-icon.svg" +} + +dokka { + dokkaSourceSets.configureEach { + includes.from("Module.md") + } +} + +kotlin { + compilerOptions { + freeCompilerArgs.add("-jvm-default=no-compatibility") + } +} diff --git a/hardware/src/main/kotlin/dev/nextftc/hardware/util/EventLoop.kt b/util/src/main/kotlin/dev/nextftc/util/EventLoop.kt similarity index 95% rename from hardware/src/main/kotlin/dev/nextftc/hardware/util/EventLoop.kt rename to util/src/main/kotlin/dev/nextftc/util/EventLoop.kt index ba069642..23a17314 100644 --- a/hardware/src/main/kotlin/dev/nextftc/hardware/util/EventLoop.kt +++ b/util/src/main/kotlin/dev/nextftc/util/EventLoop.kt @@ -1,4 +1,4 @@ -package dev.nextftc.hardware.util +package dev.nextftc.util /** * A lightweight execution loop for polling boolean triggers and executing bound actions. diff --git a/util/src/main/kotlin/dev/nextftc/util/Log.kt b/util/src/main/kotlin/dev/nextftc/util/Log.kt new file mode 100644 index 00000000..8ff6d422 --- /dev/null +++ b/util/src/main/kotlin/dev/nextftc/util/Log.kt @@ -0,0 +1,50 @@ +package dev.nextftc.util + +/** How serious a log message is. */ +enum class LogLevel { DEBUG, INFO, WARN, ERROR } + +/** Where [Log] sends its messages. */ +fun interface LogSink { + /** Records [message] at [level], with the stack trace of [throwable] if there is one. */ + fun log(level: LogLevel, message: String, throwable: Throwable?) +} + +/** + * A logging facade for code that can't depend on the FTC SDK. + * + * Messages go to [sink]. The default prints warnings and errors to standard error, which is what you + * want in unit tests; `hardware`'s `RobotController` installs one that writes to the Robot Controller + * log. + */ +object Log { + /** The destination for every message. Replace it to redirect logging. */ + @Volatile + @JvmStatic + var sink: LogSink = StderrSink + + /** Logs [message] at debug level. */ + @JvmStatic + fun debug(message: String) = sink.log(LogLevel.DEBUG, message, null) + + /** Logs [message] at info level. */ + @JvmStatic + fun info(message: String) = sink.log(LogLevel.INFO, message, null) + + /** Logs [message] at warn level, with the stack trace of [throwable] if given. */ + @JvmStatic + @JvmOverloads + fun warn(message: String, throwable: Throwable? = null) = sink.log(LogLevel.WARN, message, throwable) + + /** Logs [message] at error level, with the stack trace of [throwable] if given. */ + @JvmStatic + @JvmOverloads + fun error(message: String, throwable: Throwable? = null) = sink.log(LogLevel.ERROR, message, throwable) + + private object StderrSink : LogSink { + override fun log(level: LogLevel, message: String, throwable: Throwable?) { + if (level < LogLevel.WARN) return + System.err.println("NextFTC [$level] $message") + throwable?.printStackTrace() + } + } +} diff --git a/telemetry/src/main/kotlin/dev/nextftc/telemetry/struct/StructAnnotations.kt b/util/src/main/kotlin/dev/nextftc/util/struct/StructAnnotations.kt similarity index 98% rename from telemetry/src/main/kotlin/dev/nextftc/telemetry/struct/StructAnnotations.kt rename to util/src/main/kotlin/dev/nextftc/util/struct/StructAnnotations.kt index 89c77909..1747cefb 100644 --- a/telemetry/src/main/kotlin/dev/nextftc/telemetry/struct/StructAnnotations.kt +++ b/util/src/main/kotlin/dev/nextftc/util/struct/StructAnnotations.kt @@ -6,7 +6,7 @@ * https://opensource.org/license/bsd-3-clause. */ -package dev.nextftc.telemetry.struct +package dev.nextftc.util.struct /** * Sets the struct type name of a class when a [StructSchemaRegistry] reflects it, instead of the diff --git a/telemetry/src/main/kotlin/dev/nextftc/telemetry/struct/StructSchema.kt b/util/src/main/kotlin/dev/nextftc/util/struct/StructSchema.kt similarity index 99% rename from telemetry/src/main/kotlin/dev/nextftc/telemetry/struct/StructSchema.kt rename to util/src/main/kotlin/dev/nextftc/util/struct/StructSchema.kt index bffcfead..6196d224 100644 --- a/telemetry/src/main/kotlin/dev/nextftc/telemetry/struct/StructSchema.kt +++ b/util/src/main/kotlin/dev/nextftc/util/struct/StructSchema.kt @@ -6,7 +6,7 @@ * https://opensource.org/license/bsd-3-clause. */ -package dev.nextftc.telemetry.struct +package dev.nextftc.util.struct import java.nio.ByteBuffer import java.nio.ByteOrder diff --git a/telemetry/src/main/kotlin/dev/nextftc/telemetry/struct/StructSchemaRegistry.kt b/util/src/main/kotlin/dev/nextftc/util/struct/StructSchemaRegistry.kt similarity index 99% rename from telemetry/src/main/kotlin/dev/nextftc/telemetry/struct/StructSchemaRegistry.kt rename to util/src/main/kotlin/dev/nextftc/util/struct/StructSchemaRegistry.kt index 4768eb6d..df3dcdd6 100644 --- a/telemetry/src/main/kotlin/dev/nextftc/telemetry/struct/StructSchemaRegistry.kt +++ b/util/src/main/kotlin/dev/nextftc/util/struct/StructSchemaRegistry.kt @@ -6,7 +6,7 @@ * https://opensource.org/license/bsd-3-clause. */ -package dev.nextftc.telemetry.struct +package dev.nextftc.util.struct import java.lang.reflect.Method import java.lang.reflect.Modifier diff --git a/telemetry/src/test/java/dev/nextftc/telemetry/javatypes/JavaTypes.java b/util/src/test/java/dev/nextftc/util/javatypes/JavaTypes.java similarity index 95% rename from telemetry/src/test/java/dev/nextftc/telemetry/javatypes/JavaTypes.java rename to util/src/test/java/dev/nextftc/util/javatypes/JavaTypes.java index 3b3487b5..4903a0f5 100644 --- a/telemetry/src/test/java/dev/nextftc/telemetry/javatypes/JavaTypes.java +++ b/util/src/test/java/dev/nextftc/util/javatypes/JavaTypes.java @@ -6,12 +6,12 @@ * https://opensource.org/license/bsd-3-clause. */ -package dev.nextftc.telemetry.javatypes; +package dev.nextftc.util.javatypes; -import dev.nextftc.telemetry.struct.StructIgnore; -import dev.nextftc.telemetry.struct.StructName; -import dev.nextftc.telemetry.struct.StructOrder; -import dev.nextftc.telemetry.struct.StructSize; +import dev.nextftc.util.struct.StructIgnore; +import dev.nextftc.util.struct.StructName; +import dev.nextftc.util.struct.StructOrder; +import dev.nextftc.util.struct.StructSize; import java.util.List; /** Java classes for reflecting into struct schemas, written the way Java code usually is. */ diff --git a/telemetry/src/test/kotlin/dev/nextftc/telemetry/struct/JavaStructSchemaTest.kt b/util/src/test/kotlin/dev/nextftc/util/struct/JavaStructSchemaTest.kt similarity index 98% rename from telemetry/src/test/kotlin/dev/nextftc/telemetry/struct/JavaStructSchemaTest.kt rename to util/src/test/kotlin/dev/nextftc/util/struct/JavaStructSchemaTest.kt index c582deba..a5068ae9 100644 --- a/telemetry/src/test/kotlin/dev/nextftc/telemetry/struct/JavaStructSchemaTest.kt +++ b/util/src/test/kotlin/dev/nextftc/util/struct/JavaStructSchemaTest.kt @@ -6,9 +6,9 @@ * https://opensource.org/license/bsd-3-clause. */ -package dev.nextftc.telemetry.struct +package dev.nextftc.util.struct -import dev.nextftc.telemetry.javatypes.JavaTypes +import dev.nextftc.util.javatypes.JavaTypes import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe diff --git a/telemetry/src/test/kotlin/dev/nextftc/telemetry/struct/StructSchemaTest.kt b/util/src/test/kotlin/dev/nextftc/util/struct/StructSchemaTest.kt similarity index 99% rename from telemetry/src/test/kotlin/dev/nextftc/telemetry/struct/StructSchemaTest.kt rename to util/src/test/kotlin/dev/nextftc/util/struct/StructSchemaTest.kt index 1840a188..d2c5d00c 100644 --- a/telemetry/src/test/kotlin/dev/nextftc/telemetry/struct/StructSchemaTest.kt +++ b/util/src/test/kotlin/dev/nextftc/util/struct/StructSchemaTest.kt @@ -6,7 +6,7 @@ * https://opensource.org/license/bsd-3-clause. */ -package dev.nextftc.telemetry.struct +package dev.nextftc.util.struct import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec From cfa4df9fb4225e2eab6bc63a8140f3ad8070292b Mon Sep 17 00:00:00 2001 From: Zach Harel Date: Tue, 10 Nov 2026 13:54:14 -0500 Subject: [PATCH 2/3] add structs for nextftc and pedro geometry types, and also add telemetry support for nextftc Measure types Signed-off-by: Zach Harel --- .../control/geometry/ChassisAccelerations.kt | 14 +++ .../control/geometry/ChassisVelocities.kt | 14 +++ .../dev/nextftc/control/geometry/Pose2d.kt | 11 ++ .../control/geometry/PoseAcceleration2d.kt | 14 +++ .../control/geometry/PoseVelocity2d.kt | 14 +++ .../nextftc/control/geometry/Rotation2d.kt | 10 ++ .../nextftc/control/geometry/Transform2d.kt | 11 ++ .../dev/nextftc/control/geometry/Twist2d.kt | 13 ++ .../dev/nextftc/control/geometry/Vector2d.kt | 13 ++ .../control/geometry/GeometryStructsTest.kt | 114 ++++++++++++++++++ .../nextftc/hardware/actuators/NextMotor.kt | 2 +- .../sensors/NextColorDistanceSensor.kt | 2 +- .../hardware/sensors/NextDistanceSensor.kt | 2 +- pedro/Module.md | 4 +- .../dev/nextftc/v2/pedro/PedroMechanism.kt | 5 + .../dev/nextftc/v2/pedro/PedroStructs.kt | 86 +++++++++++++ .../dev/nextftc/v2/pedro/PedroStructsTest.kt | 65 ++++++++++ .../kotlin/dev/nextftc/robot/GlobalLog.kt | 5 +- telemetry/Module.md | 3 +- telemetry/build.gradle.kts | 1 + .../nextftc/telemetry/TelemetryRegistry.kt | 23 +++- .../dev/nextftc/telemetry/TelemetryTable.kt | 24 +++- .../dev/nextftc/telemetry/nt/NT4Server.kt | 2 +- .../nextftc/telemetry/wpilog/WPILogBackend.kt | 2 +- .../nextftc/telemetry/sim/LiveSimulator.kt | 2 +- .../telemetry/TelemetryRegistryTest.kt | 47 ++++++++ .../telemetry/backend/NT4BackendTest.kt | 2 +- .../telemetry/backend/WPILogBackendTest.kt | 2 +- .../dev/nextftc/telemetry/nt/NT4ServerTest.kt | 2 +- .../struct/StructSchemaRegistryTest.kt | 12 +- .../util/struct/StructSchemaRegistry.kt | 20 ++- 31 files changed, 517 insertions(+), 24 deletions(-) create mode 100644 control/src/test/kotlin/dev/nextftc/control/geometry/GeometryStructsTest.kt create mode 100644 pedro/src/main/kotlin/dev/nextftc/v2/pedro/PedroStructs.kt create mode 100644 pedro/src/test/kotlin/dev/nextftc/v2/pedro/PedroStructsTest.kt diff --git a/control/src/main/kotlin/dev/nextftc/control/geometry/ChassisAccelerations.kt b/control/src/main/kotlin/dev/nextftc/control/geometry/ChassisAccelerations.kt index 9deb3fc0..5ac94878 100644 --- a/control/src/main/kotlin/dev/nextftc/control/geometry/ChassisAccelerations.kt +++ b/control/src/main/kotlin/dev/nextftc/control/geometry/ChassisAccelerations.kt @@ -9,12 +9,15 @@ package dev.nextftc.control.geometry import dev.nextftc.units.InchesPerSecondSquared +import dev.nextftc.units.MetersPerSecondSquared import dev.nextftc.units.RadiansPerSecondSquared import dev.nextftc.units.measuretypes.AngularAcceleration import dev.nextftc.units.measuretypes.Time import dev.nextftc.units.unittypes.DistanceUnit import dev.nextftc.units.unittypes.PerUnit import dev.nextftc.units.unittypes.TimeUnit +import dev.nextftc.util.struct.StructSchema +import dev.nextftc.util.struct.structSchema /** * @usesMathJax @@ -180,5 +183,16 @@ data class ChassisAccelerations( Vector2d.zero(InchesPerSecondSquared), RadiansPerSecondSquared.of(0.0), ) + + /** + * The WPILib `ChassisAccelerations` struct: `ax` and `ay` in meters per second squared and + * `alpha` in radians per second squared. + */ + @JvmField + val struct: StructSchema = structSchema("ChassisAccelerations") { + float64("ax") { it.linearAcc.x.into(MetersPerSecondSquared) } + float64("ay") { it.linearAcc.y.into(MetersPerSecondSquared) } + float64("alpha") { it.angAcc.into(RadiansPerSecondSquared) } + } } } diff --git a/control/src/main/kotlin/dev/nextftc/control/geometry/ChassisVelocities.kt b/control/src/main/kotlin/dev/nextftc/control/geometry/ChassisVelocities.kt index edf57c14..09a3181f 100644 --- a/control/src/main/kotlin/dev/nextftc/control/geometry/ChassisVelocities.kt +++ b/control/src/main/kotlin/dev/nextftc/control/geometry/ChassisVelocities.kt @@ -9,11 +9,14 @@ package dev.nextftc.control.geometry import dev.nextftc.units.InchesPerSecond +import dev.nextftc.units.MetersPerSecond import dev.nextftc.units.RadiansPerSecond import dev.nextftc.units.measuretypes.AngularVelocity import dev.nextftc.units.unittypes.DistanceUnit import dev.nextftc.units.unittypes.PerUnit import dev.nextftc.units.unittypes.TimeUnit +import dev.nextftc.util.struct.StructSchema +import dev.nextftc.util.struct.structSchema /** * @usesMathJax @@ -153,5 +156,16 @@ data class ChassisVelocities( */ @JvmField val zero = ChassisVelocities(Vector2d.zero(InchesPerSecond), RadiansPerSecond.of(0.0)) + + /** + * The WPILib `ChassisVelocities` struct: `vx` and `vy` in meters per second and `omega` in + * radians per second. + */ + @JvmField + val struct: StructSchema = structSchema("ChassisVelocities") { + float64("vx") { it.linearVel.x.into(MetersPerSecond) } + float64("vy") { it.linearVel.y.into(MetersPerSecond) } + float64("omega") { it.angVel.into(RadiansPerSecond) } + } } } diff --git a/control/src/main/kotlin/dev/nextftc/control/geometry/Pose2d.kt b/control/src/main/kotlin/dev/nextftc/control/geometry/Pose2d.kt index 7a75f214..c5c164b2 100644 --- a/control/src/main/kotlin/dev/nextftc/control/geometry/Pose2d.kt +++ b/control/src/main/kotlin/dev/nextftc/control/geometry/Pose2d.kt @@ -12,6 +12,8 @@ import dev.nextftc.units.Inches import dev.nextftc.units.measuretypes.Angle import dev.nextftc.units.measuretypes.Distance import dev.nextftc.units.unittypes.DistanceUnit +import dev.nextftc.util.struct.StructSchema +import dev.nextftc.util.struct.structSchema import kotlin.math.abs import kotlin.math.cos import kotlin.math.sin @@ -309,6 +311,15 @@ data class Pose2d( } companion object { + /** + * The WPILib `Pose2d` struct: a translation in meters and a rotation in radians. + */ + @JvmField + val struct: StructSchema = structSchema("Pose2d") { + struct("translation", Vector2d.translation2dStruct) { it.position } + struct("rotation", Rotation2d.struct) { it.heading } + } + /** * Computes the pose exponential map from a twist. * diff --git a/control/src/main/kotlin/dev/nextftc/control/geometry/PoseAcceleration2d.kt b/control/src/main/kotlin/dev/nextftc/control/geometry/PoseAcceleration2d.kt index 7e114ba3..438ee736 100644 --- a/control/src/main/kotlin/dev/nextftc/control/geometry/PoseAcceleration2d.kt +++ b/control/src/main/kotlin/dev/nextftc/control/geometry/PoseAcceleration2d.kt @@ -9,12 +9,15 @@ package dev.nextftc.control.geometry import dev.nextftc.units.InchesPerSecondSquared +import dev.nextftc.units.MetersPerSecondSquared import dev.nextftc.units.RadiansPerSecondSquared import dev.nextftc.units.measuretypes.AngularAcceleration import dev.nextftc.units.measuretypes.Time import dev.nextftc.units.unittypes.DistanceUnit import dev.nextftc.units.unittypes.PerUnit import dev.nextftc.units.unittypes.TimeUnit +import dev.nextftc.util.struct.StructSchema +import dev.nextftc.util.struct.structSchema /** * @usesMathJax @@ -191,5 +194,16 @@ data class PoseAcceleration2d( Vector2d.zero(InchesPerSecondSquared), RadiansPerSecondSquared.of(0.0), ) + + /** + * The WPILib `ChassisAccelerations` struct, which WPILib uses for field-frame accelerations + * too: `ax` and `ay` in meters per second squared and `alpha` in radians per second squared. + */ + @JvmField + val struct: StructSchema = structSchema("ChassisAccelerations") { + float64("ax") { it.linearAcc.x.into(MetersPerSecondSquared) } + float64("ay") { it.linearAcc.y.into(MetersPerSecondSquared) } + float64("alpha") { it.angAcc.into(RadiansPerSecondSquared) } + } } } diff --git a/control/src/main/kotlin/dev/nextftc/control/geometry/PoseVelocity2d.kt b/control/src/main/kotlin/dev/nextftc/control/geometry/PoseVelocity2d.kt index 6a8b71bf..7f5fa3c2 100644 --- a/control/src/main/kotlin/dev/nextftc/control/geometry/PoseVelocity2d.kt +++ b/control/src/main/kotlin/dev/nextftc/control/geometry/PoseVelocity2d.kt @@ -9,11 +9,14 @@ package dev.nextftc.control.geometry import dev.nextftc.units.InchesPerSecond +import dev.nextftc.units.MetersPerSecond import dev.nextftc.units.RadiansPerSecond import dev.nextftc.units.measuretypes.AngularVelocity import dev.nextftc.units.unittypes.DistanceUnit import dev.nextftc.units.unittypes.PerUnit import dev.nextftc.units.unittypes.TimeUnit +import dev.nextftc.util.struct.StructSchema +import dev.nextftc.util.struct.structSchema /** * @usesMathJax @@ -168,5 +171,16 @@ data class PoseVelocity2d( companion object { @JvmField val zero = PoseVelocity2d(Vector2d.zero(InchesPerSecond), RadiansPerSecond.of(0.0)) + + /** + * The WPILib `ChassisVelocities` struct, which WPILib uses for field-frame velocities too: `vx` + * and `vy` in meters per second and `omega` in radians per second. + */ + @JvmField + val struct: StructSchema = structSchema("ChassisVelocities") { + float64("vx") { it.linearVel.x.into(MetersPerSecond) } + float64("vy") { it.linearVel.y.into(MetersPerSecond) } + float64("omega") { it.angVel.into(RadiansPerSecond) } + } } } diff --git a/control/src/main/kotlin/dev/nextftc/control/geometry/Rotation2d.kt b/control/src/main/kotlin/dev/nextftc/control/geometry/Rotation2d.kt index 93fef080..c0c5a1bf 100644 --- a/control/src/main/kotlin/dev/nextftc/control/geometry/Rotation2d.kt +++ b/control/src/main/kotlin/dev/nextftc/control/geometry/Rotation2d.kt @@ -12,6 +12,8 @@ import dev.nextftc.units.Radians import dev.nextftc.units.Unit import dev.nextftc.units.inches import dev.nextftc.units.measuretypes.Angle +import dev.nextftc.util.struct.StructSchema +import dev.nextftc.util.struct.structSchema import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.sin @@ -214,5 +216,13 @@ data class Rotation2d(@JvmField val real: Double, @JvmField val imag: Double) { */ @JvmField val zero = Rotation2d(1.0, 0.0) + + /** + * The WPILib `Rotation2d` struct: `double value`, the angle in radians. + */ + @JvmField + val struct: StructSchema = structSchema("Rotation2d") { + float64("value") { it.log() } + } } } diff --git a/control/src/main/kotlin/dev/nextftc/control/geometry/Transform2d.kt b/control/src/main/kotlin/dev/nextftc/control/geometry/Transform2d.kt index 033b12d2..9b805143 100644 --- a/control/src/main/kotlin/dev/nextftc/control/geometry/Transform2d.kt +++ b/control/src/main/kotlin/dev/nextftc/control/geometry/Transform2d.kt @@ -12,6 +12,8 @@ import dev.nextftc.units.Inches import dev.nextftc.units.measuretypes.Angle import dev.nextftc.units.measuretypes.Distance import dev.nextftc.units.unittypes.DistanceUnit +import dev.nextftc.util.struct.StructSchema +import dev.nextftc.util.struct.structSchema /** * @usesMathJax @@ -268,5 +270,14 @@ data class Transform2d( */ @JvmField val identity = Transform2d(Vector2d.zero(Inches), Rotation2d.zero) + + /** + * The WPILib `Transform2d` struct: a translation in meters and a rotation in radians. + */ + @JvmField + val struct: StructSchema = structSchema("Transform2d") { + struct("translation", Vector2d.translation2dStruct) { it.translation } + struct("rotation", Rotation2d.struct) { it.rotation } + } } } diff --git a/control/src/main/kotlin/dev/nextftc/control/geometry/Twist2d.kt b/control/src/main/kotlin/dev/nextftc/control/geometry/Twist2d.kt index 04222294..b7e8ce38 100644 --- a/control/src/main/kotlin/dev/nextftc/control/geometry/Twist2d.kt +++ b/control/src/main/kotlin/dev/nextftc/control/geometry/Twist2d.kt @@ -9,9 +9,12 @@ package dev.nextftc.control.geometry import dev.nextftc.units.Inches +import dev.nextftc.units.Meters import dev.nextftc.units.measuretypes.Angle import dev.nextftc.units.measuretypes.Distance import dev.nextftc.units.unittypes.DistanceUnit +import dev.nextftc.util.struct.StructSchema +import dev.nextftc.util.struct.structSchema /** * @usesMathJax @@ -171,5 +174,15 @@ data class Twist2d(@JvmField val line: Vector2d, @JvmField val ang companion object { @JvmField val zero = Twist2d(Vector2d.zero(Inches), 0.0) + + /** + * The WPILib `Twist2d` struct: `dx` and `dy` in meters and `dtheta` in radians. + */ + @JvmField + val struct: StructSchema = structSchema("Twist2d") { + float64("dx") { it.line.x.into(Meters) } + float64("dy") { it.line.y.into(Meters) } + float64("dtheta") { it.angle } + } } } diff --git a/control/src/main/kotlin/dev/nextftc/control/geometry/Vector2d.kt b/control/src/main/kotlin/dev/nextftc/control/geometry/Vector2d.kt index 068ba1c2..a682f861 100644 --- a/control/src/main/kotlin/dev/nextftc/control/geometry/Vector2d.kt +++ b/control/src/main/kotlin/dev/nextftc/control/geometry/Vector2d.kt @@ -11,6 +11,7 @@ package dev.nextftc.control.geometry import dev.nextftc.units.Measure +import dev.nextftc.units.Meters import dev.nextftc.units.Unit import dev.nextftc.units.inches import dev.nextftc.units.inchesPerSecond @@ -19,6 +20,8 @@ import dev.nextftc.units.measuretypes.Time import dev.nextftc.units.unittypes.DistanceUnit import dev.nextftc.units.unittypes.PerUnit import dev.nextftc.units.unittypes.TimeUnit +import dev.nextftc.util.struct.StructSchema +import dev.nextftc.util.struct.structSchema import kotlin.math.sqrt /** @@ -182,6 +185,16 @@ data class Vector2d>(@JvmField val x: Measure, @JvmField val y: M @JvmField val zero = Vector2d(0.0.inches, 0.0.inches) + /** + * The WPILib `Translation2d` struct: `x` and `y` in meters. It describes a displacement, so it + * applies to a `Vector2d`. + */ + @JvmField + val translation2dStruct: StructSchema> = structSchema("Translation2d") { + float64("x") { it.x.into(Meters) } + float64("y") { it.y.into(Meters) } + } + /** * Creates a zero vector with the specified unit. * diff --git a/control/src/test/kotlin/dev/nextftc/control/geometry/GeometryStructsTest.kt b/control/src/test/kotlin/dev/nextftc/control/geometry/GeometryStructsTest.kt new file mode 100644 index 00000000..ce4781fe --- /dev/null +++ b/control/src/test/kotlin/dev/nextftc/control/geometry/GeometryStructsTest.kt @@ -0,0 +1,114 @@ +/* + * 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.control.geometry + +import dev.nextftc.units.inches +import dev.nextftc.units.inchesPerSecond +import dev.nextftc.units.inchesPerSecondSquared +import dev.nextftc.units.meters +import dev.nextftc.units.metersPerSecond +import dev.nextftc.units.metersPerSecondSquared +import dev.nextftc.units.radiansPerSecond +import dev.nextftc.units.radiansPerSecondSquared +import dev.nextftc.util.struct.StructSchema +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.doubles.plusOrMinus +import io.kotest.matchers.shouldBe +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class GeometryStructsTest : + FunSpec({ + val epsilon = 1e-9 + + fun pack(schema: StructSchema, value: T): ByteBuffer { + val buffer = ByteBuffer.allocate(schema.size).order(ByteOrder.LITTLE_ENDIAN) + schema.pack(buffer, value) + buffer.flip() + return buffer + } + + test("schemas match WPILib's type names, layouts and sizes") { + Vector2d.translation2dStruct.typeName shouldBe "Translation2d" + Vector2d.translation2dStruct.schema shouldBe "float64 x; float64 y" + + Rotation2d.struct.typeName shouldBe "Rotation2d" + Rotation2d.struct.schema shouldBe "float64 value" + Rotation2d.struct.size shouldBe 8 + + Pose2d.struct.typeName shouldBe "Pose2d" + Pose2d.struct.schema shouldBe "Translation2d translation; Rotation2d rotation" + Pose2d.struct.size shouldBe 24 + + Transform2d.struct.schema shouldBe "Translation2d translation; Rotation2d rotation" + Twist2d.struct.schema shouldBe "float64 dx; float64 dy; float64 dtheta" + ChassisVelocities.struct.schema shouldBe "float64 vx; float64 vy; float64 omega" + ChassisAccelerations.struct.schema shouldBe "float64 ax; float64 ay; float64 alpha" + } + + test("a pose is written in meters and radians") { + val buffer = pack(Pose2d.struct, Pose2d(100.0.inches, 0.0.inches, Rotation2d.exp(Math.PI / 2))) + + buffer.getDouble() shouldBe (2.54 plusOrMinus epsilon) + buffer.getDouble() shouldBe (0.0 plusOrMinus epsilon) + buffer.getDouble() shouldBe (Math.PI / 2 plusOrMinus epsilon) + } + + test("a transform and a twist are written in meters and radians") { + val transform = pack( + Transform2d.struct, + Transform2d(Vector2d(1.0.meters, 2.0.meters), Rotation2d.exp(0.5)), + ) + transform.getDouble() shouldBe (1.0 plusOrMinus epsilon) + transform.getDouble() shouldBe (2.0 plusOrMinus epsilon) + transform.getDouble() shouldBe (0.5 plusOrMinus epsilon) + + val twist = pack(Twist2d.struct, Twist2d(Vector2d(3.0.meters, 4.0.meters), 0.25)) + twist.getDouble() shouldBe (3.0 plusOrMinus epsilon) + twist.getDouble() shouldBe (4.0 plusOrMinus epsilon) + twist.getDouble() shouldBe (0.25 plusOrMinus epsilon) + } + + test("chassis velocities are written in meters per second and radians per second") { + val buffer = pack( + ChassisVelocities.struct, + ChassisVelocities(Vector2d(1.0.metersPerSecond, 100.0.inchesPerSecond), 2.0.radiansPerSecond), + ) + + buffer.getDouble() shouldBe (1.0 plusOrMinus epsilon) + buffer.getDouble() shouldBe (2.54 plusOrMinus epsilon) + buffer.getDouble() shouldBe (2.0 plusOrMinus epsilon) + } + + test("field-frame velocities and accelerations use the chassis layouts") { + PoseVelocity2d.struct.typeName shouldBe ChassisVelocities.struct.typeName + PoseVelocity2d.struct.schema shouldBe ChassisVelocities.struct.schema + PoseAcceleration2d.struct.typeName shouldBe ChassisAccelerations.struct.typeName + PoseAcceleration2d.struct.schema shouldBe ChassisAccelerations.struct.schema + + val velocity = pack( + PoseVelocity2d.struct, + PoseVelocity2d(Vector2d(1.0.metersPerSecond, 100.0.inchesPerSecond), 2.0.radiansPerSecond), + ) + velocity.getDouble() shouldBe (1.0 plusOrMinus epsilon) + velocity.getDouble() shouldBe (2.54 plusOrMinus epsilon) + velocity.getDouble() shouldBe (2.0 plusOrMinus epsilon) + + val acceleration = pack( + PoseAcceleration2d.struct, + PoseAcceleration2d( + Vector2d(1.0.metersPerSecondSquared, 100.0.inchesPerSecondSquared), + 3.0.radiansPerSecondSquared, + ), + ) + acceleration.getDouble() shouldBe (1.0 plusOrMinus epsilon) + acceleration.getDouble() shouldBe (2.54 plusOrMinus epsilon) + acceleration.getDouble() shouldBe (3.0 plusOrMinus epsilon) + } + }) diff --git a/hardware/src/main/kotlin/dev/nextftc/hardware/actuators/NextMotor.kt b/hardware/src/main/kotlin/dev/nextftc/hardware/actuators/NextMotor.kt index ea2cc63f..9923245a 100644 --- a/hardware/src/main/kotlin/dev/nextftc/hardware/actuators/NextMotor.kt +++ b/hardware/src/main/kotlin/dev/nextftc/hardware/actuators/NextMotor.kt @@ -23,7 +23,6 @@ import dev.nextftc.hardware.RobotController import dev.nextftc.hardware.lynx.NextLynxModule import dev.nextftc.hardware.util.AnalogFeedback import dev.nextftc.hardware.util.Caching -import dev.nextftc.util.EventLoop import dev.nextftc.hardware.util.LazyHardware import dev.nextftc.units.amperes import dev.nextftc.units.measuretypes.Angle @@ -32,6 +31,7 @@ import dev.nextftc.units.measuretypes.Current import dev.nextftc.units.radians import dev.nextftc.units.seconds import dev.nextftc.units.volts +import dev.nextftc.util.EventLoop import org.firstinspires.ftc.robotcore.external.navigation.CurrentUnit import kotlin.math.sign import dev.nextftc.units.measuretypes.Voltage as VoltageMeasure diff --git a/hardware/src/main/kotlin/dev/nextftc/hardware/sensors/NextColorDistanceSensor.kt b/hardware/src/main/kotlin/dev/nextftc/hardware/sensors/NextColorDistanceSensor.kt index bb9ed706..752afd6d 100644 --- a/hardware/src/main/kotlin/dev/nextftc/hardware/sensors/NextColorDistanceSensor.kt +++ b/hardware/src/main/kotlin/dev/nextftc/hardware/sensors/NextColorDistanceSensor.kt @@ -16,8 +16,8 @@ import dev.nextftc.hardware.RobotController import dev.nextftc.hardware.lynx.NextLynxModule import dev.nextftc.hardware.sensors.colors.ColorProfile import dev.nextftc.hardware.sensors.colors.NextColor -import dev.nextftc.util.EventLoop import dev.nextftc.hardware.util.LazyHardware +import dev.nextftc.util.EventLoop import org.firstinspires.ftc.robotcore.external.navigation.DistanceUnit /** diff --git a/hardware/src/main/kotlin/dev/nextftc/hardware/sensors/NextDistanceSensor.kt b/hardware/src/main/kotlin/dev/nextftc/hardware/sensors/NextDistanceSensor.kt index 2821f350..b354b8de 100644 --- a/hardware/src/main/kotlin/dev/nextftc/hardware/sensors/NextDistanceSensor.kt +++ b/hardware/src/main/kotlin/dev/nextftc/hardware/sensors/NextDistanceSensor.kt @@ -12,8 +12,8 @@ import com.qualcomm.hardware.lynx.LynxI2cColorRangeSensor import com.qualcomm.robotcore.hardware.DistanceSensor import dev.nextftc.hardware.RobotController import dev.nextftc.hardware.lynx.NextLynxModule -import dev.nextftc.util.EventLoop import dev.nextftc.hardware.util.LazyHardware +import dev.nextftc.util.EventLoop import org.firstinspires.ftc.robotcore.external.navigation.DistanceUnit /** diff --git a/pedro/Module.md b/pedro/Module.md index e788b442..cfbce241 100644 --- a/pedro/Module.md +++ b/pedro/Module.md @@ -4,7 +4,9 @@ Pedro Pathing integration for NextFTC. Wraps the Pedro `Follower` so it can be d # Package dev.nextftc.v2.pedro -Core Pedro integration, including `PedroMechanism`, which owns the follower and updates it every loop. +Core Pedro integration, including `PedroMechanism`, which owns the follower and updates it every loop, +and `PedroStructs`, the telemetry struct schemas for Pedro's `Pose`, `Vector2D`, `Twist` and +`Velocity`, which `PedroMechanism` registers on creation. # Package dev.nextftc.v2.pedro.constants diff --git a/pedro/src/main/kotlin/dev/nextftc/v2/pedro/PedroMechanism.kt b/pedro/src/main/kotlin/dev/nextftc/v2/pedro/PedroMechanism.kt index 9d017354..68e23cad 100644 --- a/pedro/src/main/kotlin/dev/nextftc/v2/pedro/PedroMechanism.kt +++ b/pedro/src/main/kotlin/dev/nextftc/v2/pedro/PedroMechanism.kt @@ -13,6 +13,7 @@ import com.qualcomm.robotcore.hardware.HardwareMap import dev.nextftc.hardware.RobotController import dev.nextftc.hardware.util.LazyHardware import dev.nextftc.robot.Mechanism +import dev.nextftc.telemetry.Telemetry import java.util.function.Function /** @@ -25,6 +26,10 @@ import java.util.function.Function * its starting pose at the beginning of each OpMode. */ class PedroMechanism(followerFactory: (HardwareMap) -> Follower) : Mechanism { + init { + PedroStructs.registerAll(Telemetry.registry.structs) + } + /** The [Follower], built on first access. */ @get:JvmName("follower") val follower: Follower by LazyHardware { followerFactory(RobotController.hardwareMap) } diff --git a/pedro/src/main/kotlin/dev/nextftc/v2/pedro/PedroStructs.kt b/pedro/src/main/kotlin/dev/nextftc/v2/pedro/PedroStructs.kt new file mode 100644 index 00000000..8c5648e4 --- /dev/null +++ b/pedro/src/main/kotlin/dev/nextftc/v2/pedro/PedroStructs.kt @@ -0,0 +1,86 @@ +/* + * 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.v2.pedro + +import com.pedropathing.math.Pose +import com.pedropathing.math.Twist +import com.pedropathing.math.Vector2D +import com.pedropathing.math.Velocity +import dev.nextftc.telemetry.Telemetry +import dev.nextftc.units.Meters +import dev.nextftc.units.inches +import dev.nextftc.util.struct.StructSchema +import dev.nextftc.util.struct.StructSchemaRegistry +import dev.nextftc.util.struct.structSchema +import java.util.concurrent.atomic.AtomicBoolean + +/** + * [StructSchema]s for Pedro Pathing's geometry types, so they can be logged like the `control` ones. + * + * Pedro works in inches, so distances and speeds are written in meters to match WPILib's layouts. + * Positions stay in Pedro's coordinate frame; no offset is applied. + * + * | Pedro type | Struct type | Fields | + * |---|---|---| + * | [Pose] | `Pose2d` | `Translation2d translation; Rotation2d rotation` | + * | [Vector2D] | `Translation2d` | `x`, `y` in meters | + * | [Twist] (robot frame) | `ChassisVelocities` | `vx`, `vy` in m/s, `omega` in rad/s | + * | [Velocity] (field frame) | `ChassisVelocities` | `vx`, `vy` in m/s, `omega` in rad/s | + * + * [PedroMechanism] registers these with [Telemetry] when it is created. To use another registry, call + * [registerAll]. + */ +object PedroStructs { + private val translation2dOfPose: StructSchema = structSchema("Translation2d") { + float64("x") { it.x().inches.into(Meters) } + float64("y") { it.y().inches.into(Meters) } + } + + private val rotation2dOfHeading: StructSchema = structSchema("Rotation2d") { + float64("value") { it } + } + + /** A [Vector2D] as a WPILib `Translation2d`, in meters. */ + @JvmField + val vector2d: StructSchema = structSchema("Translation2d") { + float64("x") { it.x().inches.into(Meters) } + float64("y") { it.y().inches.into(Meters) } + } + + /** A [Pose] as a WPILib `Pose2d`: its position in meters and its heading in radians. */ + @JvmField + val pose: StructSchema = structSchema("Pose2d") { + struct("translation", translation2dOfPose) { it } + struct("rotation", rotation2dOfHeading) { it.heading() } + } + + /** A robot-frame [Twist] as WPILib `ChassisVelocities`, in meters and radians per second. */ + @JvmField + val twist: StructSchema = structSchema("ChassisVelocities") { + float64("vx") { it.vx.inches.into(Meters) } + float64("vy") { it.vy.inches.into(Meters) } + float64("omega") { it.omega } + } + + /** A field-frame [Velocity] as WPILib `ChassisVelocities`, in meters and radians per second. */ + @JvmField + val velocity: StructSchema = structSchema("ChassisVelocities") { + float64("vx") { it.vx.inches.into(Meters) } + float64("vy") { it.vy.inches.into(Meters) } + float64("omega") { it.omega } + } + + /** Registers every schema in this object with [registry], so these types can be logged. */ + fun registerAll(registry: StructSchemaRegistry) { + registry.register(Pose::class, pose) + registry.register(Vector2D::class, vector2d) + registry.register(Twist::class, twist) + registry.register(Velocity::class, velocity) + } +} diff --git a/pedro/src/test/kotlin/dev/nextftc/v2/pedro/PedroStructsTest.kt b/pedro/src/test/kotlin/dev/nextftc/v2/pedro/PedroStructsTest.kt new file mode 100644 index 00000000..0db9e0ff --- /dev/null +++ b/pedro/src/test/kotlin/dev/nextftc/v2/pedro/PedroStructsTest.kt @@ -0,0 +1,65 @@ +/* + * 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.v2.pedro + +import com.pedropathing.math.Pose +import com.pedropathing.math.Twist +import com.pedropathing.math.Vector2D +import dev.nextftc.util.struct.StructSchema +import dev.nextftc.util.struct.StructSchemaRegistry +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.doubles.plusOrMinus +import io.kotest.matchers.shouldBe +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class PedroStructsTest : + FunSpec({ + val epsilon = 1e-9 + + fun pack(schema: StructSchema, value: T): ByteBuffer { + val buffer = ByteBuffer.allocate(schema.size).order(ByteOrder.LITTLE_ENDIAN) + schema.pack(buffer, value) + buffer.flip() + return buffer + } + + test("a pose matches WPILib's Pose2d layout") { + PedroStructs.pose.typeName shouldBe "Pose2d" + PedroStructs.pose.schema shouldBe "Translation2d translation; Rotation2d rotation" + PedroStructs.pose.size shouldBe 24 + } + + test("a pose is written in meters and radians") { + val buffer = pack(PedroStructs.pose, Pose(100.0, 50.0, Math.PI / 2)) + + buffer.getDouble() shouldBe (2.54 plusOrMinus epsilon) + buffer.getDouble() shouldBe (1.27 plusOrMinus epsilon) + buffer.getDouble() shouldBe (Math.PI / 2 plusOrMinus epsilon) + } + + test("a vector and a twist are written in meters") { + val vector = pack(PedroStructs.vector2d, Vector2D.cartesian(100.0, 200.0)) + vector.getDouble() shouldBe (2.54 plusOrMinus epsilon) + vector.getDouble() shouldBe (5.08 plusOrMinus epsilon) + + val twist = pack(PedroStructs.twist, Twist(100.0, 0.0, 3.0)) + twist.getDouble() shouldBe (2.54 plusOrMinus epsilon) + twist.getDouble() shouldBe (0.0 plusOrMinus epsilon) + twist.getDouble() shouldBe (3.0 plusOrMinus epsilon) + } + + test("registerAll lets a registry find every type") { + val registry = StructSchemaRegistry() + PedroStructs.registerAll(registry) + + registry.schemaOf() shouldBe PedroStructs.pose + registry.schemaOf() shouldBe PedroStructs.twist + } + }) diff --git a/robot/src/main/kotlin/dev/nextftc/robot/GlobalLog.kt b/robot/src/main/kotlin/dev/nextftc/robot/GlobalLog.kt index 0e153e5e..e7693aa1 100644 --- a/robot/src/main/kotlin/dev/nextftc/robot/GlobalLog.kt +++ b/robot/src/main/kotlin/dev/nextftc/robot/GlobalLog.kt @@ -26,10 +26,7 @@ internal object GlobalLog { /** * 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 { + fun setErrorAndThrow(message: String, exception: NextFTCException = NextFTCException(message)): Nothing { setError(message) throw exception } diff --git a/telemetry/Module.md b/telemetry/Module.md index 7e8fbaaa..e1be230d 100644 --- a/telemetry/Module.md +++ b/telemetry/Module.md @@ -2,7 +2,8 @@ Logging and live telemetry for FTC robot code, in formats AdvantageScope understands. It has no FTC SDK or Android dependency, so it runs in unit tests or a desktop tool as easily as on a Robot -Controller. Its only NextFTC dependency is `util`, which supplies the struct schemas. +Controller. It depends on `util`, which supplies the struct schemas, and on `control`, whose +geometry types are registered as structs. Robot code logs to `Telemetry`, a table, or a typed handle, and the values go to whatever backends are registered: a WPILOG file, a NetworkTables 4 server, or a mock for tests. A model of WPILib's diff --git a/telemetry/build.gradle.kts b/telemetry/build.gradle.kts index c63e1923..4c17f62b 100644 --- a/telemetry/build.gradle.kts +++ b/telemetry/build.gradle.kts @@ -8,6 +8,7 @@ description = dependencies { api(project(":util")) + api(project(":control")) implementation(libs.kotlin.reflect) api(libs.ktor.server.core) implementation(libs.ktor.server.cio) diff --git a/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryRegistry.kt b/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryRegistry.kt index 414b88cc..c7745bc1 100644 --- a/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryRegistry.kt +++ b/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryRegistry.kt @@ -8,6 +8,14 @@ package dev.nextftc.telemetry +import dev.nextftc.control.geometry.ChassisAccelerations +import dev.nextftc.control.geometry.ChassisVelocities +import dev.nextftc.control.geometry.Pose2d +import dev.nextftc.control.geometry.PoseAcceleration2d +import dev.nextftc.control.geometry.PoseVelocity2d +import dev.nextftc.control.geometry.Rotation2d +import dev.nextftc.control.geometry.Transform2d +import dev.nextftc.control.geometry.Twist2d import dev.nextftc.util.struct.StructSchema import dev.nextftc.util.struct.StructSchemaRegistry import java.util.Collections @@ -62,8 +70,19 @@ constructor( require(!(timeSource.markNow() - origin).isNegative()) { "The origin is in the future" } } - /** Builds and keeps the schemas of the classes that are logged as structs. */ - val structs = StructSchemaRegistry() + /** + * Builds and keeps the schemas of the classes that are logged as structs. By default this includes the `control` geometry types. + */ + val structs = StructSchemaRegistry().apply { + register(Rotation2d::class, Rotation2d.struct) + register(Pose2d::class, Pose2d.struct) + register(Transform2d::class, Transform2d.struct) + register(Twist2d::class, Twist2d.struct) + register(ChassisVelocities::class, ChassisVelocities.struct) + register(ChassisAccelerations::class, ChassisAccelerations.struct) + register(PoseVelocity2d::class, PoseVelocity2d.struct) + register(PoseAcceleration2d::class, PoseAcceleration2d.struct) + } /** Returns the current time as a timestamp in microseconds since [origin]. */ fun now(): Long = timestampOf(timeSource.markNow()) diff --git a/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryTable.kt b/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryTable.kt index 910fba9f..2e103083 100644 --- a/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryTable.kt +++ b/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryTable.kt @@ -8,6 +8,10 @@ package dev.nextftc.telemetry +import dev.nextftc.units.Measure +import dev.nextftc.units.Unit +import dev.nextftc.units.unittypes.MulUnit +import dev.nextftc.units.unittypes.PerUnit import dev.nextftc.util.struct.StructSchema import java.util.concurrent.ConcurrentHashMap import kotlin.reflect.KClass @@ -317,8 +321,9 @@ class TelemetryTable internal constructor(private val registry: TelemetryRegistr * * Numbers, booleans, strings, and primitive arrays go to the topics above, with a `Byte` or * `Short` logged as an integer. An array of objects is logged as an array of structs, or of - * strings. A [TelemetryLoggable] is logged into the child table [name]. Anything else is logged as - * a struct. + * strings. A [Measure] is logged as a `double` in its base unit, with that unit's symbol as the + * topic's unit, so `12.0.inches` is logged as `0.3048` in `m`. A [TelemetryLoggable] is logged into + * the child table [name]. Anything else is logged as a struct. * * A collection or map is rejected with a warning, because its element type is erased. Use * [arrayTopic] or [logArray] for a list. @@ -346,6 +351,7 @@ class TelemetryTable internal constructor(private val registry: TelemetryRegistr is LongArray -> log(name, value) is FloatArray -> log(name, value) is DoubleArray -> log(name, value) + is Measure<*> -> logMeasure(name, value) is TelemetryLoggable -> value.logTo(table(name)) is Collection<*>, is Map<*, *>, @@ -359,6 +365,20 @@ class TelemetryTable internal constructor(private val registry: TelemetryRegistr } } + // Measures are logged using their base unit + private fun logMeasure(name: String, value: Measure<*>) { + val topic = doubleTopic(name) + if (topic.unit == null) topic.unit = baseSymbolOf(value.unit) + topic.set(value.baseUnitMagnitude) + } + + // The base unit of compound unit types are built from the base unit of their components + private fun baseSymbolOf(unit: Unit<*>): String = when (unit) { + is PerUnit<*, *> -> "${baseSymbolOf(unit.numerator)}/${baseSymbolOf(unit.denominator)}" + is MulUnit<*, *> -> "${baseSymbolOf(unit.first)}⋅${baseSymbolOf(unit.second)}" + else -> unit.baseUnit.symbol() + } + @Suppress("UNCHECKED_CAST") private fun logObjectArray(name: String, value: Array<*>) { val element = value.javaClass.componentType diff --git a/telemetry/src/main/kotlin/dev/nextftc/telemetry/nt/NT4Server.kt b/telemetry/src/main/kotlin/dev/nextftc/telemetry/nt/NT4Server.kt index a4c683bc..3597b138 100644 --- a/telemetry/src/main/kotlin/dev/nextftc/telemetry/nt/NT4Server.kt +++ b/telemetry/src/main/kotlin/dev/nextftc/telemetry/nt/NT4Server.kt @@ -8,8 +8,8 @@ package dev.nextftc.telemetry.nt -import dev.nextftc.util.struct.StructSchema import dev.nextftc.telemetry.toJsonObject +import dev.nextftc.util.struct.StructSchema import io.ktor.http.HttpStatusCode import io.ktor.server.application.install import io.ktor.server.application.serverConfig diff --git a/telemetry/src/main/kotlin/dev/nextftc/telemetry/wpilog/WPILogBackend.kt b/telemetry/src/main/kotlin/dev/nextftc/telemetry/wpilog/WPILogBackend.kt index a6e272b5..0793f1cd 100644 --- a/telemetry/src/main/kotlin/dev/nextftc/telemetry/wpilog/WPILogBackend.kt +++ b/telemetry/src/main/kotlin/dev/nextftc/telemetry/wpilog/WPILogBackend.kt @@ -13,8 +13,8 @@ import dev.nextftc.telemetry.TelemetryBackend import dev.nextftc.telemetry.TelemetryEntry import dev.nextftc.telemetry.TelemetryType import dev.nextftc.telemetry.ValueType -import dev.nextftc.util.struct.StructSchema import dev.nextftc.telemetry.toJsonObject +import dev.nextftc.util.struct.StructSchema import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import java.util.concurrent.ConcurrentHashMap diff --git a/telemetry/src/simulator/kotlin/dev/nextftc/telemetry/sim/LiveSimulator.kt b/telemetry/src/simulator/kotlin/dev/nextftc/telemetry/sim/LiveSimulator.kt index 247af39c..d16c9e09 100644 --- a/telemetry/src/simulator/kotlin/dev/nextftc/telemetry/sim/LiveSimulator.kt +++ b/telemetry/src/simulator/kotlin/dev/nextftc/telemetry/sim/LiveSimulator.kt @@ -16,11 +16,11 @@ import dev.nextftc.telemetry.TelemetryWarningHandler import dev.nextftc.telemetry.arrayTopic import dev.nextftc.telemetry.nt.NT4Backend import dev.nextftc.telemetry.nt.NT4Server -import dev.nextftc.util.struct.StructName import dev.nextftc.telemetry.topic import dev.nextftc.telemetry.withUnit import dev.nextftc.telemetry.wpilog.WPILogBackend import dev.nextftc.telemetry.wpilog.WPILogWriter +import dev.nextftc.util.struct.StructName import java.io.File import java.io.FileOutputStream import java.text.SimpleDateFormat diff --git a/telemetry/src/test/kotlin/dev/nextftc/telemetry/TelemetryRegistryTest.kt b/telemetry/src/test/kotlin/dev/nextftc/telemetry/TelemetryRegistryTest.kt index 1758b131..9b992c3b 100644 --- a/telemetry/src/test/kotlin/dev/nextftc/telemetry/TelemetryRegistryTest.kt +++ b/telemetry/src/test/kotlin/dev/nextftc/telemetry/TelemetryRegistryTest.kt @@ -8,9 +8,20 @@ package dev.nextftc.telemetry +import dev.nextftc.control.geometry.ChassisAccelerations +import dev.nextftc.control.geometry.ChassisVelocities +import dev.nextftc.control.geometry.Pose2d +import dev.nextftc.control.geometry.PoseAcceleration2d +import dev.nextftc.control.geometry.PoseVelocity2d +import dev.nextftc.control.geometry.Rotation2d +import dev.nextftc.control.geometry.Transform2d +import dev.nextftc.control.geometry.Twist2d import dev.nextftc.telemetry.backend.MockTelemetryBackend import dev.nextftc.telemetry.nt.NT4Server import dev.nextftc.telemetry.wpilog.WPILogWriter +import dev.nextftc.units.degrees +import dev.nextftc.units.inches +import dev.nextftc.units.inchesPerSecond import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe @@ -21,6 +32,11 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.TestTimeSource +private fun Double.roundTo(digits: Int): Double { + val scale = Math.pow(10.0, digits.toDouble()) + return Math.round(this * scale) / scale +} + private class Env { val clock = TestTimeSource() val registry = TelemetryRegistry(clock) @@ -60,6 +76,37 @@ private class ClosingBackend(private val fail: Boolean = false) : TelemetryBacke class TelemetryRegistryTest : FunSpec({ + test("the control geometry types are registered as structs") { + val structs = TelemetryRegistry().structs + + structs.schemaOf() shouldBe Pose2d.struct + structs.schemaOf() shouldBe Rotation2d.struct + structs.schemaOf() shouldBe Transform2d.struct + structs.schemaOf() shouldBe Twist2d.struct + structs.schemaOf() shouldBe ChassisVelocities.struct + structs.schemaOf() shouldBe ChassisAccelerations.struct + structs.schemaOf() shouldBe PoseVelocity2d.struct + structs.schemaOf() shouldBe PoseAcceleration2d.struct + } + + test("a measure is logged as a double in its base unit, labeled with that unit") { + val env = Env() + val mock = MockTelemetryBackend().also { env.registry.register(it) } + + env.registry.root.log("distance", 100.0.inches) + env.registry.root.log("speed", 100.0.inchesPerSecond) + env.registry.root.log("angle", 180.0.degrees) + env.registry.root.log("distance", 200.0.inches) + + mock.valuesAt("/distance").map { (it as Double).roundTo(6) } shouldBe listOf(2.54, 5.08) + (mock.valuesAt("/speed").single() as Double).roundTo(6) shouldBe 2.54 + (mock.valuesAt("/angle").single() as Double).roundTo(6) shouldBe 3.141593 + mock.propertiesAt("/distance") shouldBe mapOf("unit" to "m") + mock.propertiesAt("/speed") shouldBe mapOf("unit" to "m/s") + mock.propertiesAt("/angle") shouldBe mapOf("unit" to "rad") + env.warnings shouldBe emptyList() + } + test("logging with no backend registered is harmless") { val env = Env() val handle = env.registry.root.doubleTopic("x") diff --git a/telemetry/src/test/kotlin/dev/nextftc/telemetry/backend/NT4BackendTest.kt b/telemetry/src/test/kotlin/dev/nextftc/telemetry/backend/NT4BackendTest.kt index 4cde87f1..e029d30c 100644 --- a/telemetry/src/test/kotlin/dev/nextftc/telemetry/backend/NT4BackendTest.kt +++ b/telemetry/src/test/kotlin/dev/nextftc/telemetry/backend/NT4BackendTest.kt @@ -21,9 +21,9 @@ import dev.nextftc.telemetry.nt.nextValue import dev.nextftc.telemetry.nt.sendText import dev.nextftc.telemetry.nt.string import dev.nextftc.telemetry.nt.subscribeMessage -import dev.nextftc.util.struct.structSchema import dev.nextftc.telemetry.withProperties import dev.nextftc.telemetry.withUnit +import dev.nextftc.util.struct.structSchema import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.shouldBe diff --git a/telemetry/src/test/kotlin/dev/nextftc/telemetry/backend/WPILogBackendTest.kt b/telemetry/src/test/kotlin/dev/nextftc/telemetry/backend/WPILogBackendTest.kt index 016e2b48..4e30d82f 100644 --- a/telemetry/src/test/kotlin/dev/nextftc/telemetry/backend/WPILogBackendTest.kt +++ b/telemetry/src/test/kotlin/dev/nextftc/telemetry/backend/WPILogBackendTest.kt @@ -10,11 +10,11 @@ package dev.nextftc.telemetry.backend import dev.nextftc.telemetry.TelemetryRegistry import dev.nextftc.telemetry.ValueType -import dev.nextftc.util.struct.structSchema import dev.nextftc.telemetry.withUnit import dev.nextftc.telemetry.wpilog.ParsedLog import dev.nextftc.telemetry.wpilog.WPILogBackend import dev.nextftc.telemetry.wpilog.WPILogWriter +import dev.nextftc.util.struct.structSchema import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContainExactly diff --git a/telemetry/src/test/kotlin/dev/nextftc/telemetry/nt/NT4ServerTest.kt b/telemetry/src/test/kotlin/dev/nextftc/telemetry/nt/NT4ServerTest.kt index 731e948a..e1fad60f 100644 --- a/telemetry/src/test/kotlin/dev/nextftc/telemetry/nt/NT4ServerTest.kt +++ b/telemetry/src/test/kotlin/dev/nextftc/telemetry/nt/NT4ServerTest.kt @@ -8,8 +8,8 @@ package dev.nextftc.telemetry.nt -import dev.nextftc.util.struct.structSchema import dev.nextftc.telemetry.wpilog.WPILogWriter +import dev.nextftc.util.struct.structSchema import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContainExactly diff --git a/telemetry/src/test/kotlin/dev/nextftc/telemetry/struct/StructSchemaRegistryTest.kt b/telemetry/src/test/kotlin/dev/nextftc/telemetry/struct/StructSchemaRegistryTest.kt index 49d51b53..298a7cec 100644 --- a/telemetry/src/test/kotlin/dev/nextftc/telemetry/struct/StructSchemaRegistryTest.kt +++ b/telemetry/src/test/kotlin/dev/nextftc/telemetry/struct/StructSchemaRegistryTest.kt @@ -180,7 +180,7 @@ class StructSchemaRegistryTest : schema.nested.single() shouldBe custom } - test("registering a type that already has a schema is rejected") { + test("registering a type that already has a different schema is rejected") { val registry = StructSchemaRegistry() registry.schemaOf() @@ -189,6 +189,16 @@ class StructSchemaRegistryTest : } } + test("registering an identical schema for a type that already has one does nothing") { + val registry = StructSchemaRegistry() + val first = registry.schemaOf() + + registry.register(Vec2::class, first) + registry.register(Vec2::class, StructSchemaRegistry().schemaOf()) + + registry.schemaOf() shouldBe first + } + test("a nullable property is rejected, naming the property") { shouldThrow { StructSchemaRegistry().schemaOf() } .message!! shouldContain "Nullable.x" diff --git a/util/src/main/kotlin/dev/nextftc/util/struct/StructSchemaRegistry.kt b/util/src/main/kotlin/dev/nextftc/util/struct/StructSchemaRegistry.kt index df3dcdd6..80316a16 100644 --- a/util/src/main/kotlin/dev/nextftc/util/struct/StructSchemaRegistry.kt +++ b/util/src/main/kotlin/dev/nextftc/util/struct/StructSchemaRegistry.kt @@ -68,16 +68,28 @@ class StructSchemaRegistry { * Uses [schema] for [type], both when it is requested directly and when it appears as a field of * a reflected class. Use it for a class you can't annotate or that reflection can't handle. * - * @throws IllegalArgumentException if [type] already has a schema, whether registered or reflected + * Registering a schema identical to the one [type] already has does nothing. Two schemas are + * identical when they have the same type name, size and schema text. + * + * @throws IllegalArgumentException if [type] already has a different schema, whether registered or + * reflected */ @Synchronized fun register(type: KClass, schema: StructSchema) { - require(type !in schemas) { - "${type.qualifiedName} already has a struct schema, so it can't be registered again" + val existing = schemas[type] + if (existing == null) { + schemas[type] = schema + return + } + require(existing.isIdenticalTo(schema)) { + "${type.qualifiedName} already has a different struct schema (${existing.typeString}: " + + "${existing.schema}), so ${schema.typeString}: ${schema.schema} can't be registered for it" } - schemas[type] = schema } + private fun StructSchema<*>.isIdenticalTo(other: StructSchema<*>) = + typeName == other.typeName && size == other.size && schema == other.schema + /** * Returns the schema for [type], registered or reflected. * From 2f6ecac96e4742a4a471e0ffeb5c52f29671c632 Mon Sep 17 00:00:00 2001 From: Zach Harel Date: Tue, 10 Nov 2026 14:19:01 -0500 Subject: [PATCH 3/3] add type handler support instead of special-casing Measure Signed-off-by: Zach Harel --- telemetry/Module.md | 4 +- .../nextftc/telemetry/TelemetryRegistry.kt | 6 + .../dev/nextftc/telemetry/TelemetryTable.kt | 57 ++++---- .../nextftc/telemetry/TelemetryTypeHandler.kt | 122 ++++++++++++++++++ .../telemetry/TelemetryRegistryTest.kt | 61 +++++++++ .../util/struct/StructSchemaRegistry.kt | 15 +++ 6 files changed, 235 insertions(+), 30 deletions(-) create mode 100644 telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryTypeHandler.kt diff --git a/telemetry/Module.md b/telemetry/Module.md index e1be230d..6023ca61 100644 --- a/telemetry/Module.md +++ b/telemetry/Module.md @@ -28,7 +28,9 @@ timestamp, so a value means the same moment in the log and on the wire. `Telemetry.log("Drive/Pose", pose)`, or hold a typed handle for the fast path: `DoubleTopic`, `IntTopic`, `LongTopic`, `FloatTopic` and `BooleanTopic` for numbers and booleans, and `Topic` for strings, arrays, structs, and lists of structs. `TelemetryTable` groups related -topics under a path, and `TelemetryLoggable` lets an object publish its own fields. +topics under a path, and `TelemetryLoggable` lets an object publish its own fields. For a type you +don't own, register a `TelemetryTypeHandler` in `TelemetryRegistry.handlers`; `Measure` has one by +default, which logs the base-unit value with its unit. The registry stamps each value, sends it to every registered backend (or only those registered for a path prefix), and reports problems through a warning handler instead of throwing. A path keeps one diff --git a/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryRegistry.kt b/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryRegistry.kt index c7745bc1..bdfd04de 100644 --- a/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryRegistry.kt +++ b/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryRegistry.kt @@ -84,6 +84,12 @@ constructor( register(PoseAcceleration2d::class, PoseAcceleration2d.struct) } + /** + * The handlers that log values of types that can't log themselves. By default this includes one for + * `Measure`. + */ + val handlers = TelemetryTypeHandlers() + /** Returns the current time as a timestamp in microseconds since [origin]. */ fun now(): Long = timestampOf(timeSource.markNow()) diff --git a/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryTable.kt b/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryTable.kt index 2e103083..67b4445c 100644 --- a/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryTable.kt +++ b/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryTable.kt @@ -8,10 +8,6 @@ package dev.nextftc.telemetry -import dev.nextftc.units.Measure -import dev.nextftc.units.Unit -import dev.nextftc.units.unittypes.MulUnit -import dev.nextftc.units.unittypes.PerUnit import dev.nextftc.util.struct.StructSchema import java.util.concurrent.ConcurrentHashMap import kotlin.reflect.KClass @@ -321,9 +317,9 @@ class TelemetryTable internal constructor(private val registry: TelemetryRegistr * * Numbers, booleans, strings, and primitive arrays go to the topics above, with a `Byte` or * `Short` logged as an integer. An array of objects is logged as an array of structs, or of - * strings. A [Measure] is logged as a `double` in its base unit, with that unit's symbol as the - * topic's unit, so `12.0.inches` is logged as `0.3048` in `m`. A [TelemetryLoggable] is logged into - * the child table [name]. Anything else is logged as a struct. + * strings. A value with a [TelemetryTypeHandler] registered for its type, such as a `Measure`, is + * logged by that handler. A [TelemetryLoggable] is logged into the child table [name]. Anything + * else is logged as a struct. * * A collection or map is rejected with a warning, because its element type is erased. Use * [arrayTopic] or [logArray] for a list. @@ -351,32 +347,35 @@ class TelemetryTable internal constructor(private val registry: TelemetryRegistr is LongArray -> log(name, value) is FloatArray -> log(name, value) is DoubleArray -> log(name, value) - is Measure<*> -> logMeasure(name, value) - is TelemetryLoggable -> value.logTo(table(name)) - is Collection<*>, - is Map<*, *>, - -> - registry.warn( - "Could not log '${pathOf(name)}': a ${value.javaClass.simpleName} has an erased element " + - "type. Use arrayTopic or logArray to log a list of structs.", - ) - is Array<*> -> logObjectArray(name, value) - else -> topic(name, value.javaClass.kotlin).set(value) + else -> logObject(name, value) } } - // Measures are logged using their base unit - private fun logMeasure(name: String, value: Measure<*>) { - val topic = doubleTopic(name) - if (topic.unit == null) topic.unit = baseSymbolOf(value.unit) - topic.set(value.baseUnitMagnitude) - } + // Everything without a fixed encoding: a registered handler first, then the value's own way of + // logging itself, then as a struct. + private fun logObject(name: String, value: Any) { + val handler = registry.handlers.find(value.javaClass) + if (handler != null) { + handler.log(this, name, value) + return + } + + val struct = registry.structs.existingSchemaOf(value.javaClass) + if (struct != null) { + topic(name, struct).set(value) + return + } - // The base unit of compound unit types are built from the base unit of their components - private fun baseSymbolOf(unit: Unit<*>): String = when (unit) { - is PerUnit<*, *> -> "${baseSymbolOf(unit.numerator)}/${baseSymbolOf(unit.denominator)}" - is MulUnit<*, *> -> "${baseSymbolOf(unit.first)}⋅${baseSymbolOf(unit.second)}" - else -> unit.baseUnit.symbol() + when (value) { + is TelemetryLoggable -> value.logTo(table(name)) + is Collection<*>, is Map<*, *> -> registry.warn( + "Could not log '${pathOf(name)}': a ${value.javaClass.simpleName} has an erased element " + + "type. Use arrayTopic or logArray to log a list of structs.", + ) + + is Array<*> -> logObjectArray(name, value) + else -> topic(name, value.javaClass.kotlin).set(value) + } } @Suppress("UNCHECKED_CAST") diff --git a/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryTypeHandler.kt b/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryTypeHandler.kt new file mode 100644 index 00000000..f5f09c3c --- /dev/null +++ b/telemetry/src/main/kotlin/dev/nextftc/telemetry/TelemetryTypeHandler.kt @@ -0,0 +1,122 @@ +/* + * 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.telemetry + +import dev.nextftc.units.Measure +import dev.nextftc.units.Unit +import dev.nextftc.units.unittypes.MulUnit +import dev.nextftc.units.unittypes.PerUnit +import java.util.concurrent.ConcurrentHashMap +import kotlin.reflect.KClass + +/** + * Knows how to publish values of a type that can't publish themselves, such as one from a library. + * + * It is to [TelemetryLoggable] what `Comparator` is to `Comparable`: [TelemetryLoggable] is + * implemented by the type, and a handler is supplied from outside for a type you don't own. Register + * one with [TelemetryTypeHandlers.register], and [TelemetryTable.log] uses it for values of that type. + * + * ```kotlin + * Telemetry.registry.handlers.register { table, name, color -> + * table.log(name, color.toHexString()) + * } + * ``` + */ +fun interface TelemetryTypeHandler { + /** Logs [value] under [name] in [table]. */ + fun log(table: TelemetryTable, name: String, value: T) +} + +/** + * The [TelemetryTypeHandler]s of a [TelemetryRegistry], by the type they handle. + * + * A handler applies to its type and to every subtype. For a value, the handler registered for the + * nearest type wins: its own class, then its superclasses from the closest, then its interfaces. A + * handler beats the value's own [TelemetryLoggable], like a `Comparator` beats natural ordering, + * but not the types that always have a fixed encoding, such as numbers, strings and primitive + * arrays. + * + * Handlers for `Measure` are registered by default. + */ +class TelemetryTypeHandlers internal constructor() { + private val handlers = ConcurrentHashMap, TelemetryTypeHandler<*>>() + + // The handler found for each class that has been looked up, with a marker for "none". + private val resolved = ConcurrentHashMap, Any>() + + init { + register(Measure::class, MeasureHandler) + } + + /** Uses [handler] for values of [type] and its subtypes, replacing the one registered for [type]. */ + fun register(type: KClass, handler: TelemetryTypeHandler) { + handlers[type.java] = handler + resolved.clear() + } + + /** Uses [handler] for values of [T] and its subtypes, replacing the one registered for [T]. */ + inline fun register(handler: TelemetryTypeHandler) = register(T::class, handler) + + /** Stops using the handler registered for [type], if any. */ + fun unregister(type: KClass<*>) { + handlers.remove(type.java) + resolved.clear() + } + + /** Returns the handler for values of [type], or null if there isn't one. */ + fun find(type: Class<*>): TelemetryTypeHandler? { + val handler = resolved.getOrPut(type) { search(type) ?: None } + @Suppress("UNCHECKED_CAST") + return if (handler === None) null else handler as TelemetryTypeHandler + } + + private fun search(type: Class<*>): TelemetryTypeHandler<*>? { + var current: Class<*>? = type + while (current != null) { + handlers[current]?.let { return it } + current = current.superclass + } + // Interfaces, breadth first from the type's own, so the closest wins. + val queue = ArrayDeque>() + var cls: Class<*>? = type + while (cls != null) { + queue.addAll(cls.interfaces) + cls = cls.superclass + } + val seen = HashSet>() + while (queue.isNotEmpty()) { + val next = queue.removeFirst() + if (!seen.add(next)) continue + handlers[next]?.let { return it } + queue.addAll(next.interfaces) + } + return null + } + + private object None +} + +/** + * Logs a [Measure] as a `double` in its base unit, with that unit's symbol as the topic's unit, so + * `12.0.inches` is logged as `0.3048` in `m`. + */ +private object MeasureHandler : TelemetryTypeHandler> { + override fun log(table: TelemetryTable, name: String, value: Measure<*>) { + val topic = table.doubleTopic(name) + if (topic.unit == null) topic.unit = baseSymbolOf(value.unit) + topic.set(value.baseUnitMagnitude) + } + + // Uses component unit symbols for compound units + private fun baseSymbolOf(unit: Unit<*>): String = when (unit) { + is PerUnit<*, *> -> "${baseSymbolOf(unit.numerator)}/${baseSymbolOf(unit.denominator)}" + is MulUnit<*, *> -> "${baseSymbolOf(unit.first)}⋅${baseSymbolOf(unit.second)}" + else -> unit.baseUnit.symbol() + } +} diff --git a/telemetry/src/test/kotlin/dev/nextftc/telemetry/TelemetryRegistryTest.kt b/telemetry/src/test/kotlin/dev/nextftc/telemetry/TelemetryRegistryTest.kt index 9b992c3b..75a6bbd6 100644 --- a/telemetry/src/test/kotlin/dev/nextftc/telemetry/TelemetryRegistryTest.kt +++ b/telemetry/src/test/kotlin/dev/nextftc/telemetry/TelemetryRegistryTest.kt @@ -74,6 +74,20 @@ private class ClosingBackend(private val fail: Boolean = false) : TelemetryBacke } } +private open class HandledColor(val hex: String) + +private class SubColor(hex: String) : HandledColor(hex) + +private interface Named { + val label: String +} + +private class NamedThing(override val label: String) : Named + +private class SelfLogging : TelemetryLoggable { + override fun logTo(table: TelemetryTable) = table.log("self", "own") +} + class TelemetryRegistryTest : FunSpec({ test("the control geometry types are registered as structs") { @@ -107,6 +121,53 @@ class TelemetryRegistryTest : env.warnings shouldBe emptyList() } + test("a registered handler logs values of its type") { + val env = Env() + val mock = MockTelemetryBackend().also { env.registry.register(it) } + env.registry.handlers.register { table, name, color -> table.log(name, color.hex) } + + env.registry.root.log("color", HandledColor("#ff0000")) + + mock.valuesAt("/color") shouldBe listOf("#ff0000") + env.warnings shouldBe emptyList() + } + + test("a handler applies to subtypes, and the nearest type's handler wins") { + val env = Env() + val mock = MockTelemetryBackend().also { env.registry.register(it) } + env.registry.handlers.register { table, name, _ -> table.log(name, "base") } + + env.registry.root.log("sub", SubColor("#00ff00")) + env.registry.handlers.register { table, name, _ -> table.log(name, "sub") } + env.registry.root.log("sub2", SubColor("#00ff00")) + + mock.valuesAt("/sub") shouldBe listOf("base") + mock.valuesAt("/sub2") shouldBe listOf("sub") + } + + test("a handler for an interface applies to its implementers") { + val env = Env() + val mock = MockTelemetryBackend().also { env.registry.register(it) } + env.registry.handlers.register { table, name, value -> table.log(name, value.label) } + + env.registry.root.log("n", NamedThing("thing")) + + mock.valuesAt("/n") shouldBe listOf("thing") + } + + test("a handler beats the value's own TelemetryLoggable, and unregistering restores it") { + val env = Env() + val mock = MockTelemetryBackend().also { env.registry.register(it) } + env.registry.handlers.register { table, name, _ -> table.log(name, "handler") } + + env.registry.root.log("a", SelfLogging()) + env.registry.handlers.unregister(SelfLogging::class) + env.registry.root.log("b", SelfLogging()) + + mock.valuesAt("/a") shouldBe listOf("handler") + mock.valuesAt("/b/self") shouldBe listOf("own") + } + test("logging with no backend registered is harmless") { val env = Env() val handle = env.registry.root.doubleTopic("x") diff --git a/util/src/main/kotlin/dev/nextftc/util/struct/StructSchemaRegistry.kt b/util/src/main/kotlin/dev/nextftc/util/struct/StructSchemaRegistry.kt index 80316a16..6a511e70 100644 --- a/util/src/main/kotlin/dev/nextftc/util/struct/StructSchemaRegistry.kt +++ b/util/src/main/kotlin/dev/nextftc/util/struct/StructSchemaRegistry.kt @@ -90,6 +90,21 @@ class StructSchemaRegistry { private fun StructSchema<*>.isIdenticalTo(other: StructSchema<*>) = typeName == other.typeName && size == other.size && schema == other.schema + /** + * Returns the schema for [type] if it already exists, or null otherwise. + */ + @Suppress("UNCHECKED_CAST") + @Synchronized + fun existingSchemaOf(type: KClass): StructSchema? = schemas[type] as? StructSchema + + /** + * Returns the schema for [type] if it already exists, or null otherwise. + */ + @Suppress("UNCHECKED_CAST") + @Synchronized + fun existingSchemaOf(type: Class): StructSchema? = + schemas[type.kotlin] as? StructSchema + /** * Returns the schema for [type], registered or reflected. *