Skip to content

Commit

Permalink
Add controls and telemetry to camera stream widgets (wpilibsuite#425)
Browse files Browse the repository at this point in the history
* Add API for saving properties in widgets without having to export them
* Add framerate, compression, and resolution controls to camera sources

Bump plugin version to 1.1.0
Bundle empty image in resources dir

* Debounce URL updates, fix base URLs not being correct

* Fix widgets not re-enabling if initially loaded with a disconnected source
* Fix widgets being disabled at the wrong time

* Use /stream.mjpg to avoid 404s
* Reduce fragility of loading broken or incompatible save files

* Load sources last

Widgets can have properties that manipulate their sources, so loading the sources after their properties are set up lets properties be set when loading a saved widget

* Make "active" and "connected" properties atomic

* Limit stream resolution to 1920x1080

Adds range limiting options to number fields so this can be enforced both through the widget and through the source itself
  • Loading branch information
SamCarlberg authored Mar 16, 2018
1 parent 553887d commit cc4b583
Show file tree
Hide file tree
Showing 27 changed files with 1,211 additions and 53 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,26 @@
public abstract class AbstractNumberField<N extends Number> extends TextField {

private final Property<N> number = new SimpleObjectProperty<>(this, "number");
private final Property<N> minValue = new SimpleObjectProperty<>(this, "minValue", null);
private final Property<N> maxValue = new SimpleObjectProperty<>(this, "maxValue", null);

protected AbstractNumberField() {
super();
getStyleClass().add("number-field");
setText("0");
setNumber(getNumberFromText("0"));
setTextFormatter(new TextFormatter<>(change -> {
String text = change.getControlNewText();
if (isCompleteNumber(text)) {
// Bounds check
final N number = getNumberFromText(text);
if (getMaxValue() != null && number.doubleValue() > getMaxValue().doubleValue()) {
return null;
}
if (getMinValue() != null && number.doubleValue() < getMinValue().doubleValue()) {
return null;
}
}
if (isStartOfNumber(text)) {
return change;
}
Expand Down Expand Up @@ -71,4 +84,27 @@ public final void setNumber(N number) {
this.number.setValue(number);
}

public final N getMinValue() {
return minValue.getValue();
}

public final Property<N> minValueProperty() {
return minValue;
}

public final void setMinValue(N minValue) {
this.minValue.setValue(minValue);
}

public final N getMaxValue() {
return maxValue.getValue();
}

public final Property<N> maxValueProperty() {
return maxValue;
}

public final void setMaxValue(N maxValue) {
this.maxValue.setValue(maxValue);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package edu.wpi.first.shuffleboard.api.properties;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Do not use this annotation directly - use {@link SavePropertyFrom} instead. This annotation only exists so that
* {@code SavePropertyFrom} can be repeated.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface SaveProperties {

/**
* Do not use.
*/
SavePropertyFrom[] value();

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package edu.wpi.first.shuffleboard.api.properties;

import edu.wpi.first.shuffleboard.api.widget.Widget;

import java.lang.annotation.ElementType;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Flags a property of a field in a widget class to be saved as part of that widget.
*
* <p>For example, take a widget class has a slider that controls the speed of something. In the old API, the widget
* would have export its properties directly (which doesn't give descriptive names in the widget's context, and can
* run into conflicts), or create new properties with the desired names and bind those to the properties of the slider.
* This takes a lot of code and makes the class less readable; there is a lot of boilerplate. It is also not ideal,
* because properties exported via {@link Widget#getProperties()} are also configurable through the property editor for
* the widget, even if the author does not want them to be user-editable, or if they are already editable through the
* controls in the widget; for example, the value of the slider changes when a user moves the slider - it does not need
* to be editable another way.
*
* <pre>{@code
* // Bad API -- exposes properties to the user that should not be editable!
* class MyWidget implements Widget {
*
* private final Slider speedSlider = new Slider();
*
* private final DoubleProperty speed = new SimpleDoubleProperty(this, "speed");
* private final DoubleProperty minSpeed = new SimpleDoubleProperty(this, "minSpeed");
* private final DoubleProperty maxSpeed = new SimpleDoubleProperty(this, "maxSpeed");
*
* public MyWidget() {
* speedSlider.valueProperty().bindBidirectional(speed);
* speedSlider.minProperty().bindBidirectional(minSpeed);
* speedSlider.maxProperty().bindBidirectional(maxSpeed);
* }
*
* @literal @Override
* public List<Property> getProperties() {
* return ImmutableList.of(
* speed,
* minSpeed,
* maxSpeed
* );
* }
* }
* }</pre>
*
* <p>A better API is to use this annotation to specify the properties to save and the names to save them as. No
* properties have to be exposed to users, no dummy properties have to be created to set the name, and the code is
* <i>much</i> clearer:
*
* <pre>{@code
* class MyWidget implements Widget {
*
* @literal @SavePropertyFrom(propertyName = "value", savedName = "speed")
* @literal @SavePropertyFrom(propertyName = "min", savedName = "minSpeed")
* @literal @SavePropertyFrom(propertyName = "max", savedName = "maxSpeed")
* private final Slider speedSlider = new Slider();
*
* }
* }</pre>
*
* <h2>Using</h2>
* An empty string for either {@code propertyName} or {@code savedName} will throw an exception when the widget is
* attempted to be saved or loaded: {@code @SavePropertyFrom(propertyName="", savedName="")} is not allowed.
* <br>
* The property must have public "getter" and "setter" methods; for example, a property with name "foo" must have
* {@code public Foo getFoo()} and {@code public void setFoo(Foo newFoo)} methods (boolean properties may also use
* the prefix {@code is} instead of {@code get}).
* <br>
* Note that this only uses getter and setter methods, so this annotation is fully compatible with standard Java beans.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@Repeatable(SaveProperties.class)
public @interface SavePropertyFrom {

/**
* The name of the property to save.
*/
String propertyName();

/**
* The name to save the property as. By default, the name of the property is used, but can be overridden by setting
* this value.
*/
String savedName() default "";

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package edu.wpi.first.shuffleboard.api.properties;

import edu.wpi.first.shuffleboard.api.widget.Widget;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Marks a JavaFX property field in a component to be saved. Properties that are made available via
* {@link Widget#getProperties()} will be saved and loaded without needing this annotation; this annotation should only
* be placed on properties that widget authors do not want to be made user-configurable through the properties editor,
* or when the name of the property is not particularly descriptive.
*
* <p>For example, this will save a property named {@code foo} with the name of the property ("foo"):
* <pre>{@code
*@literal @SaveThisProperty
* private final Property<Foo> foo = new SimpleObjectProperty(this, "foo", new Foo());
* }</pre>
* This will save it as "a foo":
* <pre>{@code
*@literal @SaveThisProperty(name = "a foo")
* private final Property<Foo> foo = new SimpleObjectProperty(this, "foo", new Foo());
* }</pre>
*
* <p>If the property has no name (i.e. the name string is {@code null} or {@code ""}), then the annotation <i>must</i>
* set the name. Otherwise, an exception will be thrown when attempting to save or load the widget.
* <pre>{@code
* // No name set!
*@literal @SaveThisProperty
* private final Property<Foo> foo = new SimpleObjectProperty(this, "", new Foo());
* }</pre>
* <pre>{@code
* // Good - the name is set in the annotation
*@literal @SaveThisProperty(name = "a foo")
* private final Property<Foo> foo = new SimpleObjectProperty(this, "", new Foo());
* }</pre>
*
* <p>Placing this annotation on a field that does not subclass {@link javafx.beans.property.Property} will have no
* effect.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface SaveThisProperty {

/**
* The name to save the property as. The only constraint on the name is that the characters should be ASCII codes
* for maximum compatibility.
*/
String name() default "";

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import edu.wpi.first.shuffleboard.api.data.DataType;
import edu.wpi.first.shuffleboard.api.properties.AsyncProperty;
import edu.wpi.first.shuffleboard.api.properties.AtomicBooleanProperty;
import edu.wpi.first.shuffleboard.api.widget.Sourced;

import java.util.Collections;
Expand All @@ -10,7 +11,6 @@

import javafx.beans.property.BooleanProperty;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;

Expand All @@ -26,9 +26,9 @@ public abstract class AbstractDataSource<T> implements DataSource<T> {

private final Set<Sourced> clients = Collections.newSetFromMap(new WeakHashMap<>());
protected final StringProperty name = new SimpleStringProperty(this, "name", "");
protected final BooleanProperty active = new SimpleBooleanProperty(this, "active", false);
protected final BooleanProperty active = new AtomicBooleanProperty(this, "active", false);
protected final Property<T> data = new AsyncProperty<>(this, "data", null);
protected final BooleanProperty connected = new SimpleBooleanProperty(this, "connected", false);
protected final BooleanProperty connected = new AtomicBooleanProperty(this, "connected", false);
protected final DataType<T> dataType;

protected AbstractDataSource(DataType<T> dataType) {
Expand Down Expand Up @@ -74,6 +74,7 @@ public void disconnect() {
setConnected(false);
}

@Override
public BooleanProperty connectedProperty() {
return connected;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ default String getId() {
*/
void disconnect();

BooleanProperty connectedProperty();

/**
* Checks if this source is currently connected to its underlying data stream.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package edu.wpi.first.shuffleboard.api.util;

import java.lang.reflect.Field;

/**
* Utility class for working with reflection.
*/
public final class ReflectionUtils {

private ReflectionUtils() {
throw new UnsupportedOperationException("This is a utility class!");
}

/**
* Gets the value of a field.
*
* @param instance the instance of the class to get the field's value from
* @param field the field to get the value of
* @param <T> the type of object in the field
*
* @return the value of the field
*
* @throws ReflectiveOperationException if the value of the field could not be retrieved
*/
public static <T> T get(Object instance, Field field) throws ReflectiveOperationException {
field.setAccessible(true);
return (T) field.get(instance);
}

/**
* Gets the value of a field. Reflective exceptions are wrapped and re-thrown as a runtime exception.
*
* @param instance the instance of the class to get the field's value from
* @param field the field to get the value of
* @param <T> the type of object in the field
*
* @return the value of the field
*
* @throws RuntimeException if a reflective exception was thrown while attempting to get the value of the field
*/
public static <T> T getUnchecked(Object instance, Field field) {
try {
return get(instance, field);
} catch (ReflectiveOperationException e) {
throw new RuntimeException("Could not read field: " + field.toGenericString(), e);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
import javafx.beans.property.Property;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;

/**
Expand All @@ -21,6 +23,13 @@ public abstract class AbstractWidget implements Widget {
private final StringProperty title = new SimpleStringProperty(this, "title", "");

private final ObservableList<Property<?>> properties = FXCollections.observableArrayList();
private final ChangeListener<Boolean> connectionListener = (__, was, is) -> {
if (is) {
getView().setDisable(!sources.stream().allMatch(DataSource::isConnected));
} else {
getView().setDisable(true);
}
};

/**
* Only set the title when the sources change if it hasn't been set externally (eg by a user or an enclosing layout).
Expand All @@ -41,6 +50,16 @@ protected AbstractWidget() {
}
getView().setDisable(sources.stream().anyMatch(s -> !s.isConnected()));
});
sources.addListener((ListChangeListener<DataSource>) c -> {
getView().setDisable(!sources.stream().allMatch(DataSource::isConnected));
while (c.next()) {
if (c.wasAdded()) {
c.getAddedSubList().forEach(s -> s.connectedProperty().addListener(connectionListener));
} else if (c.wasRemoved()) {
c.getRemoved().forEach(s -> s.connectedProperty().removeListener(connectionListener));
}
}
});
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public abstract class SingleSourceWidget extends AbstractWidget {
public final void addSource(DataSource source) throws IncompatibleSourceException {
if (getDataTypes().contains(source.getDataType())) {
this.source.set(source);
this.sources.remove(getSource());
this.sources.setAll(source);
source.addClient(this);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,9 @@ public void load(File saveFile) throws IOException {
Reader reader = Files.newReader(saveFile, Charset.forName("UTF-8"));

DashboardData dashboardData = JsonBuilder.forSaveFile().fromJson(reader, DashboardData.class);
if (dashboardData == null) {
throw new IOException("Save file could not be read: " + saveFile);
}
setDashboard(dashboardData.getTabPane());
Platform.runLater(() -> {
centerSplitPane.setDividerPositions(dashboardData.getDividerPosition());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ public void start(Stage primaryStage) throws IOException {
if (AppPreferences.getInstance().isAutoLoadLastSaveFile()) {
try {
mainWindowController.load(AppPreferences.getInstance().getSaveFile());
} catch (IOException e) {
} catch (RuntimeException | IOException e) {
logger.log(Level.WARNING, "Could not load the last save file", e);
mainWindowController.newLayout();
}
Expand Down
Loading

0 comments on commit cc4b583

Please sign in to comment.