[epilogue] Support logging of protobuf-serializable types (#8229)

For parity with struct-serializable types.

Change struct serialization to only apply to types with a public static final <type> struct field, instead of relying only on the marker interface (which is not always followed). Doing this allows fallthrough to the protobuf handler for types with dynamic structs but static protobuf serializers.
This commit is contained in:
Sam Carlberg
2025-09-20 14:23:22 -04:00
committed by GitHub
parent 3dbdfa1839
commit ee0a8a1e56
15 changed files with 344 additions and 3 deletions

View File

@@ -112,7 +112,8 @@ public class AnnotationProcessor extends AbstractProcessor {
new MeasureHandler(processingEnv),
new PrimitiveHandler(processingEnv),
new SupplierHandler(processingEnv),
new StructHandler(processingEnv), // prioritize struct over sendable
new StructHandler(processingEnv), // prioritize struct over sendable and protobuf
new ProtobufHandler(processingEnv), // then protobuf
new SendableHandler(processingEnv));
m_epiloguerGenerator = new EpilogueGenerator(processingEnv, customLoggers);

View File

@@ -0,0 +1,102 @@
// 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.Set;
import javax.annotation.processing.ProcessingEnvironment;
import javax.lang.model.element.Element;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.TypeElement;
import javax.lang.model.element.VariableElement;
import javax.lang.model.type.TypeMirror;
import javax.lang.model.util.Elements;
import javax.lang.model.util.Types;
/**
* Supports protobuf serializable types. Protobuf-serializable types are loggable if they have a
* public static final {@code proto} field of a type that inherits from {@code Protobuf}.
*/
public class ProtobufHandler extends ElementHandler {
private final TypeMirror m_serializable;
private final TypeElement m_protobufType;
private final Types m_typeUtils;
private final Elements m_elementUtils;
protected ProtobufHandler(ProcessingEnvironment processingEnv) {
super(processingEnv);
m_serializable =
processingEnv
.getElementUtils()
.getTypeElement("edu.wpi.first.util.protobuf.ProtobufSerializable")
.asType();
m_protobufType =
processingEnv.getElementUtils().getTypeElement("edu.wpi.first.util.protobuf.Protobuf");
m_typeUtils = processingEnv.getTypeUtils();
m_elementUtils = processingEnv.getElementUtils();
}
@Override
public boolean isLoggable(Element element) {
return isLoggableType(dataType(element));
}
/**
* Checks if a type is protobuf-serializable: implements the ProtobufSerializable marker interface
* and has a `public static final proto` field of a type that inherits from Protobuf with a
* compatible generic type bound.
*
* @param type The type to check
* @return true if the type is protobuf-serializable, false otherwise
*/
public boolean isLoggableType(TypeMirror type) {
var serializableType = m_typeUtils.erasure(type);
var typeElement = m_elementUtils.getTypeElement(serializableType.toString());
if (typeElement == null) {
return false;
}
// eg `Protobuf<Rotation2d, ?>` instead of the raw `Protobuf` type. The message type doesn't
// really matter here; we can leave it as a wildcard.
var sharpProtobufType =
m_typeUtils.getDeclaredType(
m_protobufType,
typeElement.asType(), // the serializable type
m_typeUtils.getWildcardType(
m_elementUtils.getTypeElement("us.hebi.quickbuf.ProtoMessage").asType(), null));
boolean hasProto =
typeElement.getEnclosedElements().stream()
.filter(e -> e instanceof VariableElement)
.map(e -> (VariableElement) e)
.anyMatch(
field -> {
var nameMatch = field.getSimpleName().contentEquals("proto");
var modifiersMatch =
field
.getModifiers()
.containsAll(Set.of(Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL));
var typeMatch =
m_typeUtils.isAssignable(
m_typeUtils.erasure(field.asType()), sharpProtobufType);
return nameMatch && modifiersMatch && typeMatch;
});
return m_typeUtils.isAssignable(type, m_serializable) && hasProto;
}
public String protoAccess(TypeMirror serializableType) {
var className = m_typeUtils.erasure(serializableType).toString();
return className + ".proto";
}
@Override
public String logInvocation(Element element, TypeElement loggedClass) {
return "backend.log(\"%s\", %s, %s)"
.formatted(
loggedName(element),
elementAccess(element, loggedClass),
protoAccess(dataType(element)));
}
}

View File

@@ -4,15 +4,26 @@
package edu.wpi.first.epilogue.processor;
import java.util.Set;
import javax.annotation.processing.ProcessingEnvironment;
import javax.lang.model.element.Element;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.TypeElement;
import javax.lang.model.element.VariableElement;
import javax.lang.model.type.ArrayType;
import javax.lang.model.type.TypeMirror;
import javax.lang.model.util.Elements;
import javax.lang.model.util.Types;
/**
* Supports struct serializable types. Struct-serializable types are loggable if they have a public
* static final {@code struct} field of a type that inherits from {@code Struct}.
*/
public class StructHandler extends ElementHandler {
private final TypeMirror m_serializable;
private final TypeElement m_structType;
private final Types m_typeUtils;
private final Elements m_elementUtils;
protected StructHandler(ProcessingEnvironment processingEnv) {
super(processingEnv);
@@ -21,16 +32,57 @@ public class StructHandler extends ElementHandler {
.getElementUtils()
.getTypeElement("edu.wpi.first.util.struct.StructSerializable")
.asType();
m_structType =
processingEnv.getElementUtils().getTypeElement("edu.wpi.first.util.struct.Struct");
m_typeUtils = processingEnv.getTypeUtils();
m_elementUtils = processingEnv.getElementUtils();
}
@Override
public boolean isLoggable(Element element) {
return m_typeUtils.isAssignable(dataType(element), m_serializable);
return isLoggableType(dataType(element));
}
/**
* Checks if a type is struct-serializable: implements the StructSerializable marker interface and
* has a `public static final struct` field of a type that inherits from Struct with a compatible
* generic type bound.
*
* @param type The type to check
* @return true if the type is struct-serializable, false otherwise
*/
public boolean isLoggableType(TypeMirror type) {
return m_typeUtils.isAssignable(type, m_serializable);
TypeMirror serializableType;
if (type instanceof ArrayType arr) {
serializableType = arr.getComponentType();
} else {
serializableType = m_typeUtils.erasure(type);
}
var typeElement = m_elementUtils.getTypeElement(serializableType.toString());
if (typeElement == null) {
return false;
}
// eg `Struct<Rotation2d>` instead of the raw `Struct` type
var sharpStructType = m_typeUtils.getDeclaredType(m_structType, typeElement.asType());
boolean hasStruct =
typeElement.getEnclosedElements().stream()
.filter(e -> e instanceof VariableElement)
.map(e -> (VariableElement) e)
.anyMatch(
field -> {
var nameMatch = field.getSimpleName().contentEquals("struct");
var modifiersMatch =
field
.getModifiers()
.containsAll(Set.of(Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL));
var typeMatch =
m_typeUtils.isAssignable(
m_typeUtils.erasure(field.asType()), sharpStructType);
return nameMatch && modifiersMatch && typeMatch;
});
return m_typeUtils.isAssignable(type, m_serializable) && hasStruct;
}
public String structAccess(TypeMirror serializableType) {

View File

@@ -1141,6 +1141,76 @@ class AnnotationProcessorTest {
assertLoggerGenerates(source, expectedGeneratedSource);
}
@Test
void protobuf() {
String source =
"""
package edu.wpi.first.epilogue;
import edu.wpi.first.util.protobuf.Protobuf;
import edu.wpi.first.util.protobuf.ProtobufSerializable;
import java.util.List;
import us.hebi.quickbuf.*;
class ProtobufType implements ProtobufSerializable {
// Message type is necessary - Epilogue can't log with a wildcard message type in the proto
public static final Protobuf<ProtobufType, Message> proto = null; // value doesn't matter
static class Message extends ProtoMessage<Message> {
// Implement stubs for the abstract base class.
// This code never runs so actual implementations are unnecessary.
@Override
public Message copyFrom(Message other) { return null; }
@Override
public Message clear() { return null; }
@Override
public int computeSerializedSize() { return 0; }
@Override
public void writeTo(ProtoSink output) {}
@Override
public Message mergeFrom(ProtoSource input) { return null; }
@Override
public boolean equals(Object obj) { return false; }
@Override
public Message clone() { return null; }
}
}
@Logged
class Example {
ProtobufType x; // Should be logged
ProtobufType[] arr1; // Should not be logged
ProtobufType[][] arr2; // Should not be logged
List<ProtobufType> list; // 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.EpilogueBackend;
public class ExampleLogger extends ClassSpecificLogger<Example> {
public ExampleLogger() {
super(Example.class);
}
@Override
public void update(EpilogueBackend backend, Example object) {
if (Epilogue.shouldLog(Logged.Importance.DEBUG)) {
backend.log("x", object.x, edu.wpi.first.epilogue.ProtobufType.proto);
}
}
}
""";
assertLoggerGenerates(source, expectedGeneratedSource);
}
@Test
void lists() {
String source =

View File

@@ -8,5 +8,6 @@ java_library(
"//ntcore:networktables-java",
"//wpiunits",
"//wpiutil:wpiutil-java",
"@maven//:us_hebi_quickbuf_quickbuf_runtime",
],
)

View File

@@ -13,4 +13,5 @@ dependencies {
api(project(':ntcore'))
api(project(':wpiutil'))
api(project(':wpiunits'))
testImplementation(project(':wpimath')) // for convenient protobuf types
}

View File

@@ -6,8 +6,10 @@ package edu.wpi.first.epilogue.logging;
import edu.wpi.first.units.Measure;
import edu.wpi.first.units.Unit;
import edu.wpi.first.util.protobuf.Protobuf;
import edu.wpi.first.util.struct.Struct;
import java.util.Collection;
import us.hebi.quickbuf.ProtoMessage;
/** A backend is a generic interface for Epilogue to log discrete data points. */
public interface EpilogueBackend {
@@ -193,6 +195,17 @@ public interface EpilogueBackend {
log(identifier, array, struct);
}
/**
* Logs a protobuf-serializable object.
*
* @param identifier the identifier of the data point
* @param value the value of the data point
* @param proto the protobuf to use to serialize the data
* @param <P> the protobuf-serializable type
* @param <M> the protobuf message type
*/
<P, M extends ProtoMessage<M>> void log(String identifier, P value, Protobuf<P, M> proto);
/**
* Logs a measurement's value in terms of its base unit.
*

View File

@@ -16,17 +16,20 @@ 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.ProtobufLogEntry;
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.protobuf.Protobuf;
import edu.wpi.first.util.struct.Struct;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.function.BiFunction;
import us.hebi.quickbuf.ProtoMessage;
/** A backend implementation that saves information to a WPILib {@link DataLog} file on disk. */
public class FileBackend implements EpilogueBackend {
@@ -34,6 +37,7 @@ public class FileBackend implements EpilogueBackend {
private final Map<String, DataLogEntry> m_entries = new HashMap<>();
private final Map<String, NestedBackend> m_subLoggers = new HashMap<>();
private final Set<Struct<?>> m_seenSchemas = new HashSet<>();
private final Set<Protobuf<?, ?>> m_seenProtos = new HashSet<>();
/**
* Creates a new file-based backend.
@@ -166,4 +170,19 @@ public class FileBackend implements EpilogueBackend {
((StructArrayLogEntry<S>) m_entries.get(identifier)).append(value);
}
@Override
@SuppressWarnings("unchecked")
public <P, M extends ProtoMessage<M>> void log(String identifier, P value, Protobuf<P, M> proto) {
// DataLog.addSchema has checks that we're able to skip, avoiding allocations
if (m_seenProtos.add(proto)) {
m_dataLog.addSchema(proto);
}
if (!m_entries.containsKey(identifier)) {
m_entries.put(identifier, ProtobufLogEntry.create(m_dataLog, identifier, proto));
}
((ProtobufLogEntry<P>) m_entries.get(identifier)).append(value);
}
}

View File

@@ -4,11 +4,13 @@
package edu.wpi.first.epilogue.logging;
import edu.wpi.first.util.protobuf.Protobuf;
import edu.wpi.first.util.struct.Struct;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import us.hebi.quickbuf.ProtoMessage;
/**
* A backend implementation that only logs data when it changes. Useful for keeping bandwidth and
@@ -243,4 +245,17 @@ public class LazyBackend implements EpilogueBackend {
m_previousValues.put(identifier, value.clone());
m_backend.log(identifier, value, struct);
}
@Override
public <P, M extends ProtoMessage<M>> void log(String identifier, P value, Protobuf<P, M> proto) {
var previous = m_previousValues.get(identifier);
if (Objects.equals(previous, value)) {
// no change
return;
}
m_previousValues.put(identifier, value);
m_backend.log(identifier, value, proto);
}
}

View File

@@ -4,10 +4,12 @@
package edu.wpi.first.epilogue.logging;
import edu.wpi.first.util.protobuf.Protobuf;
import edu.wpi.first.util.struct.Struct;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import us.hebi.quickbuf.ProtoMessage;
/**
* A backend implementation that delegates to other backends. Helpful for simultaneous logging to
@@ -137,4 +139,11 @@ public class MultiBackend implements EpilogueBackend {
backend.log(identifier, value, struct);
}
}
@Override
public <P, M extends ProtoMessage<M>> void log(String identifier, P value, Protobuf<P, M> proto) {
for (EpilogueBackend backend : m_backends) {
backend.log(identifier, value, proto);
}
}
}

View File

@@ -13,18 +13,21 @@ 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.ProtobufPublisher;
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.protobuf.Protobuf;
import edu.wpi.first.util.struct.Struct;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import us.hebi.quickbuf.ProtoMessage;
/**
* A backend implementation that sends data over network tables. Be careful when using this, since
@@ -36,6 +39,7 @@ public class NTEpilogueBackend implements EpilogueBackend {
private final Map<String, Publisher> m_publishers = new HashMap<>();
private final Map<String, NestedBackend> m_nestedBackends = new HashMap<>();
private final Set<Struct<?>> m_seenSchemas = new HashSet<>();
private final Set<Protobuf<?, ?>> m_seenProtos = new HashSet<>();
private final Function<String, IntegerPublisher> m_createIntPublisher;
private final Function<String, FloatPublisher> m_createFloatPublisher;
private final Function<String, DoublePublisher> m_createDoublePublisher;
@@ -198,4 +202,21 @@ public class NTEpilogueBackend implements EpilogueBackend {
publisher.set(value);
}
}
@Override
@SuppressWarnings("unchecked")
public <P, M extends ProtoMessage<M>> void log(String identifier, P value, Protobuf<P, M> proto) {
// NetworkTableInstance.addSchema has checks that we're able to skip, avoiding allocations
if (m_seenProtos.add(proto)) {
m_nt.addSchema(proto);
}
if (m_publishers.containsKey(identifier)) {
((ProtobufPublisher<P>) m_publishers.get(identifier)).set(value);
} else {
ProtobufPublisher<P> publisher = m_nt.getProtobufTopic(identifier, proto).publish();
m_publishers.put(identifier, publisher);
publisher.set(value);
}
}
}

View File

@@ -4,9 +4,11 @@
package edu.wpi.first.epilogue.logging;
import edu.wpi.first.util.protobuf.Protobuf;
import edu.wpi.first.util.struct.Struct;
import java.util.HashMap;
import java.util.Map;
import us.hebi.quickbuf.ProtoMessage;
/**
* A backend that logs to an underlying backend, prepending all logged data with a specific prefix.
@@ -147,4 +149,9 @@ public class NestedBackend implements EpilogueBackend {
public <S> void log(String identifier, S[] value, Struct<S> struct) {
m_impl.log(withPrefix(identifier), value, struct);
}
@Override
public <P, M extends ProtoMessage<M>> void log(String identifier, P value, Protobuf<P, M> proto) {
m_impl.log(m_prefix + identifier, value, proto);
}
}

View File

@@ -4,7 +4,9 @@
package edu.wpi.first.epilogue.logging;
import edu.wpi.first.util.protobuf.Protobuf;
import edu.wpi.first.util.struct.Struct;
import us.hebi.quickbuf.ProtoMessage;
/** Null backend implementation that logs nothing. */
public class NullBackend implements EpilogueBackend {
@@ -62,4 +64,8 @@ public class NullBackend implements EpilogueBackend {
@Override
public <S> void log(String identifier, S[] value, Struct<S> struct) {}
@Override
public <P, M extends ProtoMessage<M>> void log(
String identifier, P value, Protobuf<P, M> proto) {}
}

View File

@@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import edu.wpi.first.math.geometry.Rotation2d;
import java.util.List;
import org.junit.jupiter.api.Test;
@@ -185,4 +186,17 @@ class LazyBackendTest {
assertArrayEquals(
new byte[] {0x01, 0x00, 0x00, 0x00}, (byte[]) backend.getEntries().get(1).value());
}
@Test
void lazyProtobuf() {
var backend = new TestBackend();
var lazy = new LazyBackend(backend);
var rotation = Rotation2d.kZero;
lazy.log("rotation", rotation, Rotation2d.proto);
assertEquals(1, backend.getEntries().size());
var entry = backend.getEntries().get(0);
assertEquals("rotation", entry.identifier());
assertArrayEquals(new byte[] {9, 0, 0, 0, 0, 0, 0, 0, 0}, (byte[]) entry.value());
}
}

View File

@@ -4,12 +4,14 @@
package edu.wpi.first.epilogue.logging;
import edu.wpi.first.util.protobuf.Protobuf;
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;
import us.hebi.quickbuf.ProtoMessage;
@SuppressWarnings("PMD.TestClassWithoutTestCases") // This is not a test class!
public class TestBackend implements EpilogueBackend {
@@ -114,4 +116,12 @@ public class TestBackend implements EpilogueBackend {
m_entries.add(new LogEntry<>(identifier, serialized));
}
@Override
public <P, M extends ProtoMessage<M>> void log(String identifier, P value, Protobuf<P, M> proto) {
var msg = proto.createMessage();
proto.pack(msg, value);
var serialized = msg.toByteArray();
m_entries.add(new LogEntry<>(identifier, serialized));
}
}