diff --git a/docs/build.gradle b/docs/build.gradle index fa07e96b06..49b8f4cf7d 100644 --- a/docs/build.gradle +++ b/docs/build.gradle @@ -6,6 +6,7 @@ plugins { evaluationDependsOn(':apriltag') evaluationDependsOn(':cameraserver') evaluationDependsOn(':cscore') +evaluationDependsOn(':epilogue-runtime') evaluationDependsOn(':hal') evaluationDependsOn(':ntcore') evaluationDependsOn(':wpilibNewCommands') @@ -234,6 +235,7 @@ task generateJavaDocs(type: Javadoc) { source project(':apriltag').sourceSets.main.java source project(':cameraserver').sourceSets.main.java source project(':cscore').sourceSets.main.java + source project(':epilogue-runtime').sourceSets.main.java source project(':hal').sourceSets.main.java source project(':ntcore').sourceSets.main.java source project(':wpilibNewCommands').sourceSets.main.java diff --git a/epilogue-processor/build.gradle b/epilogue-processor/build.gradle new file mode 100644 index 0000000000..a3dcc8d07c --- /dev/null +++ b/epilogue-processor/build.gradle @@ -0,0 +1,19 @@ +ext { + useJava = true + useCpp = false + baseId = 'epilogue-processor' + groupId = 'edu.wpi.first.epilogue' + + devMain = '' +} + +apply from: "${rootDir}/shared/java/javacommon.gradle" + +dependencies { + implementation(project(':epilogue-runtime')) + api project(':wpilibNewCommands') + + implementation 'com.google.auto.service:auto-service:1.1.1' + annotationProcessor 'com.google.auto.service:auto-service:1.1.1' + testImplementation 'com.google.testing.compile:compile-testing:+' +} diff --git a/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/AnnotationProcessor.java b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/AnnotationProcessor.java new file mode 100644 index 0000000000..5626c341dc --- /dev/null +++ b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/AnnotationProcessor.java @@ -0,0 +1,413 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.processor; + +import com.google.auto.service.AutoService; +import edu.wpi.first.epilogue.CustomLoggerFor; +import edu.wpi.first.epilogue.Logged; +import edu.wpi.first.epilogue.NotLogged; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.Processor; +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.annotation.processing.SupportedSourceVersion; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.AnnotationValue; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.NoType; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import javax.tools.Diagnostic; + +@SupportedAnnotationTypes({ + "edu.wpi.first.epilogue.CustomLoggerFor", + "edu.wpi.first.epilogue.Logged" +}) +@SupportedSourceVersion(SourceVersion.RELEASE_17) +@AutoService(Processor.class) +public class AnnotationProcessor extends AbstractProcessor { + private static final String kCustomLoggerFqn = "edu.wpi.first.epilogue.CustomLoggerFor"; + private static final String kClassSpecificLoggerFqn = + "edu.wpi.first.epilogue.logging.ClassSpecificLogger"; + private static final String kLoggedFqn = "edu.wpi.first.epilogue.Logged"; + + private EpilogueGenerator m_epiloguerGenerator; + private LoggerGenerator m_loggerGenerator; + private List m_handlers; + + @Override + public boolean process(Set annotations, RoundEnvironment roundEnv) { + if (annotations.isEmpty()) { + // Nothing to do, don't claim + return false; + } + + Map customLoggers = new HashMap<>(); + + annotations.stream() + .filter(ann -> kCustomLoggerFqn.contentEquals(ann.getQualifiedName())) + .findAny() + .ifPresent( + customLogger -> { + customLoggers.putAll(processCustomLoggers(roundEnv, customLogger)); + }); + + roundEnv.getRootElements().stream() + .filter( + e -> + processingEnv + .getTypeUtils() + .isAssignable( + e.asType(), + processingEnv + .getTypeUtils() + .erasure( + processingEnv + .getElementUtils() + .getTypeElement(kClassSpecificLoggerFqn) + .asType()))) + .filter(e -> e.getAnnotation(CustomLoggerFor.class) == null) + .forEach( + e -> { + processingEnv + .getMessager() + .printMessage( + Diagnostic.Kind.ERROR, + "Custom logger classes should have a @CustomLoggerFor annotation", + e); + }); + + // Handlers are declared in order of priority. If an element could be logged in more than one + // way (eg a class implements both Sendable and StructSerializable), the order of the handlers + // in this list will determine how it gets logged. + m_handlers = + List.of( + new LoggableHandler(processingEnv), // prioritize epilogue logging over Sendable + new ConfiguredLoggerHandler( + processingEnv, customLoggers), // then customized logging configs + new ArrayHandler(processingEnv), + new CollectionHandler(processingEnv), + new EnumHandler(processingEnv), + new MeasureHandler(processingEnv), + new PrimitiveHandler(processingEnv), + new SupplierHandler(processingEnv), + new StructHandler(processingEnv), // prioritize struct over sendable + new SendableHandler(processingEnv)); + + m_epiloguerGenerator = new EpilogueGenerator(processingEnv, customLoggers); + m_loggerGenerator = new LoggerGenerator(processingEnv, m_handlers); + + annotations.stream() + .filter(ann -> kLoggedFqn.contentEquals(ann.getQualifiedName())) + .findAny() + .ifPresent( + epilogue -> { + processEpilogue(roundEnv, epilogue); + }); + + return false; + } + + private boolean validateFields(Set annotatedElements) { + var fields = + annotatedElements.stream() + .filter(e -> e instanceof VariableElement) + .map(e -> (VariableElement) e) + .toList(); + + boolean valid = true; + + for (VariableElement field : fields) { + // Field is explicitly tagged + // And is not opted out of + if (field.getAnnotation(NotLogged.class) == null && isNotLoggable(field, field.asType())) { + // And is not of a loggable type + processingEnv + .getMessager() + .printMessage( + Diagnostic.Kind.ERROR, + "[EPILOGUE] You have opted in to logging on this field, " + + "but it is not a loggable data type!", + field); + valid = false; + } + } + return valid; + } + + private boolean validateMethods(Set annotatedElements) { + var methods = + annotatedElements.stream() + .filter(e -> e instanceof ExecutableElement) + .map(e -> (ExecutableElement) e) + .toList(); + + boolean valid = true; + + for (ExecutableElement method : methods) { + // Field is explicitly tagged + if (method.getAnnotation(NotLogged.class) == null) { + // And is not opted out of + if (isNotLoggable(method, method.getReturnType())) { + // And is not of a loggable type + processingEnv + .getMessager() + .printMessage( + Diagnostic.Kind.ERROR, + "[EPILOGUE] You have opted in to logging on this method, " + + "but it does not return a loggable data type!", + method); + valid = false; + } + + if (!method.getModifiers().contains(Modifier.PUBLIC)) { + // Only public methods can be logged + + processingEnv + .getMessager() + .printMessage( + Diagnostic.Kind.ERROR, "[EPILOGUE] Logged methods must be public", method); + + valid = false; + } + + if (method.getModifiers().contains(Modifier.STATIC)) { + processingEnv + .getMessager() + .printMessage( + Diagnostic.Kind.ERROR, "[EPILOGUE] Logged methods cannot be static", method); + + valid = false; + } + + if (method.getReturnType().getKind() == TypeKind.NONE) { + processingEnv + .getMessager() + .printMessage( + Diagnostic.Kind.ERROR, "[EPILOGUE] Logged methods cannot be void", method); + + valid = false; + } + + if (!method.getParameters().isEmpty()) { + processingEnv + .getMessager() + .printMessage( + Diagnostic.Kind.ERROR, + "[EPILOGUE] Logged methods cannot accept arguments", + method); + + valid = false; + } + } + } + return valid; + } + + /** + * Checks if a type is not loggable. + * + * @param type the type to check + */ + private boolean isNotLoggable(Element element, TypeMirror type) { + if (type instanceof NoType) { + // e.g. void, cannot log + return true; + } + + boolean loggable = m_handlers.stream().anyMatch(h -> h.isLoggable(element)); + + if (loggable) { + return false; + } + + processingEnv + .getMessager() + .printMessage( + Diagnostic.Kind.NOTE, + "[EPILOGUE] Excluded from logs because " + type + " is not a loggable data type", + element); + return true; + } + + @SuppressWarnings("unchecked") + private Map processCustomLoggers( + RoundEnvironment roundEnv, TypeElement customLoggerAnnotation) { + // map logged type to its custom logger, eg + // { Point.class => CustomPointLogger.class } + var customLoggers = new HashMap(); + + var annotatedElements = roundEnv.getElementsAnnotatedWith(customLoggerAnnotation); + + var loggerSuperClass = + processingEnv + .getElementUtils() + .getTypeElement("edu.wpi.first.epilogue.logging.ClassSpecificLogger"); + + for (Element annotatedElement : annotatedElements) { + List targetTypes = List.of(); + for (AnnotationMirror annotationMirror : annotatedElement.getAnnotationMirrors()) { + for (var entry : annotationMirror.getElementValues().entrySet()) { + if ("value".equals(entry.getKey().getSimpleName().toString())) { + targetTypes = (List) entry.getValue().getValue(); + } + } + } + + boolean hasPublicNoArgConstructor = + annotatedElement.getEnclosedElements().stream() + .anyMatch( + enclosedElement -> + enclosedElement instanceof ExecutableElement exe + && exe.getKind() == ElementKind.CONSTRUCTOR + && exe.getModifiers().contains(Modifier.PUBLIC) + && exe.getParameters().isEmpty()); + + if (!hasPublicNoArgConstructor) { + processingEnv + .getMessager() + .printMessage( + Diagnostic.Kind.ERROR, + "Logger classes must have a public no-argument constructor", + annotatedElement); + continue; + } + + for (AnnotationValue value : targetTypes) { + var targetType = (DeclaredType) value.getValue(); + var reflectedTarget = targetType.asElement(); + + // eg ClassSpecificLogger + var requiredSuperClass = + processingEnv + .getTypeUtils() + .getDeclaredType( + loggerSuperClass, + processingEnv.getTypeUtils().getWildcardType(null, reflectedTarget.asType())); + + if (customLoggers.containsKey(targetType)) { + processingEnv + .getMessager() + .printMessage( + Diagnostic.Kind.ERROR, + "Multiple custom loggers detected for type " + targetType, + annotatedElement); + continue; + } + + if (!processingEnv + .getTypeUtils() + .isAssignable(annotatedElement.asType(), requiredSuperClass)) { + processingEnv + .getMessager() + .printMessage( + Diagnostic.Kind.ERROR, + "Not a subclass of ClassSpecificLogger<" + targetType + ">", + annotatedElement); + continue; + } + + customLoggers.put(targetType, (DeclaredType) annotatedElement.asType()); + } + } + + return customLoggers; + } + + private void processEpilogue(RoundEnvironment roundEnv, TypeElement epilogueAnnotation) { + var annotatedElements = roundEnv.getElementsAnnotatedWith(epilogueAnnotation); + + List loggerClassNames = new ArrayList<>(); + var mainRobotClasses = new ArrayList(); + + // Used to check for a main robot class + var robotBaseClass = + processingEnv.getElementUtils().getTypeElement("edu.wpi.first.wpilibj.TimedRobot").asType(); + + boolean validFields = validateFields(annotatedElements); + boolean validMethods = validateMethods(annotatedElements); + + if (!(validFields && validMethods)) { + // Generate nothing and bail + return; + } + + var classes = + annotatedElements.stream() + .filter(e -> e instanceof TypeElement) + .map(e -> (TypeElement) e) + .toList(); + for (TypeElement clazz : classes) { + try { + warnOfNonLoggableElements(clazz); + m_loggerGenerator.writeLoggerFile(clazz); + + if (processingEnv.getTypeUtils().isAssignable(clazz.getSuperclass(), robotBaseClass)) { + mainRobotClasses.add(clazz); + } + + loggerClassNames.add(StringUtils.loggerClassName(clazz)); + } catch (IOException e) { + processingEnv + .getMessager() + .printMessage( + Diagnostic.Kind.ERROR, + "Could not write logger file for " + clazz.getQualifiedName(), + clazz); + e.printStackTrace(System.err); + } + } + + // Sort alphabetically + mainRobotClasses.sort(Comparator.comparing(c -> c.getSimpleName().toString())); + m_epiloguerGenerator.writeEpilogueFile(loggerClassNames, mainRobotClasses); + } + + private void warnOfNonLoggableElements(TypeElement clazz) { + var config = clazz.getAnnotation(Logged.class); + if (config.strategy() == Logged.Strategy.OPT_IN) { + // field and method validations will have already checked everything + return; + } + + for (Element element : clazz.getEnclosedElements()) { + if (element.getAnnotation(NotLogged.class) != null) { + // Explicitly opted out from, don't need to check + continue; + } + + if (element.getModifiers().contains(Modifier.STATIC)) { + // static elements are never logged + continue; + } + + if (element instanceof VariableElement v) { + // isNotLoggable will internally print a warning message + isNotLoggable(v, v.asType()); + } + + if (element instanceof ExecutableElement exe + && exe.getModifiers().contains(Modifier.PUBLIC) + && exe.getParameters().isEmpty()) { + // isNotLoggable will internally print a warning message + isNotLoggable(exe, exe.getReturnType()); + } + } + } +} diff --git a/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/ArrayHandler.java b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/ArrayHandler.java new file mode 100644 index 0000000000..dac0959bb2 --- /dev/null +++ b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/ArrayHandler.java @@ -0,0 +1,75 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.processor; + +import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.Element; +import javax.lang.model.type.ArrayType; +import javax.lang.model.type.PrimitiveType; +import javax.lang.model.type.TypeMirror; + +/** + * Arrays of bytes, ints, flats, doubles, booleans, Strings, and struct-serializable objects can be + * logged. No other array types - including multidimensional arrays - are loggable. + */ +public class ArrayHandler extends ElementHandler { + private final StructHandler m_structHandler; + private final TypeMirror m_javaLangString; + + protected ArrayHandler(ProcessingEnvironment processingEnv) { + super(processingEnv); + + // use a struct handler for managing struct arrays + m_structHandler = new StructHandler(processingEnv); + + m_javaLangString = lookupTypeElement(processingEnv, "java.lang.String").asType(); + } + + @Override + public boolean isLoggable(Element element) { + return dataType(element) instanceof ArrayType arr + && isLoggableComponentType(arr.getComponentType()); + } + + /** + * Checks if an array containing elements of the given type can be logged. + * + * @param type the data type to check + * @return true if an array like {@code type[]} can be logged, false otherwise + */ + public boolean isLoggableComponentType(TypeMirror type) { + if (type instanceof PrimitiveType primitive) { + return switch (primitive.getKind()) { + case BYTE, INT, LONG, FLOAT, DOUBLE, BOOLEAN -> true; + default -> false; + }; + } + + return m_structHandler.isLoggableType(type) + || m_processingEnv.getTypeUtils().isAssignable(type, m_javaLangString); + } + + @Override + public String logInvocation(Element element) { + var dataType = dataType(element); + + // known to be an array type (assuming isLoggable is checked first); this is a safe cast + var componentType = ((ArrayType) dataType).getComponentType(); + + if (m_structHandler.isLoggableType(componentType)) { + // Struct arrays need to pass in the struct serializer + return "dataLogger.log(\"" + + loggedName(element) + + "\", " + + elementAccess(element) + + ", " + + m_structHandler.structAccess(componentType) + + ")"; + } else { + // Primitive or string array + return "dataLogger.log(\"" + loggedName(element) + "\", " + elementAccess(element) + ")"; + } + } +} diff --git a/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/CollectionHandler.java b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/CollectionHandler.java new file mode 100644 index 0000000000..27149a63fb --- /dev/null +++ b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/CollectionHandler.java @@ -0,0 +1,57 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.processor; + +import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.Element; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeMirror; + +/** + * Collections of strings and structs are loggable. Collections of boxed primitive types are not. + */ +public class CollectionHandler extends ElementHandler { + private final ArrayHandler m_arrayHandler; + private final TypeMirror m_collectionType; + private final StructHandler m_structHandler; + + protected CollectionHandler(ProcessingEnvironment processingEnv) { + super(processingEnv); + m_arrayHandler = new ArrayHandler(processingEnv); + m_collectionType = + processingEnv.getElementUtils().getTypeElement("java.util.Collection").asType(); + m_structHandler = new StructHandler(processingEnv); + } + + @Override + public boolean isLoggable(Element element) { + var dataType = dataType(element); + + return m_processingEnv + .getTypeUtils() + .isAssignable(dataType, m_processingEnv.getTypeUtils().erasure(m_collectionType)) + && dataType instanceof DeclaredType decl + && decl.getTypeArguments().size() == 1 + && m_arrayHandler.isLoggableComponentType(decl.getTypeArguments().get(0)); + } + + @Override + public String logInvocation(Element element) { + var dataType = dataType(element); + var componentType = ((DeclaredType) dataType).getTypeArguments().get(0); + + if (m_structHandler.isLoggableType(componentType)) { + return "dataLogger.log(\"" + + loggedName(element) + + "\", " + + elementAccess(element) + + ", " + + m_structHandler.structAccess(componentType) + + ")"; + } else { + return "dataLogger.log(\"" + loggedName(element) + "\", " + elementAccess(element) + ")"; + } + } +} diff --git a/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/ConfiguredLoggerHandler.java b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/ConfiguredLoggerHandler.java new file mode 100644 index 0000000000..a2226c6f2f --- /dev/null +++ b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/ConfiguredLoggerHandler.java @@ -0,0 +1,41 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.processor; + +import java.util.Map; +import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.Element; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeMirror; + +public class ConfiguredLoggerHandler extends ElementHandler { + private final Map m_customLoggers; + + protected ConfiguredLoggerHandler( + ProcessingEnvironment processingEnv, Map customLoggers) { + super(processingEnv); + + this.m_customLoggers = customLoggers; + } + + @Override + public boolean isLoggable(Element element) { + return m_customLoggers.containsKey(dataType(element)); + } + + @Override + public String logInvocation(Element element) { + var dataType = dataType(element); + var loggerType = m_customLoggers.get(dataType); + + return "Epilogue." + + StringUtils.lowerCamelCase(loggerType.asElement().getSimpleName()) + + ".tryUpdate(dataLogger.getSubLogger(\"" + + loggedName(element) + + "\"), " + + elementAccess(element) + + ", Epilogue.getConfig().errorHandler)"; + } +} diff --git a/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/ElementHandler.java b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/ElementHandler.java new file mode 100644 index 0000000000..1903f6a4b9 --- /dev/null +++ b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/ElementHandler.java @@ -0,0 +1,136 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.processor; + +import edu.wpi.first.epilogue.Logged; +import edu.wpi.first.epilogue.logging.ClassSpecificLogger; +import edu.wpi.first.epilogue.logging.DataLogger; +import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.Element; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.TypeMirror; + +/** + * Handles logging of fields or methods. An element that passes the {@link #isLoggable(Element)} + * check guarantees that {@link #logInvocation(Element)} will generate a code snippet that will log + * that element. Some subclasses may return {@code null} for the invocation to signal that the + * element should not be logged, but still be considered loggable for the purposes of error + * messaging during the compilation phase. + */ +public abstract class ElementHandler { + protected final ProcessingEnvironment m_processingEnv; + + /** + * Instantiates the handler. + * + * @param processingEnv the processing environment, used to look up type information + */ + protected ElementHandler(ProcessingEnvironment processingEnv) { + this.m_processingEnv = processingEnv; + } + + protected static TypeElement lookupTypeElement(ProcessingEnvironment processingEnv, String name) { + return processingEnv.getElementUtils().getTypeElement(name); + } + + /** + * Gets the type of data that would be logged by a field or method. + * + * @param element the field or method element to check + * @return the logged datatype + */ + protected TypeMirror dataType(Element element) { + if (element instanceof VariableElement field) { + return field.asType(); + } else if (element instanceof ExecutableElement method) { + return method.getReturnType(); + } else { + throw new IllegalStateException("Unexpected" + element.getClass().getName()); + } + } + + /** + * Gets the name of a field or method as it would appear in logs. + * + * @param element the field or method element to check + * @return the name specified in the {@link Logged @Logged} annotation on the element, if present; + * otherwise, the field or method's name with no modifications + */ + public String loggedName(Element element) { + var elementName = element.getSimpleName().toString(); + var config = element.getAnnotation(Logged.class); + + if (config != null && !config.name().isBlank()) { + return config.name(); + } else { + return elementName; + } + } + + /** + * Generates the code snippet to use to access a field or method on a logged object. Private + * fields are accessed via {@link java.lang.invoke.VarHandle VarHandles} and private methods are + * accessed via {@link java.lang.invoke.MethodHandle MethodHandles} (note that this requires the + * logger file to generate those fields). Because the generated logger files are in the same + * package as the logged type, package-private, protected, and public fields and methods are + * always accessible using normal field reads and method calls. Values returned by {@code + * VarHandle} and {@code MethodHandle} invocations will be cast to the appropriate data type. + * + * @param element the element to generate the access for + * @return the generated access snippet + */ + public String elementAccess(Element element) { + if (element instanceof VariableElement field) { + return fieldAccess(field); + } else if (element instanceof ExecutableElement method) { + return methodAccess(method); + } else { + throw new IllegalStateException("Unexpected" + element.getClass().getName()); + } + } + + private static String fieldAccess(VariableElement field) { + if (field.getModifiers().contains(Modifier.PRIVATE)) { + // (com.example.Foo) $fooField.get(object) + return "(" + field.asType() + ") $" + field.getSimpleName() + ".get(object)"; + } else { + // object.fooField + return "object." + field.getSimpleName(); + } + } + + private static String methodAccess(ExecutableElement method) { + if (method.getModifiers().contains(Modifier.PRIVATE)) { + // (com.example.Foo) _getFoo.invoke(object) + // NOTE: Currently, only public methods are logged, so this branch will not be used + return "(" + method.getReturnType() + ") _" + method.getSimpleName() + ".invoke(object)"; + } else { + // object.getFoo() + return "object." + method.getSimpleName() + "()"; + } + } + + /** + * Checks if a field or method can be logged by this handler. + * + * @param element the field or method element to check + * @return true if the element can be logged, false if not + */ + public abstract boolean isLoggable(Element element); + + /** + * Generates a code snippet to place in a generated logger file to log the value of a field or + * method. Log invocations are placed in a generated implementation of {@link + * ClassSpecificLogger#update(DataLogger, Object)}, with access to the data logger and logged + * object passed to the method call. + * + * @param element the field or method element to generate the logger call for + * @return the generated log invocation + */ + public abstract String logInvocation(Element element); +} diff --git a/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/EnumHandler.java b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/EnumHandler.java new file mode 100644 index 0000000000..56a0ad0bb6 --- /dev/null +++ b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/EnumHandler.java @@ -0,0 +1,33 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.processor; + +import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.Element; +import javax.lang.model.type.TypeMirror; + +public class EnumHandler extends ElementHandler { + private final TypeMirror m_javaLangEnum; + + protected EnumHandler(ProcessingEnvironment processingEnv) { + super(processingEnv); + + // raw type java.lang.Enum + m_javaLangEnum = + processingEnv + .getTypeUtils() + .erasure(processingEnv.getElementUtils().getTypeElement("java.lang.Enum").asType()); + } + + @Override + public boolean isLoggable(Element element) { + return m_processingEnv.getTypeUtils().isAssignable(dataType(element), m_javaLangEnum); + } + + @Override + public String logInvocation(Element element) { + return "dataLogger.log(\"" + loggedName(element) + "\", " + elementAccess(element) + ")"; + } +} diff --git a/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/EpilogueGenerator.java b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/EpilogueGenerator.java new file mode 100644 index 0000000000..54050026a6 --- /dev/null +++ b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/EpilogueGenerator.java @@ -0,0 +1,172 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.processor; + +import edu.wpi.first.epilogue.EpilogueConfiguration; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.TypeElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeMirror; + +/** + * Generates the {@code Epilogue} file used as the main entry point to logging with Epilogue in a + * robot program. {@code Epilogue} has instances of every generated logger class, a {@link + * EpilogueConfiguration config} object, and (if the main robot class inherits from {@link + * edu.wpi.first.wpilibj.TimedRobot TimedRobot}) a {@code bind()} method to automatically add a + * periodic logging call to the robot. + */ +public class EpilogueGenerator { + private final ProcessingEnvironment m_processingEnv; + private final Map m_customLoggers; + + public EpilogueGenerator( + ProcessingEnvironment processingEnv, Map customLoggers) { + this.m_processingEnv = processingEnv; + this.m_customLoggers = customLoggers; + } + + /** + * Creates the Epilogue file, which is the main entry point for users to set up and interact with + * the generated loggers. + * + * @param loggerClassNames the names of the generated logger classes. Each of these will be + * instantiated in a public static field on the Epilogue class. + * @param mainRobotClasses the main robot classes. May be empty. Used to generate a {@code bind()} + * method to add a callback hook to a TimedRobot to log itself. + */ + @SuppressWarnings("checkstyle:LineLength") // Source code templates exceed the line length limit + public void writeEpilogueFile( + List loggerClassNames, Collection mainRobotClasses) { + try { + var centralStore = + m_processingEnv.getFiler().createSourceFile("edu.wpi.first.epilogue.Epilogue"); + + try (var out = new PrintWriter(centralStore.openOutputStream())) { + out.println("package edu.wpi.first.epilogue;"); + out.println(); + + loggerClassNames.stream() + .sorted() + .forEach( + name -> { + if (!name.contains(".")) { + // Logger is in the global namespace, don't need to import + return; + } + + out.println("import " + name + ";"); + }); + m_customLoggers.values().stream() + .distinct() + .forEach( + loggerType -> { + var name = loggerType.asElement().toString(); + if (!name.contains(".")) { + // Logger is in the global namespace, don't need to import + return; + } + out.println("import " + name + ";"); + }); + out.println(); + + out.println("public final class Epilogue {"); + out.println( + " private static final EpilogueConfiguration config = new EpilogueConfiguration();"); + out.println(); + + loggerClassNames.forEach( + name -> { + String simple = StringUtils.simpleName(name); + + // public static final FooLogger fooLogger = new FooLogger(); + out.print(" public static final "); + out.print(simple); + out.print(" "); + out.print(StringUtils.lowerCamelCase(simple)); + out.print(" = new "); + out.print(simple); + out.println("();"); + }); + m_customLoggers.values().stream() + .distinct() + .forEach( + loggerType -> { + var loggerTypeName = loggerType.asElement().getSimpleName(); + out.println( + " public static final " + + loggerTypeName + + " " + + StringUtils.lowerCamelCase(loggerTypeName) + + " = new " + + loggerTypeName + + "();"); + }); + out.println(); + + out.println( + """ + public static void configure(java.util.function.Consumer configurator) { + configurator.accept(config); + } + + public static EpilogueConfiguration getConfig() { + return config; + } + """); + + out.println( + """ + /** + * Checks if data associated with a given importance level should be logged. + */ + public static boolean shouldLog(Logged.Importance importance) { + return importance.compareTo(config.minimumImportance) >= 0; + } + """ + .stripTrailing()); + + // Only generate a binding if the robot class is a TimedRobot + if (!mainRobotClasses.isEmpty()) { + for (TypeElement mainRobotClass : mainRobotClasses) { + String robotClassName = mainRobotClass.getQualifiedName().toString(); + + out.println(); + out.print( + """ + /** + * Binds Epilogue updates to a timed robot's update period. Log calls will be made at the + * same update rate as the robot's loop function, but will be offset by a full phase + * (for example, a 20ms update rate but 10ms offset from the main loop invocation) to + * help avoid high CPU loads. However, this does mean that any logged data that reads + * directly from sensors will be slightly different from data used in the main robot + * loop. + */ + """); + out.println(" public static void bind(" + robotClassName + " robot) {"); + out.println(" robot.addPeriodic(() -> {"); + out.println(" long start = System.nanoTime();"); + out.println( + " " + + StringUtils.loggerFieldName(mainRobotClass) + + ".tryUpdate(config.dataLogger.getSubLogger(config.root), robot, config.errorHandler);"); + out.println( + " edu.wpi.first.networktables.NetworkTableInstance.getDefault().getEntry(\"Epilogue/Stats/Last Run\").setDouble((System.nanoTime() - start) / 1e6);"); + out.println(" }, robot.getPeriod(), robot.getPeriod() / 2);"); + out.println(" }"); + } + } + + out.println("}"); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/LoggableHandler.java b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/LoggableHandler.java new file mode 100644 index 0000000000..71367fc730 --- /dev/null +++ b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/LoggableHandler.java @@ -0,0 +1,43 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.processor; + +import edu.wpi.first.epilogue.Logged; +import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.Element; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeMirror; + +/** Handles logging for types annotated with the {@link Logged @Logged} annotation. */ +public class LoggableHandler extends ElementHandler { + protected LoggableHandler(ProcessingEnvironment processingEnv) { + super(processingEnv); + } + + @Override + public boolean isLoggable(Element element) { + var dataType = dataType(element); + return dataType.getAnnotation(Logged.class) != null + || dataType instanceof DeclaredType decl + && decl.asElement().getAnnotation(Logged.class) != null; + } + + @Override + public String logInvocation(Element element) { + TypeMirror dataType = dataType(element); + var reflectedType = + m_processingEnv + .getElementUtils() + .getTypeElement(m_processingEnv.getTypeUtils().erasure(dataType).toString()); + + return "Epilogue." + + StringUtils.loggerFieldName(reflectedType) + + ".tryUpdate(dataLogger.getSubLogger(\"" + + loggedName(element) + + "\"), " + + elementAccess(element) + + ", Epilogue.getConfig().errorHandler)"; + } +} diff --git a/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/LoggerGenerator.java b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/LoggerGenerator.java new file mode 100644 index 0000000000..4c7392217b --- /dev/null +++ b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/LoggerGenerator.java @@ -0,0 +1,245 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.processor; + +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.toList; + +import edu.wpi.first.epilogue.Logged; +import edu.wpi.first.epilogue.NotLogged; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.EnumMap; +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Stream; +import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.Element; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; + +/** Generates logger class files for {@link Logged @Logged}-annotated classes. */ +public class LoggerGenerator { + private final ProcessingEnvironment m_processingEnv; + private final List m_handlers; + + public LoggerGenerator(ProcessingEnvironment processingEnv, List handlers) { + this.m_processingEnv = processingEnv; + this.m_handlers = handlers; + } + + private static boolean isNotSkipped(Element e) { + return e.getAnnotation(NotLogged.class) == null; + } + + /** + * Generates the logger class used to handle data objects of the given type. The generated logger + * class will subclass from {@link edu.wpi.first.epilogue.logging.ClassSpecificLogger} and + * implement the {@code update()} method to populate a data log with information from an instance + * of the data type. + * + * @param clazz the data type that the logger should support. + * @throws IOException if the file could not be written + */ + public void writeLoggerFile(TypeElement clazz) throws IOException { + var config = clazz.getAnnotation(Logged.class); + boolean requireExplicitOptIn = config.strategy() == Logged.Strategy.OPT_IN; + + Predicate notSkipped = LoggerGenerator::isNotSkipped; + Predicate optedIn = + e -> !requireExplicitOptIn || e.getAnnotation(Logged.class) != null; + + var fieldsToLog = + clazz.getEnclosedElements().stream() + .filter(e -> e instanceof VariableElement) + .map(e -> (VariableElement) e) + .filter(notSkipped) + .filter(optedIn) + .filter(e -> !e.getModifiers().contains(Modifier.STATIC)) + .filter(this::isLoggable) + .toList(); + + var methodsToLog = + clazz.getEnclosedElements().stream() + .filter(e -> e instanceof ExecutableElement) + .map(e -> (ExecutableElement) e) + .filter(notSkipped) + .filter(optedIn) + .filter(e -> !e.getModifiers().contains(Modifier.STATIC)) + .filter(e -> e.getModifiers().contains(Modifier.PUBLIC)) + .filter(e -> e.getParameters().isEmpty()) + .filter(e -> e.getReceiverType() != null) + .filter(this::isLoggable) + .toList(); + + writeLoggerFile(clazz.getQualifiedName().toString(), config, fieldsToLog, methodsToLog); + } + + private void writeLoggerFile( + String className, + Logged classConfig, + List loggableFields, + List loggableMethods) + throws IOException { + String packageName = null; + int lastDot = className.lastIndexOf('.'); + if (lastDot > 0) { + packageName = className.substring(0, lastDot); + } + + String simpleClassName = StringUtils.simpleName(className); + String loggerClassName = className + "Logger"; + String loggerSimpleClassName = loggerClassName.substring(lastDot + 1); + + // Use the name on the class config to set the generated logger names + // This helps to avoid naming conflicts + if (!classConfig.name().isBlank()) { + loggerSimpleClassName = + StringUtils.capitalize(classConfig.name().replaceAll(" ", "")) + "Logger"; + if (lastDot > 0) { + loggerClassName = packageName + "." + loggerSimpleClassName; + } else { + loggerClassName = loggerSimpleClassName; + } + } + + var loggerFile = m_processingEnv.getFiler().createSourceFile(loggerClassName); + + var privateFields = + loggableFields.stream().filter(e -> e.getModifiers().contains(Modifier.PRIVATE)).toList(); + boolean requiresVarHandles = !privateFields.isEmpty(); + + try (var out = new PrintWriter(loggerFile.openWriter())) { + if (packageName != null) { + // package com.example; + out.println("package " + packageName + ";"); + out.println(); + } + + out.println("import edu.wpi.first.epilogue.Logged;"); + out.println("import edu.wpi.first.epilogue.Epilogue;"); + out.println("import edu.wpi.first.epilogue.logging.ClassSpecificLogger;"); + out.println("import edu.wpi.first.epilogue.logging.DataLogger;"); + if (requiresVarHandles) { + out.println("import java.lang.invoke.MethodHandles;"); + out.println("import java.lang.invoke.VarHandle;"); + } + out.println(); + + // public class FooLogger implements ClassSpecificLogger { + out.println( + "public class " + + loggerSimpleClassName + + " extends ClassSpecificLogger<" + + simpleClassName + + "> {"); + + if (requiresVarHandles) { + for (var privateField : privateFields) { + // This field needs a VarHandle to access. + // Cache it in the class to avoid lookups + out.println(" private static final VarHandle $" + privateField.getSimpleName() + ";"); + } + out.println(); + + var clazz = simpleClassName + ".class"; + + out.println(" static {"); + out.println(" try {"); + out.println( + " var lookup = MethodHandles.privateLookupIn(" + + clazz + + ", MethodHandles.lookup());"); + + for (var privateField : privateFields) { + var fieldName = privateField.getSimpleName(); + out.println( + " $" + + fieldName + + " = lookup.findVarHandle(" + + clazz + + ", \"" + + fieldName + + "\", " + + m_processingEnv.getTypeUtils().erasure(privateField.asType()) + + ".class);"); + } + + out.println(" } catch (ReflectiveOperationException e) {"); + out.println( + " throw new RuntimeException(" + + "\"[EPILOGUE] Could not load private fields for logging!\", e);"); + out.println(" }"); + out.println(" }"); + out.println(); + } + + out.println(" public " + loggerSimpleClassName + "() {"); + out.println(" super(" + simpleClassName + ".class);"); + out.println(" }"); + out.println(); + + // @Override + // public void update(DataLogger dataLogger, Foo object) { + out.println(" @Override"); + out.println(" public void update(DataLogger dataLogger, " + simpleClassName + " object) {"); + + // Build a map of importance levels to the fields logged at those levels + // e.g. { DEBUG: [fieldA, fieldB], INFO: [fieldC], CRITICAL: [fieldD, fieldE, fieldF] } + var loggedElementsByImportance = + Stream.concat(loggableFields.stream(), loggableMethods.stream()) + .collect( + groupingBy( + element -> { + var config = element.getAnnotation(Logged.class); + if (config == null) { + // No configuration on this element, fall back to the class-level + // configuration + return classConfig.importance(); + } else { + return config.importance(); + } + }, + () -> + new EnumMap<>(Logged.Importance.class), // EnumMap for consistent ordering + toList())); + + loggedElementsByImportance.forEach( + (importance, elements) -> { + out.println( + " if (Epilogue.shouldLog(Logged.Importance." + importance.name() + ")) {"); + + for (var loggableElement : elements) { + // findFirst for prioritization + var handler = + m_handlers.stream().filter(h -> h.isLoggable(loggableElement)).findFirst(); + + handler.ifPresent( + h -> { + // May be null if the handler consumes the element but does not actually want it + // to be logged. For example, the sendable handler consumes all sendable types + // but does not log commands or subsystems, to prevent excessive warnings about + // unloggable commands. + var logInvocation = h.logInvocation(loggableElement); + if (logInvocation != null) { + out.println(logInvocation.indent(6).stripTrailing() + ";"); + } + }); + } + + out.println(" }"); + }); + + out.println(" }"); + out.println("}"); + } + } + + private boolean isLoggable(Element element) { + return m_handlers.stream().anyMatch(h -> h.isLoggable(element)); + } +} diff --git a/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/MeasureHandler.java b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/MeasureHandler.java new file mode 100644 index 0000000000..d34e73add7 --- /dev/null +++ b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/MeasureHandler.java @@ -0,0 +1,37 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.processor; + +import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.Element; +import javax.lang.model.type.TypeMirror; + +public class MeasureHandler extends ElementHandler { + private final TypeMirror m_measure; + + protected MeasureHandler(ProcessingEnvironment processingEnv) { + super(processingEnv); + + m_measure = + processingEnv + .getTypeUtils() + .erasure( + processingEnv + .getElementUtils() + .getTypeElement("edu.wpi.first.units.Measure") + .asType()); + } + + @Override + public boolean isLoggable(Element element) { + return m_processingEnv.getTypeUtils().isAssignable(dataType(element), m_measure); + } + + @Override + public String logInvocation(Element element) { + // DataLogger has builtin support for logging measures + return "dataLogger.log(\"" + loggedName(element) + "\", " + elementAccess(element) + ")"; + } +} diff --git a/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/PrimitiveHandler.java b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/PrimitiveHandler.java new file mode 100644 index 0000000000..f942f99248 --- /dev/null +++ b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/PrimitiveHandler.java @@ -0,0 +1,41 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.processor; + +import static javax.lang.model.type.TypeKind.BOOLEAN; +import static javax.lang.model.type.TypeKind.BYTE; +import static javax.lang.model.type.TypeKind.CHAR; +import static javax.lang.model.type.TypeKind.DOUBLE; +import static javax.lang.model.type.TypeKind.FLOAT; +import static javax.lang.model.type.TypeKind.INT; +import static javax.lang.model.type.TypeKind.LONG; +import static javax.lang.model.type.TypeKind.SHORT; + +import java.util.Set; +import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.Element; +import javax.lang.model.type.TypeMirror; + +public class PrimitiveHandler extends ElementHandler { + private final TypeMirror m_javaLangString; + + protected PrimitiveHandler(ProcessingEnvironment processingEnv) { + super(processingEnv); + + m_javaLangString = processingEnv.getElementUtils().getTypeElement("java.lang.String").asType(); + } + + @Override + public boolean isLoggable(Element element) { + return m_processingEnv.getTypeUtils().isAssignable(dataType(element), m_javaLangString) + || Set.of(BYTE, CHAR, SHORT, INT, LONG, FLOAT, DOUBLE, BOOLEAN) + .contains(dataType(element).getKind()); + } + + @Override + public String logInvocation(Element element) { + return "dataLogger.log(\"" + loggedName(element) + "\", " + elementAccess(element) + ")"; + } +} diff --git a/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/SendableHandler.java b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/SendableHandler.java new file mode 100644 index 0000000000..e01e82017d --- /dev/null +++ b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/SendableHandler.java @@ -0,0 +1,56 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.processor; + +import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.Element; +import javax.lang.model.type.TypeMirror; + +public class SendableHandler extends ElementHandler { + private final TypeMirror m_sendableType; + private final TypeMirror m_commandType; + private final TypeMirror m_subsystemType; + + protected SendableHandler(ProcessingEnvironment processingEnv) { + super(processingEnv); + + m_sendableType = + lookupTypeElement(processingEnv, "edu.wpi.first.util.sendable.Sendable").asType(); + m_commandType = + lookupTypeElement(processingEnv, "edu.wpi.first.wpilibj2.command.Command").asType(); + m_subsystemType = + lookupTypeElement(processingEnv, "edu.wpi.first.wpilibj2.command.SubsystemBase").asType(); + } + + @Override + public boolean isLoggable(Element element) { + var dataType = dataType(element); + + // Accept any sendable type. However, the log invocation will return null + // for sendable types that should not be logged (commands, subsystems) + return m_processingEnv.getTypeUtils().isAssignable(dataType, m_sendableType); + } + + @Override + public String logInvocation(Element element) { + var dataType = dataType(element); + + if (m_processingEnv.getTypeUtils().isAssignable(dataType, m_commandType) + || m_processingEnv.getTypeUtils().isAssignable(dataType, m_subsystemType)) { + // Do not log commands or subsystems via their sendable implementations + // We accept all sendable objects to prevent them from being reported as not loggable, + // but their sendable implementations do not include helpful information. + // Users are free to provide custom logging implementations for commands, and tag their + // subsystems with @Logged to log their contents automatically + return null; + } + + return "logSendable(dataLogger.getSubLogger(\"" + + loggedName(element) + + "\"), " + + elementAccess(element) + + ")"; + } +} diff --git a/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/StringUtils.java b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/StringUtils.java new file mode 100644 index 0000000000..6cc43e53d7 --- /dev/null +++ b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/StringUtils.java @@ -0,0 +1,110 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.processor; + +import edu.wpi.first.epilogue.Logged; +import javax.lang.model.element.TypeElement; + +public final class StringUtils { + private StringUtils() { + throw new UnsupportedOperationException("This is a utility class!"); + } + + /** + * Gets the simple name of a fully qualified class. + * + * @param fqn the fully qualified class name + * @return the simple class name, without a package specifier + */ + public static String simpleName(String fqn) { + return fqn.substring(fqn.lastIndexOf('.') + 1); + } + + /** + * Capitalizes a string. The first character will be capitalized, and the rest of the string will + * be left as is. + * + * @param str the string to capitalize + * @return the capitalized string + */ + public static String capitalize(CharSequence str) { + return Character.toUpperCase(str.charAt(0)) + str.subSequence(1, str.length()).toString(); + } + + /** + * Converts a string to a lower camel-cased version. This requires the input string to only + * consist of alphanumeric characters, without underscores, spaces, or any other special + * character. + * + * @param str the string to convert + * @return the lower camel-case version of the string + */ + public static String lowerCamelCase(CharSequence str) { + StringBuilder builder = new StringBuilder(str.length()); + + int i = 0; + for (; + i < str.length() + && (i == 0 + || i == str.length() - 1 + || Character.isUpperCase(str.charAt(i)) + && Character.isUpperCase(str.charAt(i + 1))); + i++) { + builder.append(Character.toLowerCase(str.charAt(i))); + } + + builder.append(str.subSequence(i, str.length())); + return builder.toString(); + } + + /** + * Gets the name of the field used to hold a logger for data of the given type. + * + * @param clazz the data type that the logger supports + * @return the logger field name + */ + public static String loggerFieldName(TypeElement clazz) { + return lowerCamelCase(simpleName(loggerClassName(clazz))); + } + + /** + * Gets the name of the generated class used to log data of the given type. This will be + * fully-qualified class name, such as {@code "frc.robot.MyRobotLogger"}. + * + * @param clazz the data type that the logger supports + * @return the logger class name + */ + public static String loggerClassName(TypeElement clazz) { + var config = clazz.getAnnotation(Logged.class); + var className = clazz.getQualifiedName().toString(); + + String packageName; + int lastDot = className.lastIndexOf('.'); + if (lastDot <= 0) { + packageName = null; + } else { + packageName = className.substring(0, lastDot); + } + + String loggerClassName; + + // Use the name on the class config to set the generated logger names + // This helps to avoid naming conflicts + if (config.name().isBlank()) { + loggerClassName = className + "Logger"; + } else { + String cleaned = config.name().replaceAll(" ", ""); + + var loggerSimpleClassName = StringUtils.capitalize(cleaned) + "Logger"; + if (packageName != null) { + loggerClassName = packageName + "." + loggerSimpleClassName; + } else { + loggerClassName = loggerSimpleClassName; + } + } + + return loggerClassName; + } +} diff --git a/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/StructHandler.java b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/StructHandler.java new file mode 100644 index 0000000000..ead545cc07 --- /dev/null +++ b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/StructHandler.java @@ -0,0 +1,50 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.processor; + +import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.Element; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.util.Types; + +public class StructHandler extends ElementHandler { + private final TypeMirror m_serializable; + private final Types m_typeUtils; + + protected StructHandler(ProcessingEnvironment processingEnv) { + super(processingEnv); + m_serializable = + processingEnv + .getElementUtils() + .getTypeElement("edu.wpi.first.util.struct.StructSerializable") + .asType(); + m_typeUtils = processingEnv.getTypeUtils(); + } + + @Override + public boolean isLoggable(Element element) { + return m_typeUtils.isAssignable(dataType(element), m_serializable); + } + + public boolean isLoggableType(TypeMirror type) { + return m_typeUtils.isAssignable(type, m_serializable); + } + + public String structAccess(TypeMirror serializableType) { + var className = m_typeUtils.erasure(serializableType).toString(); + return className + ".struct"; + } + + @Override + public String logInvocation(Element element) { + return "dataLogger.log(\"" + + loggedName(element) + + "\", " + + elementAccess(element) + + ", " + + structAccess(dataType(element)) + + ")"; + } +} diff --git a/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/SupplierHandler.java b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/SupplierHandler.java new file mode 100644 index 0000000000..70e3154384 --- /dev/null +++ b/epilogue-processor/src/main/java/edu/wpi/first/epilogue/processor/SupplierHandler.java @@ -0,0 +1,67 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.processor; + +import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.Element; +import javax.lang.model.type.TypeMirror; + +public class SupplierHandler extends ElementHandler { + private final TypeMirror m_booleanSupplier; + private final TypeMirror m_intSupplier; + private final TypeMirror m_longSupplier; + private final TypeMirror m_doubleSupplier; + + protected SupplierHandler(ProcessingEnvironment processingEnv) { + super(processingEnv); + + m_booleanSupplier = + processingEnv + .getElementUtils() + .getTypeElement("java.util.function.BooleanSupplier") + .asType(); + m_intSupplier = + processingEnv.getElementUtils().getTypeElement("java.util.function.IntSupplier").asType(); + m_longSupplier = + processingEnv.getElementUtils().getTypeElement("java.util.function.LongSupplier").asType(); + m_doubleSupplier = + processingEnv + .getElementUtils() + .getTypeElement("java.util.function.DoubleSupplier") + .asType(); + } + + @Override + public boolean isLoggable(Element element) { + return m_processingEnv.getTypeUtils().isAssignable(dataType(element), m_booleanSupplier) + || m_processingEnv.getTypeUtils().isAssignable(dataType(element), m_intSupplier) + || m_processingEnv.getTypeUtils().isAssignable(dataType(element), m_longSupplier) + || m_processingEnv.getTypeUtils().isAssignable(dataType(element), m_doubleSupplier); + } + + @Override + public String logInvocation(Element element) { + return "dataLogger.log(\"" + loggedName(element) + "\", " + elementAccess(element) + ")"; + } + + @Override + public String elementAccess(Element element) { + var typeUtils = m_processingEnv.getTypeUtils(); + var dataType = dataType(element); + String base = super.elementAccess(element); + + if (typeUtils.isAssignable(dataType, m_booleanSupplier)) { + return base + ".getAsBoolean()"; + } else if (typeUtils.isAssignable(dataType, m_intSupplier)) { + return base + ".getAsInt()"; + } else if (typeUtils.isAssignable(dataType, m_longSupplier)) { + return base + ".getAsLong()"; + } else if (typeUtils.isAssignable(dataType, m_doubleSupplier)) { + return base + ".getAsDouble()"; + } else { + throw new IllegalArgumentException("Element type is unsupported: " + dataType); + } + } +} diff --git a/epilogue-processor/src/test/java/edu/wpi/first/epilogue/processor/AnnotationProcessorTest.java b/epilogue-processor/src/test/java/edu/wpi/first/epilogue/processor/AnnotationProcessorTest.java new file mode 100644 index 0000000000..7304e731a6 --- /dev/null +++ b/epilogue-processor/src/test/java/edu/wpi/first/epilogue/processor/AnnotationProcessorTest.java @@ -0,0 +1,1172 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.processor; + +import static com.google.testing.compile.CompilationSubject.assertThat; +import static com.google.testing.compile.Compiler.javac; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.google.testing.compile.Compilation; +import com.google.testing.compile.JavaFileObjects; +import java.io.IOException; +import java.util.List; +import java.util.Locale; +import javax.tools.Diagnostic; +import javax.tools.JavaFileObject; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("checkstyle:LineLength") // Source code templates exceed the line length limit +class AnnotationProcessorTest { + @Test + void simple() { + String source = + """ + package edu.wpi.first.epilogue; + + @Logged + class Example { + double x; + } + """; + + String expectedGeneratedSource = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.Logged; + import edu.wpi.first.epilogue.Epilogue; + import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + import edu.wpi.first.epilogue.logging.DataLogger; + + public class ExampleLogger extends ClassSpecificLogger { + public ExampleLogger() { + super(Example.class); + } + + @Override + public void update(DataLogger dataLogger, Example object) { + if (Epilogue.shouldLog(Logged.Importance.DEBUG)) { + dataLogger.log("x", object.x); + } + } + } + """; + + assertLoggerGenerates(source, expectedGeneratedSource); + } + + @Test + void multiple() { + String source = + """ + package edu.wpi.first.epilogue; + + @Logged + class Example { + double x; + double y; + } + """; + + String expectedGeneratedSource = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.Logged; + import edu.wpi.first.epilogue.Epilogue; + import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + import edu.wpi.first.epilogue.logging.DataLogger; + + public class ExampleLogger extends ClassSpecificLogger { + public ExampleLogger() { + super(Example.class); + } + + @Override + public void update(DataLogger dataLogger, Example object) { + if (Epilogue.shouldLog(Logged.Importance.DEBUG)) { + dataLogger.log("x", object.x); + dataLogger.log("y", object.y); + } + } + } + """; + + assertLoggerGenerates(source, expectedGeneratedSource); + } + + @Test + void privateFields() { + String source = + """ + package edu.wpi.first.epilogue; + + @Logged + class Example { + private double x; + } + """; + + String expectedGeneratedSource = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.Logged; + import edu.wpi.first.epilogue.Epilogue; + import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + import edu.wpi.first.epilogue.logging.DataLogger; + import java.lang.invoke.MethodHandles; + import java.lang.invoke.VarHandle; + + public class ExampleLogger extends ClassSpecificLogger { + private static final VarHandle $x; + + static { + try { + var lookup = MethodHandles.privateLookupIn(Example.class, MethodHandles.lookup()); + $x = lookup.findVarHandle(Example.class, "x", double.class); + } catch (ReflectiveOperationException e) { + throw new RuntimeException("[EPILOGUE] Could not load private fields for logging!", e); + } + } + + public ExampleLogger() { + super(Example.class); + } + + @Override + public void update(DataLogger dataLogger, Example object) { + if (Epilogue.shouldLog(Logged.Importance.DEBUG)) { + dataLogger.log("x", (double) $x.get(object)); + } + } + } + """; + + assertLoggerGenerates(source, expectedGeneratedSource); + } + + @Test + void privateWithGenerics() { + String source = + """ + package edu.wpi.first.epilogue; + + @Logged + class Example { + private edu.wpi.first.wpilibj.smartdashboard.SendableChooser chooser; + } + """; + + String expectedGeneratedSource = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.Logged; + import edu.wpi.first.epilogue.Epilogue; + import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + import edu.wpi.first.epilogue.logging.DataLogger; + import java.lang.invoke.MethodHandles; + import java.lang.invoke.VarHandle; + + public class ExampleLogger extends ClassSpecificLogger { + private static final VarHandle $chooser; + + static { + try { + var lookup = MethodHandles.privateLookupIn(Example.class, MethodHandles.lookup()); + $chooser = lookup.findVarHandle(Example.class, "chooser", edu.wpi.first.wpilibj.smartdashboard.SendableChooser.class); + } catch (ReflectiveOperationException e) { + throw new RuntimeException("[EPILOGUE] Could not load private fields for logging!", e); + } + } + + public ExampleLogger() { + super(Example.class); + } + + @Override + public void update(DataLogger dataLogger, Example object) { + if (Epilogue.shouldLog(Logged.Importance.DEBUG)) { + logSendable(dataLogger.getSubLogger("chooser"), (edu.wpi.first.wpilibj.smartdashboard.SendableChooser) $chooser.get(object)); + } + } + } + """; + + assertLoggerGenerates(source, expectedGeneratedSource); + } + + @Test + void importanceLevels() { + String source = + """ + package edu.wpi.first.epilogue; + + @Logged(importance = Logged.Importance.INFO) + class Example { + @Logged(importance = Logged.Importance.DEBUG) double low; + @Logged(importance = Logged.Importance.INFO) int medium; + @Logged(importance = Logged.Importance.CRITICAL) long high; + } + """; + + String expectedGeneratedSource = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.Logged; + import edu.wpi.first.epilogue.Epilogue; + import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + import edu.wpi.first.epilogue.logging.DataLogger; + + public class ExampleLogger extends ClassSpecificLogger { + public ExampleLogger() { + super(Example.class); + } + + @Override + public void update(DataLogger dataLogger, Example object) { + if (Epilogue.shouldLog(Logged.Importance.DEBUG)) { + dataLogger.log("low", object.low); + } + if (Epilogue.shouldLog(Logged.Importance.INFO)) { + dataLogger.log("medium", object.medium); + } + if (Epilogue.shouldLog(Logged.Importance.CRITICAL)) { + dataLogger.log("high", object.high); + } + } + } + """; + + assertLoggerGenerates(source, expectedGeneratedSource); + } + + @Test + void logEnum() { + String source = + """ + package edu.wpi.first.epilogue; + + @Logged + class Example { + enum E { + a, b, c; + } + E enumValue; // Should be logged + E[] enumArray; // Should not be logged + } + """; + + String expectedGeneratedSource = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.Logged; + import edu.wpi.first.epilogue.Epilogue; + import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + import edu.wpi.first.epilogue.logging.DataLogger; + + public class ExampleLogger extends ClassSpecificLogger { + public ExampleLogger() { + super(Example.class); + } + + @Override + public void update(DataLogger dataLogger, Example object) { + if (Epilogue.shouldLog(Logged.Importance.DEBUG)) { + dataLogger.log("enumValue", object.enumValue); + } + } + } + """; + + assertLoggerGenerates(source, expectedGeneratedSource); + } + + @Test + void bytes() { + String source = + """ + package edu.wpi.first.epilogue; + + @Logged + class Example { + byte x; // Should be logged + byte[] arr1; // Should be logged + byte[][] arr2; // Should not be logged + + public byte getX() { return x; } + public byte[] getArr1() { return arr1; } + public byte[][] getArr2() { return arr2; } + } + """; + + String expectedGeneratedSource = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.Logged; + import edu.wpi.first.epilogue.Epilogue; + import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + import edu.wpi.first.epilogue.logging.DataLogger; + + public class ExampleLogger extends ClassSpecificLogger { + public ExampleLogger() { + super(Example.class); + } + + @Override + public void update(DataLogger dataLogger, Example object) { + if (Epilogue.shouldLog(Logged.Importance.DEBUG)) { + dataLogger.log("x", object.x); + dataLogger.log("arr1", object.arr1); + dataLogger.log("getX", object.getX()); + dataLogger.log("getArr1", object.getArr1()); + } + } + } + """; + + assertLoggerGenerates(source, expectedGeneratedSource); + } + + @Test + void chars() { + String source = + """ + package edu.wpi.first.epilogue; + + @Logged + class Example { + char x; // Should be logged + char[] arr1; // Should not be logged + char[][] arr2; // Should not be logged + + public char getX() { return x; } + public char[] getArr1() { return arr1; } + public char[][] getArr2() { return arr2; } + } + """; + + String expectedGeneratedSource = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.Logged; + import edu.wpi.first.epilogue.Epilogue; + import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + import edu.wpi.first.epilogue.logging.DataLogger; + + public class ExampleLogger extends ClassSpecificLogger { + public ExampleLogger() { + super(Example.class); + } + + @Override + public void update(DataLogger dataLogger, Example object) { + if (Epilogue.shouldLog(Logged.Importance.DEBUG)) { + dataLogger.log("x", object.x); + dataLogger.log("getX", object.getX()); + } + } + } + """; + + assertLoggerGenerates(source, expectedGeneratedSource); + } + + @Test + void shorts() { + String source = + """ + package edu.wpi.first.epilogue; + + @Logged + class Example { + short x; // Should be logged + short[] arr1; // Should not be logged + short[][] arr2; // Should not be logged + + public short getX() { return x; } + public short[] getArr1() { return arr1; } + public short[][] getArr2() { return arr2; } + } + """; + + String expectedGeneratedSource = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.Logged; + import edu.wpi.first.epilogue.Epilogue; + import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + import edu.wpi.first.epilogue.logging.DataLogger; + + public class ExampleLogger extends ClassSpecificLogger { + public ExampleLogger() { + super(Example.class); + } + + @Override + public void update(DataLogger dataLogger, Example object) { + if (Epilogue.shouldLog(Logged.Importance.DEBUG)) { + dataLogger.log("x", object.x); + dataLogger.log("getX", object.getX()); + } + } + } + """; + + assertLoggerGenerates(source, expectedGeneratedSource); + } + + @Test + void ints() { + String source = + """ + package edu.wpi.first.epilogue; + + @Logged + class Example { + int x; // Should be logged + int[] arr1; // Should be logged + int[][] arr2; // Should not be logged + + public int getX() { return x; } + public int[] getArr1() { return arr1; } + public int[][] getArr2() { return arr2; } + } + """; + + String expectedGeneratedSource = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.Logged; + import edu.wpi.first.epilogue.Epilogue; + import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + import edu.wpi.first.epilogue.logging.DataLogger; + + public class ExampleLogger extends ClassSpecificLogger { + public ExampleLogger() { + super(Example.class); + } + + @Override + public void update(DataLogger dataLogger, Example object) { + if (Epilogue.shouldLog(Logged.Importance.DEBUG)) { + dataLogger.log("x", object.x); + dataLogger.log("arr1", object.arr1); + dataLogger.log("getX", object.getX()); + dataLogger.log("getArr1", object.getArr1()); + } + } + } + """; + + assertLoggerGenerates(source, expectedGeneratedSource); + } + + @Test + void longs() { + String source = + """ + package edu.wpi.first.epilogue; + + @Logged + class Example { + long x; // Should be logged + long[] arr1; // Should be logged + long[][] arr2; // Should not be logged + + public long getX() { return x; } + public long[] getArr1() { return arr1; } + public long[][] getArr2() { return arr2; } + } + """; + + String expectedGeneratedSource = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.Logged; + import edu.wpi.first.epilogue.Epilogue; + import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + import edu.wpi.first.epilogue.logging.DataLogger; + + public class ExampleLogger extends ClassSpecificLogger { + public ExampleLogger() { + super(Example.class); + } + + @Override + public void update(DataLogger dataLogger, Example object) { + if (Epilogue.shouldLog(Logged.Importance.DEBUG)) { + dataLogger.log("x", object.x); + dataLogger.log("arr1", object.arr1); + dataLogger.log("getX", object.getX()); + dataLogger.log("getArr1", object.getArr1()); + } + } + } + """; + + assertLoggerGenerates(source, expectedGeneratedSource); + } + + @Test + void floats() { + String source = + """ + package edu.wpi.first.epilogue; + + @Logged + class Example { + float x; // Should be logged + float[] arr1; // Should be logged + float[][] arr2; // Should not be logged + + public float getX() { return x; } + public float[] getArr1() { return arr1; } + public float[][] getArr2() { return arr2; } + } + """; + + String expectedGeneratedSource = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.Logged; + import edu.wpi.first.epilogue.Epilogue; + import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + import edu.wpi.first.epilogue.logging.DataLogger; + + public class ExampleLogger extends ClassSpecificLogger { + public ExampleLogger() { + super(Example.class); + } + + @Override + public void update(DataLogger dataLogger, Example object) { + if (Epilogue.shouldLog(Logged.Importance.DEBUG)) { + dataLogger.log("x", object.x); + dataLogger.log("arr1", object.arr1); + dataLogger.log("getX", object.getX()); + dataLogger.log("getArr1", object.getArr1()); + } + } + } + """; + + assertLoggerGenerates(source, expectedGeneratedSource); + } + + @Test + void doubles() { + String source = + """ + package edu.wpi.first.epilogue; + + import java.util.List; + + @Logged + class Example { + double x; // Should be logged + double[] arr1; // Should be logged + double[][] arr2; // Should not be logged + List list; // Should not be logged + + public double getX() { return x; } + public double[] getArr1() { return arr1; } + public double[][] getArr2() { return arr2; } + } + """; + + String expectedGeneratedSource = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.Logged; + import edu.wpi.first.epilogue.Epilogue; + import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + import edu.wpi.first.epilogue.logging.DataLogger; + + public class ExampleLogger extends ClassSpecificLogger { + public ExampleLogger() { + super(Example.class); + } + + @Override + public void update(DataLogger dataLogger, Example object) { + if (Epilogue.shouldLog(Logged.Importance.DEBUG)) { + dataLogger.log("x", object.x); + dataLogger.log("arr1", object.arr1); + dataLogger.log("getX", object.getX()); + dataLogger.log("getArr1", object.getArr1()); + } + } + } + """; + + assertLoggerGenerates(source, expectedGeneratedSource); + } + + @Test + void booleans() { + String source = + """ + package edu.wpi.first.epilogue; + import java.util.List; + + @Logged + class Example { + boolean x; // Should be logged + boolean[] arr1; // Should be logged + boolean[][] arr2; // Should not be logged + List list; // Should not be logged + + public boolean getX() { return x; } + public boolean[] getArr1() { return arr1; } + public boolean[][] getArr2() { return arr2; } + } + """; + + String expectedGeneratedSource = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.Logged; + import edu.wpi.first.epilogue.Epilogue; + import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + import edu.wpi.first.epilogue.logging.DataLogger; + + public class ExampleLogger extends ClassSpecificLogger { + public ExampleLogger() { + super(Example.class); + } + + @Override + public void update(DataLogger dataLogger, Example object) { + if (Epilogue.shouldLog(Logged.Importance.DEBUG)) { + dataLogger.log("x", object.x); + dataLogger.log("arr1", object.arr1); + dataLogger.log("getX", object.getX()); + dataLogger.log("getArr1", object.getArr1()); + } + } + } + """; + + assertLoggerGenerates(source, expectedGeneratedSource); + } + + @Test + void strings() { + String source = + """ + package edu.wpi.first.epilogue; + + import java.util.List; + + @Logged + class Example { + String x; // Should be logged + String[] arr1; // Should be logged + String[][] arr2; // Should not be logged + List list; // Should be logged + + public String getX() { return x; } + public String[] getArr1() { return arr1; } + public String[][] getArr2() { return arr2; } + } + """; + + String expectedGeneratedSource = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.Logged; + import edu.wpi.first.epilogue.Epilogue; + import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + import edu.wpi.first.epilogue.logging.DataLogger; + + public class ExampleLogger extends ClassSpecificLogger { + public ExampleLogger() { + super(Example.class); + } + + @Override + public void update(DataLogger dataLogger, Example object) { + if (Epilogue.shouldLog(Logged.Importance.DEBUG)) { + dataLogger.log("x", object.x); + dataLogger.log("arr1", object.arr1); + dataLogger.log("list", object.list); + dataLogger.log("getX", object.getX()); + dataLogger.log("getArr1", object.getArr1()); + } + } + } + """; + + assertLoggerGenerates(source, expectedGeneratedSource); + } + + @Test + void structs() { + String source = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.util.struct.Struct; + import edu.wpi.first.util.struct.StructSerializable; + import java.util.List; + + @Logged + class Example { + static class Structable implements StructSerializable { + int x, y; + + public static final Struct struct = null; // value doesn't matter + } + + Structable x; // Should be logged + Structable[] arr1; // Should be logged + Structable[][] arr2; // Should not be logged + List list; // Should be logged + + public Structable getX() { return x; } + public Structable[] getArr1() { return arr1; } + public Structable[][] getArr2() { return arr2; } + } + """; + + String expectedGeneratedSource = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.Logged; + import edu.wpi.first.epilogue.Epilogue; + import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + import edu.wpi.first.epilogue.logging.DataLogger; + + public class ExampleLogger extends ClassSpecificLogger { + public ExampleLogger() { + super(Example.class); + } + + @Override + public void update(DataLogger dataLogger, Example object) { + if (Epilogue.shouldLog(Logged.Importance.DEBUG)) { + dataLogger.log("x", object.x, edu.wpi.first.epilogue.Example.Structable.struct); + dataLogger.log("arr1", object.arr1, edu.wpi.first.epilogue.Example.Structable.struct); + dataLogger.log("list", object.list, edu.wpi.first.epilogue.Example.Structable.struct); + dataLogger.log("getX", object.getX(), edu.wpi.first.epilogue.Example.Structable.struct); + dataLogger.log("getArr1", object.getArr1(), edu.wpi.first.epilogue.Example.Structable.struct); + } + } + } + """; + + assertLoggerGenerates(source, expectedGeneratedSource); + } + + @Test + void lists() { + String source = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.util.struct.Struct; + import edu.wpi.first.util.struct.StructSerializable; + import java.util.*; + + @Logged + class Example { + /* Logged */ List list; + /* Not Logged */ List> nestedList; + /* Not logged */ List rawList; + /* Logged */ Set set; + /* Logged */ Queue queue; + /* Logged */ Stack stack; + } + """; + + String expectedGeneratedSource = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.Logged; + import edu.wpi.first.epilogue.Epilogue; + import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + import edu.wpi.first.epilogue.logging.DataLogger; + + public class ExampleLogger extends ClassSpecificLogger { + public ExampleLogger() { + super(Example.class); + } + + @Override + public void update(DataLogger dataLogger, Example object) { + if (Epilogue.shouldLog(Logged.Importance.DEBUG)) { + dataLogger.log("list", object.list); + dataLogger.log("set", object.set); + dataLogger.log("queue", object.queue); + dataLogger.log("stack", object.stack); + } + } + } + """; + + assertLoggerGenerates(source, expectedGeneratedSource); + } + + @Test + void boxedPrimitiveLists() { + // Boxed primitives are not directly supported, nor are arrays of boxed primitives + // int[] is fine, but Integer[] is not + + String source = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.util.struct.Struct; + import edu.wpi.first.util.struct.StructSerializable; + import java.util.List; + + @Logged + class Example { + /* Not logged */ List ints; + /* Not logged */ List doubles; + /* Not logged */ List longs; + } + """; + + String expectedGeneratedSource = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.Logged; + import edu.wpi.first.epilogue.Epilogue; + import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + import edu.wpi.first.epilogue.logging.DataLogger; + + public class ExampleLogger extends ClassSpecificLogger { + public ExampleLogger() { + super(Example.class); + } + + @Override + public void update(DataLogger dataLogger, Example object) { + } + } + """; + + assertLoggerGenerates(source, expectedGeneratedSource); + } + + @Test + void badLogSetup() { + String source = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.util.struct.Struct; + import edu.wpi.first.util.struct.StructSerializable; + import java.util.*; + + @Logged + class Example { + @Logged Map notLoggableType; + @Logged List rawType; + @NotLogged List skippedUnloggable; + + @Logged + private String privateMethod() { return ""; } + + @Logged + String packagePrivateMethod() { return ""; } + + @Logged + protected String protectedMethod() { return ""; } + + @Logged + public static String publicStaticMethod() { return ""; } + + @Logged + private static String privateStaticMethod() { return ""; } + + @Logged + public void publicVoidMethod() {} + + @Logged + public Map publicNonLoggableMethod() { return notLoggableType; } + } + """; + + Compilation compilation = + javac() + .withProcessors(new AnnotationProcessor()) + .compile(JavaFileObjects.forSourceString("edu.wpi.first.epilogue.Example", source)); + + assertThat(compilation).failed(); + assertThat(compilation).hadErrorCount(10); + + List> errors = compilation.errors(); + assertAll( + () -> + assertCompilationError( + "[EPILOGUE] You have opted in to logging on this field, but it is not a loggable data type!", + 9, + 31, + errors.get(0)), + () -> + assertCompilationError( + "[EPILOGUE] You have opted in to logging on this field, but it is not a loggable data type!", + 10, + 16, + errors.get(1)), + () -> + assertCompilationError( + "[EPILOGUE] Logged methods must be public", 14, 18, errors.get(2)), + () -> + assertCompilationError( + "[EPILOGUE] Logged methods must be public", 17, 10, errors.get(3)), + () -> + assertCompilationError( + "[EPILOGUE] Logged methods must be public", 20, 20, errors.get(4)), + () -> + assertCompilationError( + "[EPILOGUE] Logged methods cannot be static", 23, 24, errors.get(5)), + () -> + assertCompilationError( + "[EPILOGUE] Logged methods must be public", 26, 25, errors.get(6)), + () -> + assertCompilationError( + "[EPILOGUE] Logged methods cannot be static", 26, 25, errors.get(7)), + () -> + assertCompilationError( + "[EPILOGUE] You have opted in to logging on this method, but it does not return a loggable data type!", + 29, + 15, + errors.get(8)), + () -> + assertCompilationError( + "[EPILOGUE] You have opted in to logging on this method, but it does not return a loggable data type!", + 32, + 30, + errors.get(9))); + } + + @Test + void onGenericType() { + String source = + """ + package edu.wpi.first.epilogue; + + @Logged + class Example { + T value; + + public S upcast() { return (S) value; } + } + """; + + String expectedGeneratedSource = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.Logged; + import edu.wpi.first.epilogue.Epilogue; + import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + import edu.wpi.first.epilogue.logging.DataLogger; + + public class ExampleLogger extends ClassSpecificLogger { + public ExampleLogger() { + super(Example.class); + } + + @Override + public void update(DataLogger dataLogger, Example object) { + if (Epilogue.shouldLog(Logged.Importance.DEBUG)) { + dataLogger.log("value", object.value); + dataLogger.log("upcast", object.upcast()); + } + } + } + """; + + assertLoggerGenerates(source, expectedGeneratedSource); + } + + @Test + void annotationInheritance() { + String source = + """ + package edu.wpi.first.epilogue; + + @Logged + class Child {} + + class GoldenChild extends Child {} // inherits the @Logged annotation from the parent + + @Logged + interface IO {} + + class IOImpl implements IO {} + + @Logged + public class Example { + /* Logged */ Child child; + /* Not Logged */ GoldenChild goldenChild; + /* Logged */ IO io; + /* Not logged */ IOImpl ioImpl; + } + """; + + String expectedRootLogger = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.Logged; + import edu.wpi.first.epilogue.Epilogue; + import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + import edu.wpi.first.epilogue.logging.DataLogger; + + public class ExampleLogger extends ClassSpecificLogger { + public ExampleLogger() { + super(Example.class); + } + + @Override + public void update(DataLogger dataLogger, Example object) { + if (Epilogue.shouldLog(Logged.Importance.DEBUG)) { + Epilogue.childLogger.tryUpdate(dataLogger.getSubLogger("child"), object.child, Epilogue.getConfig().errorHandler); + Epilogue.ioLogger.tryUpdate(dataLogger.getSubLogger("io"), object.io, Epilogue.getConfig().errorHandler); + } + } + } + """; + + assertLoggerGenerates(source, expectedRootLogger); + } + + @Test + void customLogger() { + String source = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.logging.*; + + record Point(int x, int y) {} + + @CustomLoggerFor(Point.class) + class CustomPointLogger extends ClassSpecificLogger { + public CustomPointLogger() { + super(Point.class); + } + + @Override + public void update(DataLogger dataLogger, Point point) { + // Implementation is irrelevant + } + } + + @Logged + class Example { + Point point; + } + """; + + String expectedGeneratedSource = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.Logged; + import edu.wpi.first.epilogue.Epilogue; + import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + import edu.wpi.first.epilogue.logging.DataLogger; + + public class ExampleLogger extends ClassSpecificLogger { + public ExampleLogger() { + super(Example.class); + } + + @Override + public void update(DataLogger dataLogger, Example object) { + if (Epilogue.shouldLog(Logged.Importance.DEBUG)) { + Epilogue.customPointLogger.tryUpdate(dataLogger.getSubLogger("point"), object.point, Epilogue.getConfig().errorHandler); + } + } + } + """; + + assertLoggerGenerates(source, expectedGeneratedSource); + } + + @Test + void warnsAboutNonLoggableFields() { + String source = + """ + package edu.wpi.first.epilogue; + + @Logged + class Example { + Throwable t; + } + """; + + Compilation compilation = + javac() + .withProcessors(new AnnotationProcessor()) + .compile(JavaFileObjects.forSourceString("edu.wpi.first.epilogue.Example", source)); + + assertThat(compilation).succeeded(); + assertEquals(1, compilation.notes().size()); + var warning = compilation.notes().get(0); + var message = warning.getMessage(Locale.getDefault()); + assertEquals( + "[EPILOGUE] Excluded from logs because java.lang.Throwable is not a loggable data type", + message); + } + + private void assertCompilationError( + String message, long lineNumber, long col, Diagnostic diagnostic) { + assertAll( + () -> assertEquals(Diagnostic.Kind.ERROR, diagnostic.getKind(), "not an error"), + () -> + assertEquals( + message, diagnostic.getMessage(Locale.getDefault()), "error message mismatch"), + () -> assertEquals(lineNumber, diagnostic.getLineNumber(), "line number mismatch"), + () -> assertEquals(col, diagnostic.getColumnNumber(), "column number mismatch")); + } + + private void assertLoggerGenerates(String loggedClassContent, String loggerClassContent) { + Compilation compilation = + javac() + .withProcessors(new AnnotationProcessor()) + .compile( + JavaFileObjects.forSourceString( + "edu.wpi.first.epilogue.Example", loggedClassContent)); + + assertThat(compilation).succeeded(); + var generatedFiles = compilation.generatedSourceFiles(); + var generatedFile = + generatedFiles.stream() + .filter(jfo -> jfo.getName().contains("Example")) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Logger file was not generated!")); + try { + var content = generatedFile.getCharContent(false); + assertEquals( + loggerClassContent.replace("\r\n", "\n"), content.toString().replace("\r\n", "\n")); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/epilogue-processor/src/test/java/edu/wpi/first/epilogue/processor/EpilogueGeneratorTest.java b/epilogue-processor/src/test/java/edu/wpi/first/epilogue/processor/EpilogueGeneratorTest.java new file mode 100644 index 0000000000..bf1c94049e --- /dev/null +++ b/epilogue-processor/src/test/java/edu/wpi/first/epilogue/processor/EpilogueGeneratorTest.java @@ -0,0 +1,326 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.processor; + +import static com.google.testing.compile.CompilationSubject.assertThat; +import static com.google.testing.compile.Compiler.javac; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.google.testing.compile.Compilation; +import com.google.testing.compile.JavaFileObjects; +import java.io.IOException; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("checkstyle:LineLength") // Source code templates exceed the line length limit +class EpilogueGeneratorTest { + @Test + void noFields() { + String source = + """ + package edu.wpi.first.epilogue; + + @Logged + class Example { + } + """; + + String expected = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.ExampleLogger; + + public final class Epilogue { + private static final EpilogueConfiguration config = new EpilogueConfiguration(); + + public static final ExampleLogger exampleLogger = new ExampleLogger(); + + public static void configure(java.util.function.Consumer configurator) { + configurator.accept(config); + } + + public static EpilogueConfiguration getConfig() { + return config; + } + + /** + * Checks if data associated with a given importance level should be logged. + */ + public static boolean shouldLog(Logged.Importance importance) { + return importance.compareTo(config.minimumImportance) >= 0; + } + } + """; + + assertGeneratedEpilogueContents(source, expected); + } + + /** Subclassing RobotBase should not generate the bind() method because it lacks addPeriodic(). */ + @Test + void robotBase() { + String source = + """ + package edu.wpi.first.epilogue; + + @Logged + class Example extends edu.wpi.first.wpilibj.RobotBase { + @Override + public void startCompetition() {} + @Override + public void endCompetition() {} + } + """; + + String expected = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.ExampleLogger; + + public final class Epilogue { + private static final EpilogueConfiguration config = new EpilogueConfiguration(); + + public static final ExampleLogger exampleLogger = new ExampleLogger(); + + public static void configure(java.util.function.Consumer configurator) { + configurator.accept(config); + } + + public static EpilogueConfiguration getConfig() { + return config; + } + + /** + * Checks if data associated with a given importance level should be logged. + */ + public static boolean shouldLog(Logged.Importance importance) { + return importance.compareTo(config.minimumImportance) >= 0; + } + } + """; + + assertGeneratedEpilogueContents(source, expected); + } + + @Test + void timedRobot() { + String source = + """ + package edu.wpi.first.epilogue; + + @Logged + class Example extends edu.wpi.first.wpilibj.TimedRobot { + } + """; + + String expected = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.ExampleLogger; + + public final class Epilogue { + private static final EpilogueConfiguration config = new EpilogueConfiguration(); + + public static final ExampleLogger exampleLogger = new ExampleLogger(); + + public static void configure(java.util.function.Consumer configurator) { + configurator.accept(config); + } + + public static EpilogueConfiguration getConfig() { + return config; + } + + /** + * Checks if data associated with a given importance level should be logged. + */ + public static boolean shouldLog(Logged.Importance importance) { + return importance.compareTo(config.minimumImportance) >= 0; + } + + /** + * Binds Epilogue updates to a timed robot's update period. Log calls will be made at the + * same update rate as the robot's loop function, but will be offset by a full phase + * (for example, a 20ms update rate but 10ms offset from the main loop invocation) to + * help avoid high CPU loads. However, this does mean that any logged data that reads + * directly from sensors will be slightly different from data used in the main robot + * loop. + */ + public static void bind(edu.wpi.first.epilogue.Example robot) { + robot.addPeriodic(() -> { + long start = System.nanoTime(); + exampleLogger.tryUpdate(config.dataLogger.getSubLogger(config.root), robot, config.errorHandler); + edu.wpi.first.networktables.NetworkTableInstance.getDefault().getEntry("Epilogue/Stats/Last Run").setDouble((System.nanoTime() - start) / 1e6); + }, robot.getPeriod(), robot.getPeriod() / 2); + } + } + """; + + assertGeneratedEpilogueContents(source, expected); + } + + @Test + void multipleRobots() { + String source = + """ + package edu.wpi.first.epilogue; + + @Logged + class AlphaBot extends edu.wpi.first.wpilibj.TimedRobot { } + + @Logged + class BetaBot extends edu.wpi.first.wpilibj.TimedRobot { } + """; + + String expected = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.AlphaBotLogger; + import edu.wpi.first.epilogue.BetaBotLogger; + + public final class Epilogue { + private static final EpilogueConfiguration config = new EpilogueConfiguration(); + + public static final AlphaBotLogger alphaBotLogger = new AlphaBotLogger(); + public static final BetaBotLogger betaBotLogger = new BetaBotLogger(); + + public static void configure(java.util.function.Consumer configurator) { + configurator.accept(config); + } + + public static EpilogueConfiguration getConfig() { + return config; + } + + /** + * Checks if data associated with a given importance level should be logged. + */ + public static boolean shouldLog(Logged.Importance importance) { + return importance.compareTo(config.minimumImportance) >= 0; + } + + /** + * Binds Epilogue updates to a timed robot's update period. Log calls will be made at the + * same update rate as the robot's loop function, but will be offset by a full phase + * (for example, a 20ms update rate but 10ms offset from the main loop invocation) to + * help avoid high CPU loads. However, this does mean that any logged data that reads + * directly from sensors will be slightly different from data used in the main robot + * loop. + */ + public static void bind(edu.wpi.first.epilogue.AlphaBot robot) { + robot.addPeriodic(() -> { + long start = System.nanoTime(); + alphaBotLogger.tryUpdate(config.dataLogger.getSubLogger(config.root), robot, config.errorHandler); + edu.wpi.first.networktables.NetworkTableInstance.getDefault().getEntry("Epilogue/Stats/Last Run").setDouble((System.nanoTime() - start) / 1e6); + }, robot.getPeriod(), robot.getPeriod() / 2); + } + + /** + * Binds Epilogue updates to a timed robot's update period. Log calls will be made at the + * same update rate as the robot's loop function, but will be offset by a full phase + * (for example, a 20ms update rate but 10ms offset from the main loop invocation) to + * help avoid high CPU loads. However, this does mean that any logged data that reads + * directly from sensors will be slightly different from data used in the main robot + * loop. + */ + public static void bind(edu.wpi.first.epilogue.BetaBot robot) { + robot.addPeriodic(() -> { + long start = System.nanoTime(); + betaBotLogger.tryUpdate(config.dataLogger.getSubLogger(config.root), robot, config.errorHandler); + edu.wpi.first.networktables.NetworkTableInstance.getDefault().getEntry("Epilogue/Stats/Last Run").setDouble((System.nanoTime() - start) / 1e6); + }, robot.getPeriod(), robot.getPeriod() / 2); + } + } + """; + + assertGeneratedEpilogueContents(source, expected); + } + + @Test + void genericCustomLogger() { + String source = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.logging.*; + + class A {} + class B extends A {} + class C extends A {} + + @CustomLoggerFor({A.class, B.class, C.class}) + class CustomLogger extends ClassSpecificLogger { + public CustomLogger() { super(A.class); } + + @Override + public void update(DataLogger logger, A object) {} // implementation is irrelevant + } + + @Logged + class Example { + A a_b_or_c; + B b; + C c; + } + """; + + String expected = + """ + package edu.wpi.first.epilogue; + + import edu.wpi.first.epilogue.ExampleLogger; + import edu.wpi.first.epilogue.CustomLogger; + + public final class Epilogue { + private static final EpilogueConfiguration config = new EpilogueConfiguration(); + + public static final ExampleLogger exampleLogger = new ExampleLogger(); + public static final CustomLogger customLogger = new CustomLogger(); + + public static void configure(java.util.function.Consumer configurator) { + configurator.accept(config); + } + + public static EpilogueConfiguration getConfig() { + return config; + } + + /** + * Checks if data associated with a given importance level should be logged. + */ + public static boolean shouldLog(Logged.Importance importance) { + return importance.compareTo(config.minimumImportance) >= 0; + } + } + """; + + assertGeneratedEpilogueContents(source, expected); + } + + private void assertGeneratedEpilogueContents( + String loggedClassContent, String loggerClassContent) { + Compilation compilation = + javac() + .withProcessors(new AnnotationProcessor()) + .compile(JavaFileObjects.forSourceString("", loggedClassContent)); + + assertThat(compilation).succeededWithoutWarnings(); + var generatedFiles = compilation.generatedSourceFiles(); + var generatedFile = + generatedFiles.stream() + .filter(jfo -> jfo.getName().contains("Epilogue")) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Epilogue file was not generated!")); + try { + var content = generatedFile.getCharContent(false); + assertEquals( + loggerClassContent.replace("\r\n", "\n"), content.toString().replace("\r\n", "\n")); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/epilogue-processor/src/test/java/edu/wpi/first/epilogue/processor/StringUtilsTest.java b/epilogue-processor/src/test/java/edu/wpi/first/epilogue/processor/StringUtilsTest.java new file mode 100644 index 0000000000..4032260fff --- /dev/null +++ b/epilogue-processor/src/test/java/edu/wpi/first/epilogue/processor/StringUtilsTest.java @@ -0,0 +1,19 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.processor; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class StringUtilsTest { + @Test + void lowerCamelCase() { + assertEquals("ioLogger", StringUtils.lowerCamelCase("IOLogger")); + assertEquals("ledSubsystem", StringUtils.lowerCamelCase("LEDSubsystem")); + assertEquals("fooBar", StringUtils.lowerCamelCase("FooBar")); + assertEquals("allcaps", StringUtils.lowerCamelCase("ALLCAPS")); + } +} diff --git a/epilogue-runtime/build.gradle b/epilogue-runtime/build.gradle new file mode 100644 index 0000000000..fb96095a0a --- /dev/null +++ b/epilogue-runtime/build.gradle @@ -0,0 +1,16 @@ +ext { + useJava = true + useCpp = false + baseId = 'epilogue-runtime' + groupId = 'edu.wpi.first.epilogue' + + devMain = '' +} + +apply from: "${rootDir}/shared/java/javacommon.gradle" + +dependencies { + api(project(':ntcore')) + api(project(':wpiutil')) + api(project(':wpiunits')) +} diff --git a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/CustomLoggerFor.java b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/CustomLoggerFor.java new file mode 100644 index 0000000000..93b11cf1f4 --- /dev/null +++ b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/CustomLoggerFor.java @@ -0,0 +1,30 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Placed on a subclass of {@code ClassSpecificLogger}. Epilogue will detect it at compile time and + * allow logging of data types compatible with the logger. + * + *

+ *   {@literal @}CustomLoggerFor(VendorMotorType.class)
+ *    class ExampleMotorLogger extends ClassSpecificLogger<VendorMotorType> { }
+ * 
+ */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface CustomLoggerFor { + /** + * The class or classes of objects able to be logged with the annotated logger. + * + * @return the supported data types + */ + Class[] value(); +} diff --git a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/EpilogueConfiguration.java b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/EpilogueConfiguration.java new file mode 100644 index 0000000000..e5b569bf0a --- /dev/null +++ b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/EpilogueConfiguration.java @@ -0,0 +1,43 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue; + +import edu.wpi.first.epilogue.logging.DataLogger; +import edu.wpi.first.epilogue.logging.NTDataLogger; +import edu.wpi.first.epilogue.logging.errors.ErrorHandler; +import edu.wpi.first.epilogue.logging.errors.ErrorPrinter; +import edu.wpi.first.networktables.NetworkTableInstance; + +/** + * A configuration object to be used by the generated {@code Epilogue} class to customize its + * behavior. + */ +@SuppressWarnings("checkstyle:MemberName") +public class EpilogueConfiguration { + /** + * The data logger implementation for Epilogue to use. By default, this will log data directly to + * NetworkTables. NetworkTable data can be mirrored to a log file on disk by calling {@code + * DataLogManager.start()} in your {@code robotInit} method. + */ + public DataLogger dataLogger = new NTDataLogger(NetworkTableInstance.getDefault()); + + /** + * The minimum importance level of data to be logged. Defaults to debug, which logs data of all + * importance levels. Any data tagged with an importance level lower than this will not be logged. + */ + public Logged.Importance minimumImportance = Logged.Importance.DEBUG; + + /** + * The error handler for loggers to use if they encounter an error while logging. Defaults to + * printing an error to the standard output. + */ + public ErrorHandler errorHandler = new ErrorPrinter(); + + /** + * The root identifier to use for all logged data. Defaults to "Robot", but can be changed to any + * string. + */ + public String root = "Robot"; +} diff --git a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/Logged.java b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/Logged.java new file mode 100644 index 0000000000..94762fc03d --- /dev/null +++ b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/Logged.java @@ -0,0 +1,92 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Place this annotation on a class to automatically log every field and every public accessor + * method (methods with no arguments and return a loggable data type). Use {@link #strategy()} to + * flag a class as logging everything it can, except for those elements tagged with + * {@code @Logged(importance = NONE)}; or for logging only specific items also tagged with + * {@code @Logged}. + * + *

Logged fields may have any access modifier. Logged methods must be public; non-public methods + * will be ignored. + * + *

Epilogue can log all primitive types, arrays of primitive types (except char and short), + * Strings, arrays of Strings, sendable objects, objects with a struct serializer, and arrays of + * objects with struct serializers. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD, ElementType.METHOD, ElementType.TYPE}) +public @interface Logged { + /** + * The name for the annotated element to be logged as. Does nothing on class-level annotations. + * Fields and methods will default to be logged using their in-code names; use this attribute to + * set it to something custom. + * + *

If the annotation is placed on a class, the specified name will not change logged data + * (since that uses the names of the specific usages of the class in fields and methods); however, + * it will be used to set the names of the generated logger that Logged will use to log instances + * of the class. This can be used to avoid name conflicts if you have multiple classes with the + * same name, but in different packages, and want to be able to log both. + * + * @return the name to use to log the field or method under; or the name of the generated + * class-specific logger + */ + String name() default ""; + + /** Opt-in or opt-out strategies for logging. */ + enum Strategy { + /** + * Log everything except for those elements explicitly opted out of with the skip = true + * attribute. This is the default behavior. + */ + OPT_OUT, + + /** Log only fields and methods tagged with an {@link Logged} annotation. */ + OPT_IN + } + + /** + * The strategy to use for logging. Only has an effect on annotations on class or interface + * declarations. + * + * @return the strategy to use to determine which fields and methods in the class to log + */ + Strategy strategy() default Strategy.OPT_OUT; + + /** + * Data importance. Can be used at the class level to set the default importance for all data + * points in the class, and can be used on individual fields and methods to set a specific + * importance level overriding the class-level default. + */ + enum Importance { + /** Debug information. Useful for low-level information like raw sensor values. */ + DEBUG, + + /** + * Informational data. Useful for higher-level information like pose estimates or subsystem + * state. + */ + INFO, + + /** Critical data that should always be present in logs. */ + CRITICAL + } + + /** + * The importance of the annotated data. If placed on a class or interface, this will be the + * default importance of all data within that class; this can be overridden on a per-element basis + * by annotating fields and methods with their own {@code @Logged(importance = ...)} annotation. + * + * @return the importance of the annotated element + */ + Importance importance() default Importance.DEBUG; +} diff --git a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/NotLogged.java b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/NotLogged.java new file mode 100644 index 0000000000..af0d3217b5 --- /dev/null +++ b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/NotLogged.java @@ -0,0 +1,18 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * A field or method annotated as {@code @NotLogged} will be ignored by Epilogue when determining + * the data to log. + */ +@Target({ElementType.FIELD, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface NotLogged {} diff --git a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/ClassSpecificLogger.java b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/ClassSpecificLogger.java new file mode 100644 index 0000000000..d5f34b3b75 --- /dev/null +++ b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/ClassSpecificLogger.java @@ -0,0 +1,119 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.logging; + +import edu.wpi.first.epilogue.CustomLoggerFor; +import edu.wpi.first.epilogue.logging.errors.ErrorHandler; +import edu.wpi.first.util.sendable.Sendable; +import edu.wpi.first.util.sendable.SendableBuilder; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Base class for class-specific generated loggers. Loggers are generated at compile time by the + * Epilogue annotation processor and are used at runtime for zero-overhead data logging. Users may + * also declare custom loggers, annotated with {@link CustomLoggerFor @CustomLoggerFor}, for + * Epilogue to pull in during compile time to use for logging third party types. + * + * @param the type of data supported by the logger + */ +@SuppressWarnings("unused") // Used by generated subclasses +public abstract class ClassSpecificLogger { + private final Class m_clazz; + // TODO: This will hold onto Sendables that are otherwise no longer referenced by a robot program. + // Determine if that's a concern + // Linked hashmap to maintain insert order + private final Map m_sendables = new LinkedHashMap<>(); + + @SuppressWarnings("PMD.RedundantFieldInitializer") + private boolean m_disabled = false; + + /** + * Instantiates the logger. + * + * @param clazz the Java class of objects that can be logged + */ + protected ClassSpecificLogger(Class clazz) { + this.m_clazz = clazz; + } + + /** + * Updates an object's fields in a data log. + * + * @param dataLogger the logger to update + * @param object the object to update in the log + */ + protected abstract void update(DataLogger dataLogger, T object); + + /** + * Attempts to update the data log. Will do nothing if the logger is {@link #disable() disabled}. + * + * @param dataLogger the logger to log data to + * @param object the data object to log + * @param errorHandler the handler to use if logging raised an exception + */ + @SuppressWarnings("PMD.AvoidCatchingGenericException") + public final void tryUpdate(DataLogger dataLogger, T object, ErrorHandler errorHandler) { + if (m_disabled) { + return; + } + + try { + update(dataLogger, object); + } catch (Exception e) { + errorHandler.handle(e, this); + } + } + + /** + * Checks if this logger has been disabled. + * + * @return true if this logger has been disabled by {@link #disable()}, false if not + */ + public final boolean isDisabled() { + return m_disabled; + } + + /** Disables this logger. Any log calls made while disabled will be ignored. */ + public final void disable() { + m_disabled = true; + } + + /** Reenables this logger after being disabled. Has no effect if the logger is already enabled. */ + public final void reenable() { + m_disabled = false; + } + + /** + * Gets the type of the data this logger accepts. + * + * @return the logged data type + */ + public final Class getLoggedType() { + return m_clazz; + } + + /** + * Logs a sendable type. + * + * @param dataLogger the logger to log data into + * @param sendable the sendable object to log + */ + protected void logSendable(DataLogger dataLogger, Sendable sendable) { + if (sendable == null) { + return; + } + + var builder = + m_sendables.computeIfAbsent( + sendable, + s -> { + var b = new LogBackedSendableBuilder(dataLogger); + s.initSendable(b); + return b; + }); + builder.update(); + } +} diff --git a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/DataLogger.java b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/DataLogger.java new file mode 100644 index 0000000000..a8b6f34168 --- /dev/null +++ b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/DataLogger.java @@ -0,0 +1,229 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.logging; + +import edu.wpi.first.units.Measure; +import edu.wpi.first.units.Unit; +import edu.wpi.first.util.struct.Struct; +import java.util.Collection; + +/** A data logger is a generic interface for logging discrete data points. */ +public interface DataLogger { + /** + * Creates a data logger that logs to multiple backends at once. Data reads will still only occur + * once; data is passed to all composed loggers at once. + * + * @param loggers the loggers to compose together + * @return the multi logger + */ + static DataLogger multi(DataLogger... loggers) { + return new MultiLogger(loggers); + } + + /** + * Creates a lazy version of this logger. A lazy logger will only log data to a field when its + * value changes, which can help keep file size and bandwidth usage in check. However, there is an + * additional CPU and memory overhead associated with tracking the current value of every logged + * entry. The most surefire way to reduce CPU and memory usage associated with logging is to log + * fewer things - which can be done by opting out of logging unnecessary data or increasing the + * minimum logged importance level in the Epilogue configuration. + * + * @return the lazy logger + */ + default DataLogger lazy() { + return new LazyLogger(this); + } + + /** + * Gets a logger that can be used to log nested data underneath a specific path. + * + * @param path the path to use for logging nested data under + * @return the sub logger + */ + DataLogger getSubLogger(String path); + + /** + * Logs a 32-bit integer data point. + * + * @param identifier the identifier of the data point + * @param value the value of the data point + */ + void log(String identifier, int value); + + /** + * Logs a 64-bit integer data point. + * + * @param identifier the identifier of the data point + * @param value the value of the data point + */ + void log(String identifier, long value); + + /** + * Logs a 32-bit floating point data point. + * + * @param identifier the identifier of the data point + * @param value the value of the data point + */ + void log(String identifier, float value); + + /** + * Logs a 64-bit floating point data point. + * + * @param identifier the identifier of the data point + * @param value the value of the data point + */ + void log(String identifier, double value); + + /** + * Logs a boolean data point. + * + * @param identifier the identifier of the data point + * @param value the value of the data point + */ + void log(String identifier, boolean value); + + /** + * Logs a raw byte array data point. NOTE: serializable data should be logged + * using {@link #log(String, Object, Struct)}. + * + * @param identifier the identifier of the data point + * @param value the value of the data point + */ + void log(String identifier, byte[] value); + + /** + * Logs a 32-bit integer array data point. + * + * @param identifier the identifier of the data point + * @param value the value of the data point + */ + void log(String identifier, int[] value); + + /** + * Logs a 64-bit integer array data point. + * + * @param identifier the identifier of the data point + * @param value the value of the data point + */ + void log(String identifier, long[] value); + + /** + * Logs a 32-bit floating point array data point. + * + * @param identifier the identifier of the data point + * @param value the value of the data point + */ + void log(String identifier, float[] value); + + /** + * Logs a 64-bit floating point array data point. + * + * @param identifier the identifier of the data point + * @param value the value of the data point + */ + void log(String identifier, double[] value); + + /** + * Logs a boolean array data point. + * + * @param identifier the identifier of the data point + * @param value the value of the data point + */ + void log(String identifier, boolean[] value); + + /** + * Logs a text data point. + * + * @param identifier the identifier of the data point + * @param value the value of the data point + */ + void log(String identifier, String value); + + /** + * Logs a string array data point. + * + * @param identifier the identifier of the data point + * @param value the value of the data point + */ + void log(String identifier, String[] value); + + /** + * Logs a collection of strings data point. + * + * @param identifier the identifier of the data point + * @param value the value of the data point + */ + default void log(String identifier, Collection value) { + log(identifier, value.toArray(String[]::new)); + } + + /** + * Logs a struct-serializable object. + * + * @param identifier the identifier of the data point + * @param value the value of the data point + * @param struct the struct to use to serialize the data + * @param the serializable type + */ + void log(String identifier, S value, Struct struct); + + /** + * Logs an array of struct-serializable objects. + * + * @param identifier the identifier of the data point + * @param value the value of the data point + * @param struct the struct to use to serialize the objects + * @param the serializable type + */ + void log(String identifier, S[] value, Struct struct); + + /** + * Logs a collection of struct-serializable objects. + * + * @param identifier the identifier of the data + * @param value the collection of objects to log + * @param struct the struct to use to serialize the objects + * @param the serializable type + */ + default void log(String identifier, Collection value, Struct struct) { + @SuppressWarnings("unchecked") + S[] array = (S[]) value.toArray(Object[]::new); + log(identifier, array, struct); + } + + /** + * Logs a measurement's value in terms of its base unit. + * + * @param identifier the identifier of the data field + * @param value the new value of the data field + */ + default void log(String identifier, Measure value) { + log(identifier, value.baseUnitMagnitude()); + } + + /** + * Logs a measurement's value in terms of another unit. + * + * @param identifier the identifier of the data field + * @param value the new value of the data field + * @param unit the unit to log the measurement in + * @param the dimension of the unit + */ + default > void log(String identifier, Measure value, U unit) { + log(identifier, value.in(unit)); + } + + /** + * Logs an enum value. The value will appear as a string entry using the name of the enum. + * + * @param identifier the identifier of the data field + * @param value the new value of the data field + */ + default void log(String identifier, Enum value) { + log(identifier, value.name()); + } + + // TODO: Add default methods to support common no-struct no-sendable types like joysticks? +} diff --git a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/FileLogger.java b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/FileLogger.java new file mode 100644 index 0000000000..9c66bca274 --- /dev/null +++ b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/FileLogger.java @@ -0,0 +1,144 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.logging; + +import static edu.wpi.first.util.ErrorMessages.requireNonNullParam; + +import edu.wpi.first.util.datalog.BooleanArrayLogEntry; +import edu.wpi.first.util.datalog.BooleanLogEntry; +import edu.wpi.first.util.datalog.DataLog; +import edu.wpi.first.util.datalog.DataLogEntry; +import edu.wpi.first.util.datalog.DoubleArrayLogEntry; +import edu.wpi.first.util.datalog.DoubleLogEntry; +import edu.wpi.first.util.datalog.FloatArrayLogEntry; +import edu.wpi.first.util.datalog.FloatLogEntry; +import edu.wpi.first.util.datalog.IntegerArrayLogEntry; +import edu.wpi.first.util.datalog.IntegerLogEntry; +import edu.wpi.first.util.datalog.RawLogEntry; +import edu.wpi.first.util.datalog.StringArrayLogEntry; +import edu.wpi.first.util.datalog.StringLogEntry; +import edu.wpi.first.util.datalog.StructArrayLogEntry; +import edu.wpi.first.util.datalog.StructLogEntry; +import edu.wpi.first.util.struct.Struct; +import java.util.HashMap; +import java.util.Map; +import java.util.function.BiFunction; + +/** A data logger implementation that saves information to a WPILib {@link DataLog} file on disk. */ +public class FileLogger implements DataLogger { + private final DataLog m_dataLog; + private final Map m_entries = new HashMap<>(); + private final Map m_subLoggers = new HashMap<>(); + + /** + * Creates a new file logger. + * + * @param dataLog the data log to save data to + */ + public FileLogger(DataLog dataLog) { + this.m_dataLog = requireNonNullParam(dataLog, "dataLog", "FileLogger"); + } + + @Override + public DataLogger getSubLogger(String path) { + return m_subLoggers.computeIfAbsent(path, k -> new SubLogger(k, this)); + } + + @SuppressWarnings("unchecked") + private E getEntry( + String identifier, BiFunction ctor) { + if (m_entries.get(identifier) != null) { + return (E) m_entries.get(identifier); + } + + var entry = ctor.apply(m_dataLog, identifier); + m_entries.put(identifier, entry); + return entry; + } + + @Override + public void log(String identifier, int value) { + getEntry(identifier, IntegerLogEntry::new).append(value); + } + + @Override + public void log(String identifier, long value) { + getEntry(identifier, IntegerLogEntry::new).append(value); + } + + @Override + public void log(String identifier, float value) { + getEntry(identifier, FloatLogEntry::new).append(value); + } + + @Override + public void log(String identifier, double value) { + getEntry(identifier, DoubleLogEntry::new).append(value); + } + + @Override + public void log(String identifier, boolean value) { + getEntry(identifier, BooleanLogEntry::new).append(value); + } + + @Override + public void log(String identifier, byte[] value) { + getEntry(identifier, RawLogEntry::new).append(value); + } + + @Override + @SuppressWarnings("PMD.UnnecessaryCastRule") + public void log(String identifier, int[] value) { + long[] widened = new long[value.length]; + for (int i = 0; i < value.length; i++) { + widened[i] = (long) value[i]; + } + getEntry(identifier, IntegerArrayLogEntry::new).append(widened); + } + + @Override + public void log(String identifier, long[] value) { + getEntry(identifier, IntegerArrayLogEntry::new).append(value); + } + + @Override + public void log(String identifier, float[] value) { + getEntry(identifier, FloatArrayLogEntry::new).append(value); + } + + @Override + public void log(String identifier, double[] value) { + getEntry(identifier, DoubleArrayLogEntry::new).append(value); + } + + @Override + public void log(String identifier, boolean[] value) { + getEntry(identifier, BooleanArrayLogEntry::new).append(value); + } + + @Override + public void log(String identifier, String value) { + getEntry(identifier, StringLogEntry::new).append(value); + } + + @Override + public void log(String identifier, String[] value) { + getEntry(identifier, StringArrayLogEntry::new).append(value); + } + + @Override + @SuppressWarnings("unchecked") + public void log(String identifier, S value, Struct struct) { + m_dataLog.addSchema(struct); + getEntry(identifier, (log, k) -> StructLogEntry.create(log, k, struct)).append(value); + } + + @Override + @SuppressWarnings("unchecked") + public void log(String identifier, S[] value, Struct struct) { + m_dataLog.addSchema(struct); + getEntry(identifier, (log, k) -> StructArrayLogEntry.create(log, k, struct)).append(value); + } +} diff --git a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/LazyLogger.java b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/LazyLogger.java new file mode 100644 index 0000000000..6e99aa5c30 --- /dev/null +++ b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/LazyLogger.java @@ -0,0 +1,240 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.logging; + +import edu.wpi.first.util.struct.Struct; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * A data logger implementation that only logs data when it changes. Useful for keeping bandwidth + * and file sizes down. However, because it still needs to check that data has changed, it cannot + * avoid expensive sensor reads. + */ +public class LazyLogger implements DataLogger { + private final DataLogger m_logger; + + // Keep a record of the most recent value written to each entry + // Note that this may duplicate a lot of data, and will box primitives. + private final Map m_previousValues = new HashMap<>(); + private final Map m_subLoggers = new HashMap<>(); + + /** + * Creates a new lazy logger wrapper around another logger. + * + * @param logger the logger to delegate to + */ + public LazyLogger(DataLogger logger) { + this.m_logger = logger; + } + + @Override + public DataLogger lazy() { + // Already lazy, don't need to wrap it again + return this; + } + + @Override + public DataLogger getSubLogger(String path) { + return m_subLoggers.computeIfAbsent(path, k -> new SubLogger(k, this)); + } + + @Override + public void log(String identifier, int value) { + var previous = m_previousValues.get(identifier); + + if (previous instanceof Integer oldValue && oldValue == value) { + // no change + return; + } + + m_previousValues.put(identifier, value); + m_logger.log(identifier, value); + } + + @Override + public void log(String identifier, long value) { + var previous = m_previousValues.get(identifier); + + if (previous instanceof Long oldValue && oldValue == value) { + // no change + return; + } + + m_previousValues.put(identifier, value); + m_logger.log(identifier, value); + } + + @Override + public void log(String identifier, float value) { + var previous = m_previousValues.get(identifier); + + if (previous instanceof Float oldValue && oldValue == value) { + // no change + return; + } + + m_previousValues.put(identifier, value); + m_logger.log(identifier, value); + } + + @Override + public void log(String identifier, double value) { + var previous = m_previousValues.get(identifier); + + if (previous instanceof Double oldValue && oldValue == value) { + // no change + return; + } + + m_previousValues.put(identifier, value); + m_logger.log(identifier, value); + } + + @Override + public void log(String identifier, boolean value) { + var previous = m_previousValues.get(identifier); + + if (previous instanceof Boolean oldValue && oldValue == value) { + // no change + return; + } + + m_previousValues.put(identifier, value); + m_logger.log(identifier, value); + } + + @Override + public void log(String identifier, byte[] value) { + var previous = m_previousValues.get(identifier); + + if (previous instanceof byte[] oldValue && Arrays.equals(oldValue, value)) { + // no change + return; + } + + m_previousValues.put(identifier, value); + m_logger.log(identifier, value); + } + + @Override + public void log(String identifier, int[] value) { + var previous = m_previousValues.get(identifier); + + if (previous instanceof int[] oldValue && Arrays.equals(oldValue, value)) { + // no change + return; + } + + m_previousValues.put(identifier, value); + m_logger.log(identifier, value); + } + + @Override + public void log(String identifier, long[] value) { + var previous = m_previousValues.get(identifier); + + if (previous instanceof long[] oldValue && Arrays.equals(oldValue, value)) { + // no change + return; + } + + m_previousValues.put(identifier, value); + m_logger.log(identifier, value); + } + + @Override + public void log(String identifier, float[] value) { + var previous = m_previousValues.get(identifier); + + if (previous instanceof float[] oldValue && Arrays.equals(oldValue, value)) { + // no change + return; + } + + m_previousValues.put(identifier, value); + m_logger.log(identifier, value); + } + + @Override + public void log(String identifier, double[] value) { + var previous = m_previousValues.get(identifier); + + if (previous instanceof double[] oldValue && Arrays.equals(oldValue, value)) { + // no change + return; + } + + m_previousValues.put(identifier, value); + m_logger.log(identifier, value); + } + + @Override + public void log(String identifier, boolean[] value) { + var previous = m_previousValues.get(identifier); + + if (previous instanceof boolean[] oldValue && Arrays.equals(oldValue, value)) { + // no change + return; + } + + m_previousValues.put(identifier, value); + m_logger.log(identifier, value); + } + + @Override + public void log(String identifier, String value) { + var previous = m_previousValues.get(identifier); + + if (previous instanceof String oldValue && oldValue.equals(value)) { + // no change + return; + } + + m_previousValues.put(identifier, value); + m_logger.log(identifier, value); + } + + @Override + public void log(String identifier, String[] value) { + var previous = m_previousValues.get(identifier); + + if (previous instanceof String[] oldValue && Arrays.equals(oldValue, value)) { + // no change + return; + } + + m_previousValues.put(identifier, value); + m_logger.log(identifier, value); + } + + @Override + public void log(String identifier, S value, Struct struct) { + var previous = m_previousValues.get(identifier); + + if (Objects.equals(previous, value)) { + // no change + return; + } + + m_previousValues.put(identifier, value); + m_logger.log(identifier, value, struct); + } + + @Override + public void log(String identifier, S[] value, Struct struct) { + var previous = m_previousValues.get(identifier); + + if (previous instanceof Object[] oldValue && Arrays.equals(oldValue, value)) { + // no change + return; + } + + m_previousValues.put(identifier, value); + m_logger.log(identifier, value, struct); + } +} diff --git a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/LogBackedSendableBuilder.java b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/LogBackedSendableBuilder.java new file mode 100644 index 0000000000..aa71dbbf97 --- /dev/null +++ b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/LogBackedSendableBuilder.java @@ -0,0 +1,212 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.logging; + +import edu.wpi.first.util.function.BooleanConsumer; +import edu.wpi.first.util.function.FloatConsumer; +import edu.wpi.first.util.function.FloatSupplier; +import edu.wpi.first.util.sendable.SendableBuilder; +import java.util.ArrayList; +import java.util.Collection; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; +import java.util.function.DoubleConsumer; +import java.util.function.DoubleSupplier; +import java.util.function.LongConsumer; +import java.util.function.LongSupplier; +import java.util.function.Supplier; + +/** A sendable builder implementation that sends data to a {@link DataLogger}. */ +@SuppressWarnings("PMD.CouplingBetweenObjects") // most methods simply delegate to the logger +public class LogBackedSendableBuilder implements SendableBuilder { + private final DataLogger m_logger; + private final Collection m_updates = new ArrayList<>(); + + /** + * Creates a new sendable builder that delegates writes to an underlying data logger. + * + * @param logger the data logger to write the sendable data to + */ + public LogBackedSendableBuilder(DataLogger logger) { + this.m_logger = logger; + } + + @Override + public void setSmartDashboardType(String type) { + m_logger.log(".type", type); + } + + @Override + public void setActuator(boolean value) { + // ignore + } + + @Override + public void setSafeState(Runnable func) { + // ignore + } + + @Override + public void addBooleanProperty(String key, BooleanSupplier getter, BooleanConsumer setter) { + m_updates.add(() -> m_logger.log(key, getter.getAsBoolean())); + } + + @Override + public void publishConstBoolean(String key, boolean value) { + m_logger.log(key, value); + } + + @Override + public void addIntegerProperty(String key, LongSupplier getter, LongConsumer setter) { + m_updates.add(() -> m_logger.log(key, getter.getAsLong())); + } + + @Override + public void publishConstInteger(String key, long value) { + m_logger.log(key, value); + } + + @Override + public void addFloatProperty(String key, FloatSupplier getter, FloatConsumer setter) { + m_updates.add(() -> m_logger.log(key, getter.getAsFloat())); + } + + @Override + public void publishConstFloat(String key, float value) { + m_logger.log(key, value); + } + + @Override + public void addDoubleProperty(String key, DoubleSupplier getter, DoubleConsumer setter) { + m_updates.add(() -> m_logger.log(key, getter.getAsDouble())); + } + + @Override + public void publishConstDouble(String key, double value) { + m_logger.log(key, value); + } + + @Override + public void addStringProperty(String key, Supplier getter, Consumer setter) { + if (getter != null) { + m_updates.add(() -> m_logger.log(key, getter.get())); + } + } + + @Override + public void publishConstString(String key, String value) { + m_logger.log(key, value); + } + + @Override + public void addBooleanArrayProperty( + String key, Supplier getter, Consumer setter) { + if (getter != null) { + m_updates.add(() -> m_logger.log(key, getter.get())); + } + } + + @Override + public void publishConstBooleanArray(String key, boolean[] value) { + m_logger.log(key, value); + } + + @Override + public void addIntegerArrayProperty( + String key, Supplier getter, Consumer setter) { + if (getter != null) { + m_updates.add(() -> m_logger.log(key, getter.get())); + } + } + + @Override + public void publishConstIntegerArray(String key, long[] value) { + m_logger.log(key, value); + } + + @Override + public void addFloatArrayProperty( + String key, Supplier getter, Consumer setter) { + if (getter != null) { + m_updates.add(() -> m_logger.log(key, getter.get())); + } + } + + @Override + public void publishConstFloatArray(String key, float[] value) { + m_logger.log(key, value); + } + + @Override + public void addDoubleArrayProperty( + String key, Supplier getter, Consumer setter) { + if (getter != null) { + m_updates.add(() -> m_logger.log(key, getter.get())); + } + } + + @Override + public void publishConstDoubleArray(String key, double[] value) { + m_logger.log(key, value); + } + + @Override + public void addStringArrayProperty( + String key, Supplier getter, Consumer setter) { + if (getter != null) { + m_updates.add(() -> m_logger.log(key, getter.get())); + } + } + + @Override + public void publishConstStringArray(String key, String[] value) { + m_logger.log(key, value); + } + + @Override + public void addRawProperty( + String key, String typeString, Supplier getter, Consumer setter) { + if (getter != null) { + m_updates.add(() -> m_logger.log(key, getter.get())); + } + } + + @Override + public void publishConstRaw(String key, String typeString, byte[] value) { + m_logger.log(key, value); + } + + @Override + public BackendKind getBackendKind() { + return BackendKind.kUnknown; + } + + @Override + public boolean isPublished() { + return true; + } + + @Override + public void update() { + for (Runnable update : m_updates) { + update.run(); + } + } + + @Override + public void clearProperties() { + m_updates.clear(); + } + + @Override + public void addCloseable(AutoCloseable closeable) { + // Ignore + } + + @Override + public void close() throws Exception { + clearProperties(); + } +} diff --git a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/MultiLogger.java b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/MultiLogger.java new file mode 100644 index 0000000000..b7ad348d75 --- /dev/null +++ b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/MultiLogger.java @@ -0,0 +1,134 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.logging; + +import edu.wpi.first.util.struct.Struct; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * A data logger implementation that delegates to other loggers. Helpful for simultaneous logging to + * multiple data stores at once. + */ +public class MultiLogger implements DataLogger { + private final List m_loggers; + private final Map m_subLoggers = new HashMap<>(); + + // Use DataLogger.multi(...) instead of instantiation directly + MultiLogger(DataLogger... loggers) { + this.m_loggers = List.of(loggers); + } + + @Override + public DataLogger getSubLogger(String path) { + return m_subLoggers.computeIfAbsent(path, k -> new SubLogger(k, this)); + } + + @Override + public void log(String identifier, int value) { + for (DataLogger logger : m_loggers) { + logger.log(identifier, value); + } + } + + @Override + public void log(String identifier, long value) { + for (DataLogger logger : m_loggers) { + logger.log(identifier, value); + } + } + + @Override + public void log(String identifier, float value) { + for (DataLogger logger : m_loggers) { + logger.log(identifier, value); + } + } + + @Override + public void log(String identifier, double value) { + for (DataLogger logger : m_loggers) { + logger.log(identifier, value); + } + } + + @Override + public void log(String identifier, boolean value) { + for (DataLogger logger : m_loggers) { + logger.log(identifier, value); + } + } + + @Override + public void log(String identifier, byte[] value) { + for (DataLogger logger : m_loggers) { + logger.log(identifier, value); + } + } + + @Override + public void log(String identifier, int[] value) { + for (DataLogger logger : m_loggers) { + logger.log(identifier, value); + } + } + + @Override + public void log(String identifier, long[] value) { + for (DataLogger logger : m_loggers) { + logger.log(identifier, value); + } + } + + @Override + public void log(String identifier, float[] value) { + for (DataLogger logger : m_loggers) { + logger.log(identifier, value); + } + } + + @Override + public void log(String identifier, double[] value) { + for (DataLogger logger : m_loggers) { + logger.log(identifier, value); + } + } + + @Override + public void log(String identifier, boolean[] value) { + for (DataLogger logger : m_loggers) { + logger.log(identifier, value); + } + } + + @Override + public void log(String identifier, String value) { + for (DataLogger logger : m_loggers) { + logger.log(identifier, value); + } + } + + @Override + public void log(String identifier, String[] value) { + for (DataLogger logger : m_loggers) { + logger.log(identifier, value); + } + } + + @Override + public void log(String identifier, S value, Struct struct) { + for (DataLogger logger : m_loggers) { + logger.log(identifier, value, struct); + } + } + + @Override + public void log(String identifier, S[] value, Struct struct) { + for (DataLogger logger : m_loggers) { + logger.log(identifier, value, struct); + } + } +} diff --git a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/NTDataLogger.java b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/NTDataLogger.java new file mode 100644 index 0000000000..881d7f7c5e --- /dev/null +++ b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/NTDataLogger.java @@ -0,0 +1,167 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.logging; + +import edu.wpi.first.networktables.BooleanArrayPublisher; +import edu.wpi.first.networktables.BooleanPublisher; +import edu.wpi.first.networktables.DoubleArrayPublisher; +import edu.wpi.first.networktables.DoublePublisher; +import edu.wpi.first.networktables.FloatArrayPublisher; +import edu.wpi.first.networktables.FloatPublisher; +import edu.wpi.first.networktables.IntegerArrayPublisher; +import edu.wpi.first.networktables.IntegerPublisher; +import edu.wpi.first.networktables.NetworkTableInstance; +import edu.wpi.first.networktables.Publisher; +import edu.wpi.first.networktables.RawPublisher; +import edu.wpi.first.networktables.StringArrayPublisher; +import edu.wpi.first.networktables.StringPublisher; +import edu.wpi.first.networktables.StructArrayPublisher; +import edu.wpi.first.networktables.StructPublisher; +import edu.wpi.first.util.struct.Struct; +import java.util.HashMap; +import java.util.Map; + +/** + * A data logger implementation that sends data over network tables. Be careful when using this, + * since sending too much data may cause bandwidth or CPU starvation. + */ +public class NTDataLogger implements DataLogger { + private final NetworkTableInstance m_nt; + + private final Map m_publishers = new HashMap<>(); + private final Map m_subLoggers = new HashMap<>(); + + /** + * Creates a data logger that sends information to NetworkTables. + * + * @param nt the NetworkTable instance to use to send data to + */ + public NTDataLogger(NetworkTableInstance nt) { + this.m_nt = nt; + } + + @Override + public DataLogger getSubLogger(String path) { + return m_subLoggers.computeIfAbsent(path, k -> new SubLogger(k, this)); + } + + @Override + public void log(String identifier, int value) { + ((IntegerPublisher) + m_publishers.computeIfAbsent(identifier, k -> m_nt.getIntegerTopic(k).publish())) + .set(value); + } + + @Override + public void log(String identifier, long value) { + ((IntegerPublisher) + m_publishers.computeIfAbsent(identifier, k -> m_nt.getIntegerTopic(k).publish())) + .set(value); + } + + @Override + public void log(String identifier, float value) { + ((FloatPublisher) + m_publishers.computeIfAbsent(identifier, k -> m_nt.getFloatTopic(k).publish())) + .set(value); + } + + @Override + public void log(String identifier, double value) { + ((DoublePublisher) + m_publishers.computeIfAbsent(identifier, k -> m_nt.getDoubleTopic(k).publish())) + .set(value); + } + + @Override + public void log(String identifier, boolean value) { + ((BooleanPublisher) + m_publishers.computeIfAbsent(identifier, k -> m_nt.getBooleanTopic(k).publish())) + .set(value); + } + + @Override + public void log(String identifier, byte[] value) { + ((RawPublisher) + m_publishers.computeIfAbsent(identifier, k -> m_nt.getRawTopic(k).publish("raw"))) + .set(value); + } + + @Override + @SuppressWarnings("PMD.UnnecessaryCastRule") + public void log(String identifier, int[] value) { + // NT backend only supports int64[], so we have to manually widen to 64 bits before sending + long[] widened = new long[value.length]; + + for (int i = 0; i < value.length; i++) { + widened[i] = (long) value[i]; + } + + ((IntegerArrayPublisher) + m_publishers.computeIfAbsent(identifier, k -> m_nt.getIntegerArrayTopic(k).publish())) + .set(widened); + } + + @Override + public void log(String identifier, long[] value) { + ((IntegerArrayPublisher) + m_publishers.computeIfAbsent(identifier, k -> m_nt.getIntegerArrayTopic(k).publish())) + .set(value); + } + + @Override + public void log(String identifier, float[] value) { + ((FloatArrayPublisher) + m_publishers.computeIfAbsent(identifier, k -> m_nt.getFloatArrayTopic(k).publish())) + .set(value); + } + + @Override + public void log(String identifier, double[] value) { + ((DoubleArrayPublisher) + m_publishers.computeIfAbsent(identifier, k -> m_nt.getDoubleArrayTopic(k).publish())) + .set(value); + } + + @Override + public void log(String identifier, boolean[] value) { + ((BooleanArrayPublisher) + m_publishers.computeIfAbsent(identifier, k -> m_nt.getBooleanArrayTopic(k).publish())) + .set(value); + } + + @Override + public void log(String identifier, String value) { + ((StringPublisher) + m_publishers.computeIfAbsent(identifier, k -> m_nt.getStringTopic(k).publish())) + .set(value); + } + + @Override + public void log(String identifier, String[] value) { + ((StringArrayPublisher) + m_publishers.computeIfAbsent(identifier, k -> m_nt.getStringArrayTopic(k).publish())) + .set(value); + } + + @Override + @SuppressWarnings("unchecked") + public void log(String identifier, S value, Struct struct) { + m_nt.addSchema(struct); + ((StructPublisher) + m_publishers.computeIfAbsent(identifier, k -> m_nt.getStructTopic(k, struct).publish())) + .set(value); + } + + @Override + @SuppressWarnings("unchecked") + public void log(String identifier, S[] value, Struct struct) { + m_nt.addSchema(struct); + ((StructArrayPublisher) + m_publishers.computeIfAbsent( + identifier, k -> m_nt.getStructArrayTopic(k, struct).publish())) + .set(value); + } +} diff --git a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/NullLogger.java b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/NullLogger.java new file mode 100644 index 0000000000..fb6d30474b --- /dev/null +++ b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/NullLogger.java @@ -0,0 +1,62 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.logging; + +import edu.wpi.first.util.struct.Struct; + +/** Null data logger implementation that logs nothing. */ +public class NullLogger implements DataLogger { + @Override + public DataLogger getSubLogger(String path) { + // Since a sublogger would still log nothing and has no state, we can just return the same + // null-logging implementation + return this; + } + + @Override + public void log(String identifier, int value) {} + + @Override + public void log(String identifier, long value) {} + + @Override + public void log(String identifier, float value) {} + + @Override + public void log(String identifier, double value) {} + + @Override + public void log(String identifier, boolean value) {} + + @Override + public void log(String identifier, byte[] value) {} + + @Override + public void log(String identifier, int[] value) {} + + @Override + public void log(String identifier, long[] value) {} + + @Override + public void log(String identifier, float[] value) {} + + @Override + public void log(String identifier, double[] value) {} + + @Override + public void log(String identifier, boolean[] value) {} + + @Override + public void log(String identifier, String value) {} + + @Override + public void log(String identifier, String[] value) {} + + @Override + public void log(String identifier, S value, Struct struct) {} + + @Override + public void log(String identifier, S[] value, Struct struct) {} +} diff --git a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/SubLogger.java b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/SubLogger.java new file mode 100644 index 0000000000..cb5a48ea1a --- /dev/null +++ b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/SubLogger.java @@ -0,0 +1,115 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.logging; + +import edu.wpi.first.util.struct.Struct; +import java.util.HashMap; +import java.util.Map; + +/** + * A data logger that logs to an underlying logger, prepending all logged data with a specific + * prefix. Useful for logging nested data structures. + */ +public class SubLogger implements DataLogger { + private final String m_prefix; + private final DataLogger m_impl; + private final Map m_subLoggers = new HashMap<>(); + + /** + * Creates a new sublogger underneath another logger. + * + * @param prefix the prefix to append to all data logged in the sublogger + * @param impl the data logger to log to + */ + public SubLogger(String prefix, DataLogger impl) { + // Add a trailing slash if not already present + if (prefix.endsWith("/")) { + this.m_prefix = prefix; + } else { + this.m_prefix = prefix + "/"; + } + this.m_impl = impl; + } + + @Override + public DataLogger getSubLogger(String path) { + return m_subLoggers.computeIfAbsent(path, k -> new SubLogger(k, this)); + } + + @Override + public void log(String identifier, int value) { + m_impl.log(m_prefix + identifier, value); + } + + @Override + public void log(String identifier, long value) { + m_impl.log(m_prefix + identifier, value); + } + + @Override + public void log(String identifier, float value) { + m_impl.log(m_prefix + identifier, value); + } + + @Override + public void log(String identifier, double value) { + m_impl.log(m_prefix + identifier, value); + } + + @Override + public void log(String identifier, boolean value) { + m_impl.log(m_prefix + identifier, value); + } + + @Override + public void log(String identifier, byte[] value) { + m_impl.log(m_prefix + identifier, value); + } + + @Override + public void log(String identifier, int[] value) { + m_impl.log(m_prefix + identifier, value); + } + + @Override + public void log(String identifier, long[] value) { + m_impl.log(m_prefix + identifier, value); + } + + @Override + public void log(String identifier, float[] value) { + m_impl.log(m_prefix + identifier, value); + } + + @Override + public void log(String identifier, double[] value) { + m_impl.log(m_prefix + identifier, value); + } + + @Override + public void log(String identifier, boolean[] value) { + m_impl.log(m_prefix + identifier, value); + } + + @Override + public void log(String identifier, String value) { + m_impl.log(m_prefix + identifier, value); + } + + @Override + public void log(String identifier, String[] value) { + m_impl.log(m_prefix + identifier, value); + } + + @Override + public void log(String identifier, S value, Struct struct) { + m_impl.log(m_prefix + identifier, value, struct); + } + + @Override + public void log(String identifier, S[] value, Struct struct) { + m_impl.log(m_prefix + identifier, value, struct); + } +} diff --git a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/errors/CrashOnError.java b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/errors/CrashOnError.java new file mode 100644 index 0000000000..63a68d0852 --- /dev/null +++ b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/errors/CrashOnError.java @@ -0,0 +1,22 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.logging.errors; + +import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + +/** + * An error handler implementation that will throw an exception if logging raised an exception. This + * is useful when running code in simulation or in JUnit tests to quickly identify errors in your + * code. + */ +public class CrashOnError implements ErrorHandler { + @Override + public void handle(Throwable exception, ClassSpecificLogger logger) { + throw new RuntimeException( + "[EPILOGUE] An error occurred while logging an instance of " + + logger.getLoggedType().getName(), + exception); + } +} diff --git a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/errors/ErrorHandler.java b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/errors/ErrorHandler.java new file mode 100644 index 0000000000..910de79089 --- /dev/null +++ b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/errors/ErrorHandler.java @@ -0,0 +1,61 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.logging.errors; + +import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + +/** + * An error handler is used by the Logged framework to catch and process any errors that occur + * during the logging process. Different handlers can be used in different operating modes, such as + * crashing in simulation to identify errors before they make it to a robot, or automatically + * disabling loggers if they encounter too many errors on the field to let the robot keep running + * while playing a match. + */ +@FunctionalInterface +public interface ErrorHandler { + /** + * Handles an exception that arose while logging. + * + * @param exception the exception that occurred + * @param logger the logger that was being processed that caused the error to occur + */ + void handle(Throwable exception, ClassSpecificLogger logger); + + /** + * Creates an error handler that will immediately re-throw an exception and cause robot code to + * exit. This is particularly useful when running in simulation or JUnit tests to identify errors + * quickly and safely. + * + * @return the error handler + */ + static ErrorHandler crashOnError() { + return new CrashOnError(); + } + + /** + * Creates an error handler that will print error messages to the console output, but otherwise + * allow logging to continue in the future. This can be helpful when errors occur only rarely and + * you don't want your robot program to crash or disable future logging. + * + * @return the error handler + */ + static ErrorHandler printErrorMessages() { + return new ErrorPrinter(); + } + + /** + * Creates an error handler that will automatically disable a logger if it encounters too many + * errors. Only the error-prone logger(s) will be disabled; loggers that have not encountered any + * errors, or encountered fewer than the limit, will continue to be used. Disabled loggers can be + * reset by calling {@link LoggerDisabler#reset()} on the handler. + * + * @param maximumPermissibleErrors the maximum number of errors that a logger is permitted to + * encounter before being disabled. + * @return the error handler + */ + static LoggerDisabler disabling(int maximumPermissibleErrors) { + return LoggerDisabler.forLimit(maximumPermissibleErrors); + } +} diff --git a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/errors/ErrorPrinter.java b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/errors/ErrorPrinter.java new file mode 100644 index 0000000000..4987994f9e --- /dev/null +++ b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/errors/ErrorPrinter.java @@ -0,0 +1,19 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.logging.errors; + +import edu.wpi.first.epilogue.logging.ClassSpecificLogger; + +/** An error handler implementation that prints error information to the console. */ +public class ErrorPrinter implements ErrorHandler { + @Override + public void handle(Throwable exception, ClassSpecificLogger logger) { + System.err.println( + "[EPILOGUE] An error occurred while logging an instance of " + + logger.getLoggedType().getName() + + ": " + + exception.getMessage()); + } +} diff --git a/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/errors/LoggerDisabler.java b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/errors/LoggerDisabler.java new file mode 100644 index 0000000000..99b20aa4b2 --- /dev/null +++ b/epilogue-runtime/src/main/java/edu/wpi/first/epilogue/logging/errors/LoggerDisabler.java @@ -0,0 +1,69 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.logging.errors; + +import edu.wpi.first.epilogue.logging.ClassSpecificLogger; +import java.util.HashMap; +import java.util.Map; + +/** + * An error handler that disables loggers after too many exceptions are raised. Useful when playing + * in matches, where data logging is less important than reliable control. Setting the threshold to + * ≤0 will cause any logger that encounters an exception whilst logging to immediately be disabled. + * Setting to higher values means your program is more tolerant of errors, but takes longer to + * disable, and therefore may have more sets of partial or incomplete data and may have more CPU + * overhead due to the cost of throwing exceptions. + */ +public class LoggerDisabler implements ErrorHandler { + private final int m_threshold; + private final Map, Integer> m_errorCounts = new HashMap<>(); + + /** + * Creates a new logger-disabling error handler. + * + * @param threshold how many errors any one logger is allowed to encounter before it is disabled. + */ + public LoggerDisabler(int threshold) { + this.m_threshold = threshold; + } + + /** + * Creates a disabler that kicks in after a logger raises more than {@code threshold} exceptions. + * + * @param threshold the threshold value for the maximum number of exceptions loggers are permitted + * to encounter before they are disabled + * @return the disabler + */ + public static LoggerDisabler forLimit(int threshold) { + return new LoggerDisabler(threshold); + } + + @Override + public void handle(Throwable exception, ClassSpecificLogger logger) { + var errorCount = m_errorCounts.getOrDefault(logger, 0) + 1; + m_errorCounts.put(logger, errorCount); + + if (errorCount > m_threshold) { + logger.disable(); + System.err.println( + "[EPILOGUE] Too many errors detected in " + + logger.getClass().getName() + + " (maximum allowed: " + + m_threshold + + "). The most recent error follows:"); + System.err.println(exception.getMessage()); + exception.printStackTrace(System.err); + } + } + + /** Resets all error counts and reenables all loggers. */ + public void reset() { + for (var logger : m_errorCounts.keySet()) { + // Safe. This is a no-op on loggers that are already enabled + logger.reenable(); + } + m_errorCounts.clear(); + } +} diff --git a/epilogue-runtime/src/test/java/edu/wpi/first/epilogue/logging/ClassSpecificLoggerTest.java b/epilogue-runtime/src/test/java/edu/wpi/first/epilogue/logging/ClassSpecificLoggerTest.java new file mode 100644 index 0000000000..1688f587eb --- /dev/null +++ b/epilogue-runtime/src/test/java/edu/wpi/first/epilogue/logging/ClassSpecificLoggerTest.java @@ -0,0 +1,44 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.logging; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import edu.wpi.first.epilogue.Logged; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ClassSpecificLoggerTest { + @Logged + record Point2d(double x, double y, int dim) { + static class Logger extends ClassSpecificLogger { + Logger() { + super(Point2d.class); + } + + @Override + protected void update(DataLogger dataLogger, Point2d object) { + dataLogger.log("x", object.x); + dataLogger.log("y", object.y); + dataLogger.log("dim", object.dim); + } + } + } + + @Test + void testReadPrivate() { + var point = new Point2d(1, 4, 2); + var logger = new Point2d.Logger(); + var dataLog = new TestLogger(); + logger.update(dataLog.getSubLogger("Point"), point); + + assertEquals( + List.of( + new TestLogger.LogEntry<>("Point/x", 1.0), + new TestLogger.LogEntry<>("Point/y", 4.0), + new TestLogger.LogEntry<>("Point/dim", 2)), + dataLog.getEntries()); + } +} diff --git a/epilogue-runtime/src/test/java/edu/wpi/first/epilogue/logging/LazyLoggerTest.java b/epilogue-runtime/src/test/java/edu/wpi/first/epilogue/logging/LazyLoggerTest.java new file mode 100644 index 0000000000..6fe07a7154 --- /dev/null +++ b/epilogue-runtime/src/test/java/edu/wpi/first/epilogue/logging/LazyLoggerTest.java @@ -0,0 +1,56 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.logging; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class LazyLoggerTest { + @Test + void lazyOfLazyReturnsSelf() { + var lazy = new LazyLogger(new NullLogger()); + assertSame(lazy, lazy.lazy()); + } + + @Test + void lazyInt() { + var logger = new TestLogger(); + var lazy = new LazyLogger(logger); + + { + // First time logging to "int" should go through + lazy.log("int", 0); + assertEquals(List.of(new TestLogger.LogEntry<>("int", 0)), logger.getEntries()); + } + + { + // Logging the current value shouldn't go through + lazy.log("int", 0); + assertEquals(List.of(new TestLogger.LogEntry<>("int", 0)), logger.getEntries()); + } + + { + // Logging a new value should go through + lazy.log("int", 1); + assertEquals( + List.of(new TestLogger.LogEntry<>("int", 0), new TestLogger.LogEntry<>("int", 1)), + logger.getEntries()); + } + + { + // Logging a previous value should go through + lazy.log("int", 0); + assertEquals( + List.of( + new TestLogger.LogEntry<>("int", 0), + new TestLogger.LogEntry<>("int", 1), + new TestLogger.LogEntry<>("int", 0)), + logger.getEntries()); + } + } +} diff --git a/epilogue-runtime/src/test/java/edu/wpi/first/epilogue/logging/TestLogger.java b/epilogue-runtime/src/test/java/edu/wpi/first/epilogue/logging/TestLogger.java new file mode 100644 index 0000000000..1dc8547ed7 --- /dev/null +++ b/epilogue-runtime/src/test/java/edu/wpi/first/epilogue/logging/TestLogger.java @@ -0,0 +1,109 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.epilogue.logging; + +import edu.wpi.first.util.struct.Struct; +import edu.wpi.first.util.struct.StructBuffer; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@SuppressWarnings("PMD.TestClassWithoutTestCases") // This is not a test class! +public class TestLogger implements DataLogger { + public record LogEntry(String identifier, T value) {} + + private final Map m_subLoggers = new HashMap<>(); + + private final List> m_entries = new ArrayList<>(); + + public List> getEntries() { + return m_entries; + } + + @Override + public DataLogger getSubLogger(String path) { + return m_subLoggers.computeIfAbsent(path, k -> new SubLogger(k, this)); + } + + @Override + public void log(String identifier, int value) { + m_entries.add(new LogEntry<>(identifier, value)); + } + + @Override + public void log(String identifier, long value) { + m_entries.add(new LogEntry<>(identifier, value)); + } + + @Override + public void log(String identifier, float value) { + m_entries.add(new LogEntry<>(identifier, value)); + } + + @Override + public void log(String identifier, double value) { + m_entries.add(new LogEntry<>(identifier, value)); + } + + @Override + public void log(String identifier, boolean value) { + m_entries.add(new LogEntry<>(identifier, value)); + } + + @Override + public void log(String identifier, byte[] value) { + m_entries.add(new LogEntry<>(identifier, value)); + } + + @Override + public void log(String identifier, int[] value) { + m_entries.add(new LogEntry<>(identifier, value)); + } + + @Override + public void log(String identifier, long[] value) { + m_entries.add(new LogEntry<>(identifier, value)); + } + + @Override + public void log(String identifier, float[] value) { + m_entries.add(new LogEntry<>(identifier, value)); + } + + @Override + public void log(String identifier, double[] value) { + m_entries.add(new LogEntry<>(identifier, value)); + } + + @Override + public void log(String identifier, boolean[] value) { + m_entries.add(new LogEntry<>(identifier, value)); + } + + @Override + public void log(String identifier, String value) { + m_entries.add(new LogEntry<>(identifier, value)); + } + + @Override + public void log(String identifier, String[] value) { + m_entries.add(new LogEntry<>(identifier, value)); + } + + @Override + public void log(String identifier, S value, Struct struct) { + var serialized = StructBuffer.create(struct).write(value).array(); + + m_entries.add(new LogEntry<>(identifier, serialized)); + } + + @Override + public void log(String identifier, S[] value, Struct struct) { + var serialized = StructBuffer.create(struct).writeArray(value).array(); + + m_entries.add(new LogEntry<>(identifier, serialized)); + } +} diff --git a/settings.gradle b/settings.gradle index 0ce2398ca6..7d63645cd5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -58,6 +58,8 @@ include 'msvcruntime' include 'ntcoreffi' include 'apriltag' include 'processstarter' +include 'epilogue-processor' +include 'epilogue-runtime' buildCache { def cred = { diff --git a/shared/java/javacommon.gradle b/shared/java/javacommon.gradle index 46ad707bb6..5d0cae5791 100644 --- a/shared/java/javacommon.gradle +++ b/shared/java/javacommon.gradle @@ -113,6 +113,8 @@ tasks.withType(JavaCompile).configureEach { "-Xlint:-try", // ignore missing serialVersionUID warnings "-Xlint:-serial", + // ignore unclaimed annotation warning from annotation processing + "-Xlint:-processing", ] } diff --git a/wpilibjExamples/build.gradle b/wpilibjExamples/build.gradle index 37b6d95cc9..e549e5d9ff 100644 --- a/wpilibjExamples/build.gradle +++ b/wpilibjExamples/build.gradle @@ -24,6 +24,8 @@ dependencies { implementation project(':wpilibNewCommands') implementation project(':romiVendordep') implementation project(':xrpVendordep') + implementation project(':epilogue-runtime') + annotationProcessor project(':epilogue-processor') testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/RapidReactCommandBot.java b/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/RapidReactCommandBot.java index 88b1913aa0..4c8ffbcd56 100644 --- a/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/RapidReactCommandBot.java +++ b/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/RapidReactCommandBot.java @@ -6,6 +6,7 @@ package edu.wpi.first.wpilibj.examples.rapidreactcommandbot; import static edu.wpi.first.wpilibj2.command.Commands.parallel; +import edu.wpi.first.epilogue.Logged; import edu.wpi.first.wpilibj.examples.rapidreactcommandbot.Constants.AutoConstants; import edu.wpi.first.wpilibj.examples.rapidreactcommandbot.Constants.OIConstants; import edu.wpi.first.wpilibj.examples.rapidreactcommandbot.Constants.ShooterConstants; @@ -24,6 +25,7 @@ import edu.wpi.first.wpilibj2.command.button.Trigger; * periodic methods (other than the scheduler calls). Instead, the structure of the robot (including * subsystems, commands, and button mappings) should be declared here. */ +@Logged(name = "Rapid React Command Robot Container") public class RapidReactCommandBot { // The robot's subsystems private final Drive m_drive = new Drive(); diff --git a/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/Robot.java b/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/Robot.java index 3588677ba4..049798cad6 100644 --- a/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/Robot.java +++ b/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/Robot.java @@ -4,6 +4,9 @@ package edu.wpi.first.wpilibj.examples.rapidreactcommandbot; +import edu.wpi.first.epilogue.Epilogue; +import edu.wpi.first.epilogue.Logged; +import edu.wpi.first.wpilibj.DataLogManager; import edu.wpi.first.wpilibj.TimedRobot; import edu.wpi.first.wpilibj2.command.Command; import edu.wpi.first.wpilibj2.command.CommandScheduler; @@ -14,6 +17,7 @@ import edu.wpi.first.wpilibj2.command.CommandScheduler; * the package after creating this project, you must also update the build.gradle file in the * project. */ +@Logged(name = "Rapid React Command Robot") public class Robot extends TimedRobot { private Command m_autonomousCommand; @@ -27,6 +31,10 @@ public class Robot extends TimedRobot { public void robotInit() { // Configure default commands and condition bindings on robot startup m_robot.configureBindings(); + + // Initialize data logging. + DataLogManager.start(); + Epilogue.bind(this); } /** diff --git a/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/subsystems/Drive.java b/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/subsystems/Drive.java index dd7854d15f..18f12001ca 100644 --- a/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/subsystems/Drive.java +++ b/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/subsystems/Drive.java @@ -4,6 +4,8 @@ package edu.wpi.first.wpilibj.examples.rapidreactcommandbot.subsystems; +import edu.wpi.first.epilogue.Logged; +import edu.wpi.first.epilogue.NotLogged; import edu.wpi.first.util.sendable.SendableRegistry; import edu.wpi.first.wpilibj.Encoder; import edu.wpi.first.wpilibj.drive.DifferentialDrive; @@ -13,6 +15,7 @@ import edu.wpi.first.wpilibj2.command.Command; import edu.wpi.first.wpilibj2.command.SubsystemBase; import java.util.function.DoubleSupplier; +@Logged public class Drive extends SubsystemBase { // The motors on the left side of the drive. private final PWMSparkMax m_leftLeader = new PWMSparkMax(DriveConstants.kLeftMotor1Port); @@ -23,6 +26,7 @@ public class Drive extends SubsystemBase { private final PWMSparkMax m_rightFollower = new PWMSparkMax(DriveConstants.kRightMotor2Port); // The robot's drive + @NotLogged // Would duplicate motor data, there's no point sending it twice private final DifferentialDrive m_drive = new DifferentialDrive(m_leftLeader::set, m_rightLeader::set); diff --git a/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/subsystems/Intake.java b/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/subsystems/Intake.java index 0f242dfae0..d6b841962a 100644 --- a/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/subsystems/Intake.java +++ b/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/subsystems/Intake.java @@ -6,12 +6,14 @@ package edu.wpi.first.wpilibj.examples.rapidreactcommandbot.subsystems; import static edu.wpi.first.wpilibj.examples.rapidreactcommandbot.Constants.IntakeConstants; +import edu.wpi.first.epilogue.Logged; import edu.wpi.first.wpilibj.DoubleSolenoid; import edu.wpi.first.wpilibj.PneumaticsModuleType; import edu.wpi.first.wpilibj.motorcontrol.PWMSparkMax; import edu.wpi.first.wpilibj2.command.Command; import edu.wpi.first.wpilibj2.command.SubsystemBase; +@Logged public class Intake extends SubsystemBase { private final PWMSparkMax m_motor = new PWMSparkMax(IntakeConstants.kMotorPort); diff --git a/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/subsystems/Pneumatics.java b/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/subsystems/Pneumatics.java index b51562fca8..fd35d5f64a 100644 --- a/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/subsystems/Pneumatics.java +++ b/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/subsystems/Pneumatics.java @@ -4,14 +4,15 @@ package edu.wpi.first.wpilibj.examples.rapidreactcommandbot.subsystems; +import edu.wpi.first.epilogue.Logged; import edu.wpi.first.wpilibj.AnalogPotentiometer; import edu.wpi.first.wpilibj.Compressor; import edu.wpi.first.wpilibj.PneumaticsModuleType; -import edu.wpi.first.wpilibj.shuffleboard.Shuffleboard; import edu.wpi.first.wpilibj2.command.Command; import edu.wpi.first.wpilibj2.command.SubsystemBase; /** Subsystem for managing the compressor, pressure sensor, etc. */ +@Logged public class Pneumatics extends SubsystemBase { // External analog pressure sensor // product-specific voltage->pressure conversion, see product manual @@ -26,17 +27,12 @@ public class Pneumatics extends SubsystemBase { // Compressor connected to a PCM with a default CAN ID (0) private final Compressor m_compressor = new Compressor(PneumaticsModuleType.CTREPCM); - public Pneumatics() { - var tab = Shuffleboard.getTab("Pneumatics"); - tab.addDouble("External Pressure [PSI]", this::getPressure); - } - /** * Query the analog pressure sensor. * * @return the measured pressure, in PSI */ - private double getPressure() { + public double getPressure() { // Get the pressure (in PSI) from an analog pressure sensor connected to the RIO. return m_pressureTransducer.get(); } diff --git a/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/subsystems/Shooter.java b/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/subsystems/Shooter.java index 88d564beb3..a10f6f9143 100644 --- a/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/subsystems/Shooter.java +++ b/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/subsystems/Shooter.java @@ -9,6 +9,7 @@ import static edu.wpi.first.units.Units.Volts; import static edu.wpi.first.wpilibj2.command.Commands.parallel; import static edu.wpi.first.wpilibj2.command.Commands.waitUntil; +import edu.wpi.first.epilogue.Logged; import edu.wpi.first.math.controller.PIDController; import edu.wpi.first.math.controller.SimpleMotorFeedforward; import edu.wpi.first.wpilibj.Encoder; @@ -17,6 +18,7 @@ import edu.wpi.first.wpilibj.motorcontrol.PWMSparkMax; import edu.wpi.first.wpilibj2.command.Command; import edu.wpi.first.wpilibj2.command.SubsystemBase; +@Logged public class Shooter extends SubsystemBase { private final PWMSparkMax m_shooterMotor = new PWMSparkMax(ShooterConstants.kShooterMotorPort); private final PWMSparkMax m_feederMotor = new PWMSparkMax(ShooterConstants.kFeederMotorPort); diff --git a/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/subsystems/Storage.java b/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/subsystems/Storage.java index f3812b1d89..db4611e5e8 100644 --- a/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/subsystems/Storage.java +++ b/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/rapidreactcommandbot/subsystems/Storage.java @@ -4,6 +4,8 @@ package edu.wpi.first.wpilibj.examples.rapidreactcommandbot.subsystems; +import edu.wpi.first.epilogue.Logged; +import edu.wpi.first.epilogue.NotLogged; import edu.wpi.first.wpilibj.DigitalInput; import edu.wpi.first.wpilibj.examples.rapidreactcommandbot.Constants.StorageConstants; import edu.wpi.first.wpilibj.motorcontrol.PWMSparkMax; @@ -11,13 +13,16 @@ import edu.wpi.first.wpilibj2.command.Command; import edu.wpi.first.wpilibj2.command.SubsystemBase; import edu.wpi.first.wpilibj2.command.button.Trigger; +@Logged public class Storage extends SubsystemBase { private final PWMSparkMax m_motor = new PWMSparkMax(StorageConstants.kMotorPort); + @NotLogged // We'll log a more meaningful boolean instead private final DigitalInput m_ballSensor = new DigitalInput(StorageConstants.kBallSensorPort); // Expose trigger from subsystem to improve readability and ease // inter-subsystem communications /** Whether the ball storage is full. */ + @Logged(name = "Has Cargo") @SuppressWarnings("checkstyle:MemberName") public final Trigger hasCargo = new Trigger(m_ballSensor::get);