properties) {
+ m_properties = properties;
+ m_metadataDirty = true;
+ return (C) this;
+ }
+
+ /**
+ * Sets the position of this component in the tab. This has no effect if this component is inside
+ * a layout.
+ *
+ * If the position of a single component is set, it is recommended to set the positions of
+ * all components inside a tab to prevent Shuffleboard from automatically placing another
+ * component there before the one with the specific position is sent.
+ *
+ * @param columnIndex the column in the tab to place this component
+ * @param rowIndex the row in the tab to place this component
+ * @return this component
+ */
+ public final C withPosition(int columnIndex, int rowIndex) {
+ m_column = columnIndex;
+ m_row = rowIndex;
+ m_metadataDirty = true;
+ return (C) this;
+ }
+
+ /**
+ * Sets the size of this component in the tab. This has no effect if this component is inside a
+ * layout.
+ *
+ * @param width how many columns wide the component should be
+ * @param height how many rows high the component should be
+ * @return this component
+ */
+ public final C withSize(int width, int height) {
+ m_width = width;
+ m_height = height;
+ m_metadataDirty = true;
+ return (C) this;
+ }
+
+ protected final void buildMetadata(NetworkTable metaTable) {
+ if (!m_metadataDirty) {
+ return;
+ }
+ // Component type
+ if (getType() == null) {
+ metaTable.getEntry("PreferredComponent").delete();
+ } else {
+ metaTable.getEntry("PreferredComponent").forceSetString(getType());
+ }
+
+ // Tile size
+ if (m_width <= 0 || m_height <= 0) {
+ metaTable.getEntry("Size").delete();
+ } else {
+ metaTable.getEntry("Size").setDoubleArray(new double[]{m_width, m_height});
+ }
+
+ // Tile position
+ if (m_column < 0 || m_row < 0) {
+ metaTable.getEntry("Position").delete();
+ } else {
+ metaTable.getEntry("Position").setDoubleArray(new double[]{m_column, m_row});
+ }
+
+ // Custom properties
+ if (getProperties() != null) {
+ NetworkTable propTable = metaTable.getSubTable("Properties");
+ getProperties().forEach((name, value) -> propTable.getEntry(name).setValue(value));
+ }
+ m_metadataDirty = false;
+ }
+
+}
diff --git a/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardContainer.java b/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardContainer.java
new file mode 100644
index 0000000000..aa50f9f758
--- /dev/null
+++ b/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardContainer.java
@@ -0,0 +1,86 @@
+/*----------------------------------------------------------------------------*/
+/* Copyright (c) 2018 FIRST. All Rights Reserved. */
+/* Open Source Software - may be modified and shared by FRC teams. The code */
+/* must be accompanied by the FIRST BSD license file in the root directory of */
+/* the project. */
+/*----------------------------------------------------------------------------*/
+
+package edu.wpi.first.wpilibj.shuffleboard;
+
+import java.util.List;
+
+import edu.wpi.first.wpilibj.Sendable;
+
+/**
+ * Common interface for objects that can contain shuffleboard components.
+ */
+public interface ShuffleboardContainer extends ShuffleboardValue {
+
+ /**
+ * Gets the components that are direct children of this container.
+ */
+ List> getComponents();
+
+ /**
+ * Gets the layout with the given type and title, creating it if it does not already exist at the
+ * time this method is called.
+ *
+ * @param type the type of the layout, eg "List" or "Grid"
+ * @param title the title of the layout
+ * @return the layout
+ */
+ ShuffleboardLayout getLayout(String type, String title);
+
+ /**
+ * Adds a widget to this container to display the given sendable.
+ *
+ * @param title the title of the widget
+ * @param sendable the sendable to display
+ * @return a widget to display the sendable data
+ * @throws IllegalArgumentException if a widget already exists in this container with the given
+ * title
+ */
+ ComplexWidget add(String title, Sendable sendable) throws IllegalArgumentException;
+
+ /**
+ * Adds a widget to this container to display the given sendable.
+ *
+ * @param sendable the sendable to display
+ * @return a widget to display the sendable data
+ * @throws IllegalArgumentException if a widget already exists in this container with the given
+ * title, or if the sendable's name has not been specified
+ */
+ ComplexWidget add(Sendable sendable);
+
+ /**
+ * Adds a widget to this container to display the given data.
+ *
+ * @param title the title of the widget
+ * @param defaultValue the default value of the widget
+ * @return a widget to display the sendable data
+ * @throws IllegalArgumentException if a widget already exists in this container with the given
+ * title
+ * @see #addPersistent(String, Object) add(String title, Object defaultValue)
+ */
+ SimpleWidget add(String title, Object defaultValue) throws IllegalArgumentException;
+
+ /**
+ * Adds a widget to this container to display a simple piece of data. Unlike
+ * {@link #add(String, Object)}, the value in the widget will be saved on the robot and will be
+ * used when the robot program next starts rather than {@code defaultValue}.
+ *
+ * @param title the title of the widget
+ * @param defaultValue the default value of the widget
+ * @return a widget to display the sendable data
+ * @throws IllegalArgumentException if a widget already exists in this container with the given
+ * title
+ * @see #add(String, Object) add(String title, Object defaultValue)
+ */
+ default SimpleWidget addPersistent(String title, Object defaultValue)
+ throws IllegalArgumentException {
+ SimpleWidget widget = add(title, defaultValue);
+ widget.getEntry().setPersistent();
+ return widget;
+ }
+
+}
diff --git a/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardInstance.java b/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardInstance.java
new file mode 100644
index 0000000000..81d1c68dcf
--- /dev/null
+++ b/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardInstance.java
@@ -0,0 +1,93 @@
+/*----------------------------------------------------------------------------*/
+/* Copyright (c) 2018 FIRST. All Rights Reserved. */
+/* Open Source Software - may be modified and shared by FRC teams. The code */
+/* must be accompanied by the FIRST BSD license file in the root directory of */
+/* the project. */
+/*----------------------------------------------------------------------------*/
+
+package edu.wpi.first.wpilibj.shuffleboard;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Consumer;
+
+import edu.wpi.first.networktables.NetworkTable;
+import edu.wpi.first.networktables.NetworkTableInstance;
+
+final class ShuffleboardInstance implements ShuffleboardRoot {
+ private final Map m_tabs = new LinkedHashMap<>();
+
+ private boolean m_tabsChanged = false; // NOPMD redundant field initializer
+ private final NetworkTable m_rootTable;
+ private final NetworkTable m_rootMetaTable;
+
+ ShuffleboardInstance(NetworkTableInstance ntInstance) {
+ Objects.requireNonNull(ntInstance, "NetworkTable instance cannot be null");
+ m_rootTable = ntInstance.getTable(Shuffleboard.kBaseTableName);
+ m_rootMetaTable = m_rootTable.getSubTable(".metadata");
+ }
+
+ @Override
+ public ShuffleboardTab getTab(String title) {
+ Objects.requireNonNull(title, "Tab title cannot be null");
+ if (!m_tabs.containsKey(title)) {
+ m_tabs.put(title, new ShuffleboardTab(this, title));
+ m_tabsChanged = true;
+ }
+ return m_tabs.get(title);
+ }
+
+ @Override
+ public void update() {
+ if (m_tabsChanged) {
+ String[] tabTitles = m_tabs.values()
+ .stream()
+ .map(ShuffleboardTab::getTitle)
+ .toArray(String[]::new);
+ m_rootMetaTable.getEntry("Tabs").forceSetStringArray(tabTitles);
+ m_tabsChanged = false;
+ }
+ for (ShuffleboardTab tab : m_tabs.values()) {
+ String title = tab.getTitle();
+ tab.buildInto(m_rootTable, m_rootMetaTable.getSubTable(title));
+ }
+ }
+
+ @Override
+ public void enableActuatorWidgets() {
+ applyToAllComplexWidgets(ComplexWidget::enableIfActuator);
+ }
+
+ @Override
+ public void disableActuatorWidgets() {
+ applyToAllComplexWidgets(ComplexWidget::disableIfActuator);
+ }
+
+ /**
+ * Applies the function {@code func} to all complex widgets in this root, regardless of how they
+ * are nested.
+ *
+ * @param func the function to apply to all complex widgets
+ */
+ private void applyToAllComplexWidgets(Consumer func) {
+ for (ShuffleboardTab tab : m_tabs.values()) {
+ apply(tab, func);
+ }
+ }
+
+ /**
+ * Applies the function {@code func} to all complex widgets in {@code container}. Helper method
+ * for {@link #applyToAllComplexWidgets}.
+ */
+ private void apply(ShuffleboardContainer container, Consumer func) {
+ for (ShuffleboardComponent> component : container.getComponents()) {
+ if (component instanceof ComplexWidget) {
+ func.accept((ComplexWidget) component);
+ }
+ if (component instanceof ShuffleboardContainer) {
+ apply((ShuffleboardContainer) component, func);
+ }
+ }
+ }
+}
diff --git a/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardLayout.java b/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardLayout.java
new file mode 100644
index 0000000000..7c41035ed6
--- /dev/null
+++ b/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardLayout.java
@@ -0,0 +1,62 @@
+/*----------------------------------------------------------------------------*/
+/* Copyright (c) 2018 FIRST. All Rights Reserved. */
+/* Open Source Software - may be modified and shared by FRC teams. The code */
+/* must be accompanied by the FIRST BSD license file in the root directory of */
+/* the project. */
+/*----------------------------------------------------------------------------*/
+
+package edu.wpi.first.wpilibj.shuffleboard;
+
+import java.util.List;
+import java.util.Objects;
+
+import edu.wpi.first.networktables.NetworkTable;
+import edu.wpi.first.wpilibj.Sendable;
+
+/**
+ * A layout in a Shuffleboard tab. Layouts can contain widgets and other layouts.
+ */
+public class ShuffleboardLayout extends ShuffleboardComponent
+ implements ShuffleboardContainer {
+ private final ContainerHelper m_helper = new ContainerHelper(this);
+
+ ShuffleboardLayout(ShuffleboardContainer parent, String name, String type) {
+ super(parent, Objects.requireNonNull(type, "Layout type must be specified"), name);
+ }
+
+ @Override
+ public List> getComponents() {
+ return m_helper.getComponents();
+ }
+
+ @Override
+ public ShuffleboardLayout getLayout(String type, String title) {
+ return m_helper.getLayout(type, title);
+ }
+
+ @Override
+ public ComplexWidget add(String title, Sendable sendable) throws IllegalArgumentException {
+ return m_helper.add(title, sendable);
+ }
+
+ @Override
+ public ComplexWidget add(Sendable sendable) throws IllegalArgumentException {
+ return m_helper.add(sendable);
+ }
+
+ @Override
+ public SimpleWidget add(String title, Object defaultValue) throws IllegalArgumentException {
+ return m_helper.add(title, defaultValue);
+ }
+
+ @Override
+ public void buildInto(NetworkTable parentTable, NetworkTable metaTable) {
+ buildMetadata(metaTable);
+ NetworkTable table = parentTable.getSubTable(getTitle());
+ table.getEntry(".type").setString("ShuffleboardLayout");
+ for (ShuffleboardComponent> component : getComponents()) {
+ component.buildInto(table, metaTable.getSubTable(component.getTitle()));
+ }
+ }
+
+}
diff --git a/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardRoot.java b/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardRoot.java
new file mode 100644
index 0000000000..18da1e8444
--- /dev/null
+++ b/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardRoot.java
@@ -0,0 +1,41 @@
+/*----------------------------------------------------------------------------*/
+/* Copyright (c) 2018 FIRST. All Rights Reserved. */
+/* Open Source Software - may be modified and shared by FRC teams. The code */
+/* must be accompanied by the FIRST BSD license file in the root directory of */
+/* the project. */
+/*----------------------------------------------------------------------------*/
+
+package edu.wpi.first.wpilibj.shuffleboard;
+
+/**
+ * The root of the data placed in Shuffleboard. It contains the tabs, but no data is placed
+ * directly in the root.
+ *
+ * This class is package-private to minimize API surface area.
+ */
+interface ShuffleboardRoot {
+
+ /**
+ * Gets the tab with the given title, creating it if it does not already exist.
+ *
+ * @param title the title of the tab
+ * @return the tab with the given title
+ */
+ ShuffleboardTab getTab(String title);
+
+ /**
+ * Updates all tabs.
+ */
+ void update();
+
+ /**
+ * Enables all widgets in Shuffleboard that offer user control over actuators.
+ */
+ void enableActuatorWidgets();
+
+ /**
+ * Disables all widgets in Shuffleboard that offer user control over actuators.
+ */
+ void disableActuatorWidgets();
+
+}
diff --git a/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardTab.java b/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardTab.java
new file mode 100644
index 0000000000..83b897a3e9
--- /dev/null
+++ b/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardTab.java
@@ -0,0 +1,74 @@
+/*----------------------------------------------------------------------------*/
+/* Copyright (c) 2018 FIRST. All Rights Reserved. */
+/* Open Source Software - may be modified and shared by FRC teams. The code */
+/* must be accompanied by the FIRST BSD license file in the root directory of */
+/* the project. */
+/*----------------------------------------------------------------------------*/
+
+package edu.wpi.first.wpilibj.shuffleboard;
+
+import java.util.List;
+
+import edu.wpi.first.networktables.NetworkTable;
+import edu.wpi.first.wpilibj.Sendable;
+
+/**
+ * Represents a tab in the Shuffleboard dashboard. Widgets can be added to the tab with
+ * {@link #add(Sendable)}, {@link #add(String, Object)}, and {@link #add(String, Sendable)}. Widgets
+ * can also be added to layouts with {@link #getLayout(String, String)}; layouts can be nested
+ * arbitrarily deep (note that too many levels may make deeper components unusable).
+ */
+public final class ShuffleboardTab implements ShuffleboardContainer {
+ private final ContainerHelper m_helper = new ContainerHelper(this);
+ private final ShuffleboardRoot m_root;
+ private final String m_title;
+
+ ShuffleboardTab(ShuffleboardRoot root, String title) {
+ m_root = root;
+ m_title = title;
+ }
+
+ @Override
+ public String getTitle() {
+ return m_title;
+ }
+
+ ShuffleboardRoot getRoot() {
+ return m_root;
+ }
+
+ @Override
+ public List> getComponents() {
+ return m_helper.getComponents();
+ }
+
+ @Override
+ public ShuffleboardLayout getLayout(String type, String title) {
+ return m_helper.getLayout(type, title);
+ }
+
+ @Override
+ public ComplexWidget add(String title, Sendable sendable) {
+ return m_helper.add(title, sendable);
+ }
+
+ @Override
+ public ComplexWidget add(Sendable sendable) throws IllegalArgumentException {
+ return m_helper.add(sendable);
+ }
+
+ @Override
+ public SimpleWidget add(String title, Object defaultValue) {
+ return m_helper.add(title, defaultValue);
+ }
+
+ @Override
+ public void buildInto(NetworkTable parentTable, NetworkTable metaTable) {
+ NetworkTable tabTable = parentTable.getSubTable(m_title);
+ tabTable.getEntry(".type").setString("ShuffleboardTab");
+ for (ShuffleboardComponent> component : m_helper.getComponents()) {
+ component.buildInto(tabTable, metaTable.getSubTable(component.getTitle()));
+ }
+ }
+
+}
diff --git a/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardValue.java b/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardValue.java
new file mode 100644
index 0000000000..8bfa6c37d1
--- /dev/null
+++ b/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardValue.java
@@ -0,0 +1,31 @@
+/*----------------------------------------------------------------------------*/
+/* Copyright (c) 2018 FIRST. All Rights Reserved. */
+/* Open Source Software - may be modified and shared by FRC teams. The code */
+/* must be accompanied by the FIRST BSD license file in the root directory of */
+/* the project. */
+/*----------------------------------------------------------------------------*/
+
+package edu.wpi.first.wpilibj.shuffleboard;
+
+import edu.wpi.first.networktables.NetworkTable;
+
+interface ShuffleboardValue {
+
+ /**
+ * Gets the title of this Shuffleboard value.
+ */
+ String getTitle();
+
+ /**
+ * Builds the entries for this value.
+ *
+ * @param parentTable the table containing all the data for the parent. Values that require a
+ * complex entry or table structure should call {@code
+ * parentTable.getSubTable(getTitle())} to get the table to put data into.
+ * Values that only use a single entry should call {@code
+ * parentTable.getEntry(getTitle())} to get that entry.
+ * @param metaTable the table containing all the metadata for this value and its sub-values
+ */
+ void buildInto(NetworkTable parentTable, NetworkTable metaTable);
+
+}
diff --git a/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardWidget.java b/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardWidget.java
new file mode 100644
index 0000000000..91e532d817
--- /dev/null
+++ b/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardWidget.java
@@ -0,0 +1,36 @@
+/*----------------------------------------------------------------------------*/
+/* Copyright (c) 2018 FIRST. All Rights Reserved. */
+/* Open Source Software - may be modified and shared by FRC teams. The code */
+/* must be accompanied by the FIRST BSD license file in the root directory of */
+/* the project. */
+/*----------------------------------------------------------------------------*/
+
+package edu.wpi.first.wpilibj.shuffleboard;
+
+/**
+ * Abstract superclass for widgets.
+ *
+ * This class is package-private to minimize API surface area.
+ *
+ * @param the self type
+ */
+abstract class ShuffleboardWidget>
+ extends ShuffleboardComponent {
+
+ ShuffleboardWidget(ShuffleboardContainer parent, String title) {
+ super(parent, title);
+ }
+
+ /**
+ * Sets the type of widget used to display the data. If not set, the default widget type will be
+ * used.
+ *
+ * @param widgetType the type of the widget used to display the data
+ * @return this widget object
+ */
+ public final W withWidget(String widgetType) {
+ setType(widgetType);
+ return (W) this;
+ }
+
+}
diff --git a/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/SimpleWidget.java b/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/SimpleWidget.java
new file mode 100644
index 0000000000..c645baa4dd
--- /dev/null
+++ b/wpilibj/src/main/java/edu/wpi/first/wpilibj/shuffleboard/SimpleWidget.java
@@ -0,0 +1,50 @@
+/*----------------------------------------------------------------------------*/
+/* Copyright (c) 2018 FIRST. All Rights Reserved. */
+/* Open Source Software - may be modified and shared by FRC teams. The code */
+/* must be accompanied by the FIRST BSD license file in the root directory of */
+/* the project. */
+/*----------------------------------------------------------------------------*/
+
+package edu.wpi.first.wpilibj.shuffleboard;
+
+import edu.wpi.first.networktables.NetworkTable;
+import edu.wpi.first.networktables.NetworkTableEntry;
+
+/**
+ * A Shuffleboard widget that handles a single data point such as a number or string.
+ */
+public final class SimpleWidget extends ShuffleboardWidget {
+ private NetworkTableEntry m_entry;
+
+ SimpleWidget(ShuffleboardContainer parent, String title) {
+ super(parent, title);
+ }
+
+ /**
+ * Gets the NetworkTable entry that contains the data for this widget.
+ */
+ public NetworkTableEntry getEntry() {
+ if (m_entry == null) {
+ forceGenerate();
+ }
+ return m_entry;
+ }
+
+ @Override
+ public void buildInto(NetworkTable parentTable, NetworkTable metaTable) {
+ buildMetadata(metaTable);
+ if (m_entry == null) {
+ m_entry = parentTable.getEntry(getTitle());
+ }
+ }
+
+ private void forceGenerate() {
+ ShuffleboardContainer parent = getParent();
+ while (parent instanceof ShuffleboardLayout) {
+ parent = ((ShuffleboardLayout) parent).getParent();
+ }
+ ShuffleboardTab tab = (ShuffleboardTab) parent;
+ tab.getRoot().update();
+ }
+
+}
diff --git a/wpilibj/src/test/java/edu/wpi/first/wpilibj/shuffleboard/MockActuatorSendable.java b/wpilibj/src/test/java/edu/wpi/first/wpilibj/shuffleboard/MockActuatorSendable.java
new file mode 100644
index 0000000000..6cbeae2cf9
--- /dev/null
+++ b/wpilibj/src/test/java/edu/wpi/first/wpilibj/shuffleboard/MockActuatorSendable.java
@@ -0,0 +1,26 @@
+/*----------------------------------------------------------------------------*/
+/* Copyright (c) 2018 FIRST. All Rights Reserved. */
+/* Open Source Software - may be modified and shared by FRC teams. The code */
+/* must be accompanied by the FIRST BSD license file in the root directory of */
+/* the project. */
+/*----------------------------------------------------------------------------*/
+
+package edu.wpi.first.wpilibj.shuffleboard;
+
+import edu.wpi.first.wpilibj.SendableBase;
+import edu.wpi.first.wpilibj.smartdashboard.SendableBuilder;
+
+/**
+ * A mock sendable that marks itself as an actuator.
+ */
+public class MockActuatorSendable extends SendableBase {
+ public MockActuatorSendable(String name) {
+ super(false);
+ setName(name);
+ }
+
+ @Override
+ public void initSendable(SendableBuilder builder) {
+ builder.setActuator(true);
+ }
+}
diff --git a/wpilibj/src/test/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardInstanceTest.java b/wpilibj/src/test/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardInstanceTest.java
new file mode 100644
index 0000000000..dea8ed772a
--- /dev/null
+++ b/wpilibj/src/test/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardInstanceTest.java
@@ -0,0 +1,115 @@
+/*----------------------------------------------------------------------------*/
+/* Copyright (c) 2018 FIRST. All Rights Reserved. */
+/* Open Source Software - may be modified and shared by FRC teams. The code */
+/* must be accompanied by the FIRST BSD license file in the root directory of */
+/* the project. */
+/*----------------------------------------------------------------------------*/
+
+package edu.wpi.first.wpilibj.shuffleboard;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import edu.wpi.first.networktables.NetworkTableEntry;
+import edu.wpi.first.networktables.NetworkTableInstance;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class ShuffleboardInstanceTest {
+ private NetworkTableInstance m_ntInstance;
+ private ShuffleboardInstance m_shuffleboardInstance;
+
+ @BeforeEach
+ void setupInstance() {
+ m_ntInstance = NetworkTableInstance.create();
+ m_shuffleboardInstance = new ShuffleboardInstance(m_ntInstance);
+ }
+
+ @AfterEach
+ void tearDownInstance() {
+ m_ntInstance.close();
+ }
+
+ @Test
+ void testPathFluent() {
+ NetworkTableEntry entry = m_shuffleboardInstance.getTab("Tab Title")
+ .getLayout("List", "List Layout")
+ .add("Data", "string")
+ .withWidget("Text View")
+ .getEntry();
+
+ assertAll(
+ () -> assertEquals("string", entry.getString(null), "Wrong entry value"),
+ () -> assertEquals("/Shuffleboard/Tab Title/List Layout/Data", entry.getName(),
+ "Entry path generated incorrectly"));
+ }
+
+ @Test
+ void testNestedLayoutsFluent() {
+ NetworkTableEntry entry = m_shuffleboardInstance.getTab("Tab")
+ .getLayout("List", "First")
+ .getLayout("List", "Second")
+ .getLayout("List", "Third")
+ .getLayout("List", "Fourth")
+ .add("Value", "string")
+ .getEntry();
+
+ assertAll(
+ () -> assertEquals("string", entry.getString(null), "Wrong entry value"),
+ () -> assertEquals("/Shuffleboard/Tab/First/Second/Third/Fourth/Value", entry.getName(),
+ "Entry path generated incorrectly"));
+ }
+
+ @Test
+ void testNestedLayoutsOop() {
+ ShuffleboardTab tab = m_shuffleboardInstance.getTab("Tab");
+ ShuffleboardLayout first = tab.getLayout("List", "First");
+ ShuffleboardLayout second = first.getLayout("List", "Second");
+ ShuffleboardLayout third = second.getLayout("List", "Third");
+ ShuffleboardLayout fourth = third.getLayout("List", "Fourth");
+ SimpleWidget widget = fourth.add("Value", "string");
+ NetworkTableEntry entry = widget.getEntry();
+
+ assertAll(
+ () -> assertEquals("string", entry.getString(null), "Wrong entry value"),
+ () -> assertEquals("/Shuffleboard/Tab/First/Second/Third/Fourth/Value", entry.getName(),
+ "Entry path generated incorrectly"));
+ }
+
+ @Test
+ void testLayoutTypeIsSet() {
+ String layoutType = "Type";
+ m_shuffleboardInstance.getTab("Tab")
+ .getLayout(layoutType, "Title");
+ m_shuffleboardInstance.update();
+ NetworkTableEntry entry = m_ntInstance.getEntry(
+ "/Shuffleboard/.metadata/Tab/Title/PreferredComponent");
+ assertEquals(layoutType, entry.getString("Not Set"), "Layout type not set");
+ }
+
+ @Test
+ void testNestedActuatorWidgetsAreDisabled() {
+ m_shuffleboardInstance.getTab("Tab")
+ .getLayout("Layout", "Title")
+ .add(new MockActuatorSendable("Actuator"));
+ NetworkTableEntry controllableEntry =
+ m_ntInstance.getEntry("/Shuffleboard/Tab/Title/Actuator/.controllable");
+
+ m_shuffleboardInstance.update();
+
+ // Note: we use the unsafe `getBoolean()` method because if the value is NOT a boolean, or if it
+ // is not present, then something has clearly gone very, very wrong
+ boolean controllable = controllableEntry.getValue().getBoolean();
+
+ // Sanity check
+ assertTrue(controllable, "The nested actuator widget should be enabled by default");
+ m_shuffleboardInstance.disableActuatorWidgets();
+ controllable = controllableEntry.getValue().getBoolean();
+ assertFalse(controllable, "The nested actuator widget should have been disabled");
+ }
+
+}
diff --git a/wpilibj/src/test/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardTabTest.java b/wpilibj/src/test/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardTabTest.java
new file mode 100644
index 0000000000..fe48c130ef
--- /dev/null
+++ b/wpilibj/src/test/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardTabTest.java
@@ -0,0 +1,152 @@
+/*----------------------------------------------------------------------------*/
+/* Copyright (c) 2018 FIRST. All Rights Reserved. */
+/* Open Source Software - may be modified and shared by FRC teams. The code */
+/* must be accompanied by the FIRST BSD license file in the root directory of */
+/* the project. */
+/*----------------------------------------------------------------------------*/
+
+package edu.wpi.first.wpilibj.shuffleboard;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import edu.wpi.first.networktables.NetworkTableEntry;
+import edu.wpi.first.networktables.NetworkTableInstance;
+import edu.wpi.first.wpilibj.Sendable;
+import edu.wpi.first.wpilibj.command.InstantCommand;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+@SuppressWarnings({"PMD.TooManyMethods"})
+public class ShuffleboardTabTest {
+ private NetworkTableInstance m_ntInstance;
+ private ShuffleboardTab m_tab;
+ private ShuffleboardInstance m_instance;
+
+ @BeforeEach
+ void setup() {
+ m_ntInstance = NetworkTableInstance.create();
+ m_instance = new ShuffleboardInstance(m_ntInstance);
+ m_tab = m_instance.getTab("Tab");
+ }
+
+ @AfterEach
+ void tearDown() {
+ m_ntInstance.close();
+ }
+
+ @Test
+ void testAddDouble() {
+ NetworkTableEntry entry = m_tab.add("Double", 1.0).getEntry();
+ assertAll(
+ () -> assertEquals("/Shuffleboard/Tab/Double", entry.getName()),
+ () -> assertEquals(1.0, entry.getValue().getDouble()));
+ }
+
+ @Test
+ void testAddInteger() {
+ NetworkTableEntry entry = m_tab.add("Int", 1).getEntry();
+ assertAll(
+ () -> assertEquals("/Shuffleboard/Tab/Int", entry.getName()),
+ () -> assertEquals(1.0, entry.getValue().getDouble()));
+ }
+
+ @Test
+ void testAddLong() {
+ NetworkTableEntry entry = m_tab.add("Long", 1L).getEntry();
+ assertAll(
+ () -> assertEquals("/Shuffleboard/Tab/Long", entry.getName()),
+ () -> assertEquals(1.0, entry.getValue().getDouble()));
+ }
+
+
+ @Test
+ void testAddBoolean() {
+ NetworkTableEntry entry = m_tab.add("Bool", false).getEntry();
+ assertAll(
+ () -> assertEquals("/Shuffleboard/Tab/Bool", entry.getName()),
+ () -> assertFalse(entry.getValue().getBoolean()));
+ }
+
+ @Test
+ void testAddString() {
+ NetworkTableEntry entry = m_tab.add("String", "foobar").getEntry();
+ assertAll(
+ () -> assertEquals("/Shuffleboard/Tab/String", entry.getName()),
+ () -> assertEquals("foobar", entry.getValue().getString()));
+ }
+
+ @Test
+ void testAddNamedSendableWithProperties() {
+ Sendable sendable = new InstantCommand("Command");
+ String widgetType = "Command Widget";
+ m_tab.add(sendable)
+ .withWidget(widgetType)
+ .withProperties(mapOf("foo", 1234, "bar", "baz"));
+
+ m_instance.update();
+ String meta = "/Shuffleboard/.metadata/Tab/Command";
+
+ assertAll(
+ () -> assertEquals(1234,
+ m_ntInstance.getEntry(meta + "/Properties/foo").getDouble(-1),
+ "Property 'foo' not set correctly"),
+ () -> assertEquals("baz",
+ m_ntInstance.getEntry(meta + "/Properties/bar").getString(null),
+ "Property 'bar' not set correctly"),
+ () -> assertEquals(widgetType,
+ m_ntInstance.getEntry(meta + "/PreferredComponent").getString(null),
+ "Preferred component not set correctly"));
+ }
+
+ @Test
+ void testAddNumberArray() {
+ NetworkTableEntry entry = m_tab.add("DoubleArray", new double[]{1, 2, 3}).getEntry();
+ assertAll(
+ () -> assertEquals("/Shuffleboard/Tab/DoubleArray", entry.getName()),
+ () -> assertArrayEquals(new double[]{1, 2, 3}, entry.getValue().getDoubleArray()));
+ }
+
+ @Test
+ void testAddBooleanArray() {
+ NetworkTableEntry entry = m_tab.add("BoolArray", new boolean[]{true, false}).getEntry();
+ assertAll(
+ () -> assertEquals("/Shuffleboard/Tab/BoolArray", entry.getName()),
+ () -> assertArrayEquals(new boolean[]{true, false}, entry.getValue().getBooleanArray()));
+ }
+
+ @Test
+ void testAddStringArray() {
+ NetworkTableEntry entry = m_tab.add("StringArray", new String[]{"foo", "bar"}).getEntry();
+ assertAll(
+ () -> assertEquals("/Shuffleboard/Tab/StringArray", entry.getName()),
+ () -> assertArrayEquals(new String[]{"foo", "bar"}, entry.getValue().getStringArray()));
+ }
+
+ @Test
+ void testTitleDuplicates() {
+ m_tab.add("foo", "bar");
+ assertThrows(IllegalArgumentException.class, () -> m_tab.add("foo", "baz"));
+ }
+
+ /**
+ * Stub for Java 9 {@code Map.of()}.
+ */
+ @SuppressWarnings({"unchecked", "PMD"})
+ private static Map mapOf(Object... entries) {
+ Map map = new HashMap<>();
+ for (int i = 0; i < entries.length; i += 2) {
+ map.put((K) entries[i], (V) entries[i + 1]);
+ }
+ return map;
+ }
+
+}
diff --git a/wpilibj/src/test/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardTest.java b/wpilibj/src/test/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardTest.java
new file mode 100644
index 0000000000..c4285fb099
--- /dev/null
+++ b/wpilibj/src/test/java/edu/wpi/first/wpilibj/shuffleboard/ShuffleboardTest.java
@@ -0,0 +1,30 @@
+/*----------------------------------------------------------------------------*/
+/* Copyright (c) 2018 FIRST. All Rights Reserved. */
+/* Open Source Software - may be modified and shared by FRC teams. The code */
+/* must be accompanied by the FIRST BSD license file in the root directory of */
+/* the project. */
+/*----------------------------------------------------------------------------*/
+
+package edu.wpi.first.wpilibj.shuffleboard;
+
+import org.junit.jupiter.api.Test;
+
+import edu.wpi.first.wpilibj.UtilityClassTest;
+
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+public class ShuffleboardTest extends UtilityClassTest {
+ public ShuffleboardTest() {
+ super(Shuffleboard.class);
+ }
+
+ // Most relevant tests are in ShuffleboardTabTest
+
+ @Test
+ void testTabObjectsCached() {
+ ShuffleboardTab tab1 = Shuffleboard.getTab("testTabObjectsCached");
+ ShuffleboardTab tab2 = Shuffleboard.getTab("testTabObjectsCached");
+ assertSame(tab1, tab2, "Tab objects were not cached");
+ }
+
+}
diff --git a/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/examples.json b/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/examples.json
index eb2a26ed93..19af701cd1 100644
--- a/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/examples.json
+++ b/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/examples.json
@@ -176,5 +176,16 @@
"foldername": "axiscamera",
"gradlebase": "java",
"mainclass": "Main"
+ },
+ {
+ "name": "Shuffleboard Sample",
+ "description": "An example program that adds data to various Shuffleboard tabs that demonstrates the Shuffleboard API",
+ "tags": [
+ "Shuffleboard",
+ "Dashboards"
+ ],
+ "foldername": "shuffleboard",
+ "gradlebase": "java",
+ "mainclass": "Main"
}
]
diff --git a/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/shuffleboard/Main.java b/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/shuffleboard/Main.java
new file mode 100644
index 0000000000..d2283df467
--- /dev/null
+++ b/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/shuffleboard/Main.java
@@ -0,0 +1,29 @@
+/*----------------------------------------------------------------------------*/
+/* Copyright (c) 2018 FIRST. All Rights Reserved. */
+/* Open Source Software - may be modified and shared by FRC teams. The code */
+/* must be accompanied by the FIRST BSD license file in the root directory of */
+/* the project. */
+/*----------------------------------------------------------------------------*/
+
+package edu.wpi.first.wpilibj.examples.shuffleboard;
+
+import edu.wpi.first.wpilibj.RobotBase;
+
+/**
+ * Do NOT add any static variables to this class, or any initialization at all.
+ * Unless you know what you are doing, do not modify this file except to
+ * change the parameter class to the startRobot call.
+ */
+public final class Main {
+ private Main() {
+ }
+
+ /**
+ * Main initialization function. Do not perform any initialization here.
+ *
+ * If you change your main robot class, change the parameter type.
+ */
+ public static void main(String... args) {
+ RobotBase.startRobot(Robot::new);
+ }
+}
diff --git a/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/shuffleboard/Robot.java b/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/shuffleboard/Robot.java
new file mode 100644
index 0000000000..a04a860933
--- /dev/null
+++ b/wpilibjExamples/src/main/java/edu/wpi/first/wpilibj/examples/shuffleboard/Robot.java
@@ -0,0 +1,62 @@
+/*----------------------------------------------------------------------------*/
+/* Copyright (c) 2018 FIRST. All Rights Reserved. */
+/* Open Source Software - may be modified and shared by FRC teams. The code */
+/* must be accompanied by the FIRST BSD license file in the root directory of */
+/* the project. */
+/*----------------------------------------------------------------------------*/
+
+package edu.wpi.first.wpilibj.examples.shuffleboard;
+
+import edu.wpi.first.networktables.NetworkTableEntry;
+import edu.wpi.first.wpilibj.AnalogPotentiometer;
+import edu.wpi.first.wpilibj.Encoder;
+import edu.wpi.first.wpilibj.IterativeRobot;
+import edu.wpi.first.wpilibj.Spark;
+import edu.wpi.first.wpilibj.drive.DifferentialDrive;
+import edu.wpi.first.wpilibj.shuffleboard.Shuffleboard;
+import edu.wpi.first.wpilibj.shuffleboard.ShuffleboardLayout;
+import edu.wpi.first.wpilibj.shuffleboard.ShuffleboardTab;
+
+public class Robot extends IterativeRobot {
+ private final DifferentialDrive m_tankDrive = new DifferentialDrive(new Spark(0), new Spark(1));
+ private final Encoder m_leftEncoder = new Encoder(0, 1);
+ private final Encoder m_rightEncoder = new Encoder(2, 3);
+
+ private final Spark m_elevatorMotor = new Spark(2);
+ private final AnalogPotentiometer m_elevatorPot = new AnalogPotentiometer(0);
+ private NetworkTableEntry m_maxSpeed;
+
+ @Override
+ public void robotInit() {
+ // Add a 'max speed' widget to a tab named 'Configuration', using a number slider
+ // The widget will be placed in the second column and row and will be two columns wide
+ m_maxSpeed = Shuffleboard.getTab("Configuration")
+ .add("Max Speed", 1)
+ .withWidget("Number Slider")
+ .withPosition(1, 1)
+ .withSize(2, 1)
+ .getEntry();
+
+ // Add the tank drive and encoders to a 'Drivebase' tab
+ ShuffleboardTab driveBaseTab = Shuffleboard.getTab("Drivebase");
+ driveBaseTab.add("Tank Drive", m_tankDrive);
+ // Put both encoders in a list layout
+ ShuffleboardLayout encoders = driveBaseTab.getLayout("List", "Encoders")
+ .withPosition(0, 0)
+ .withSize(2, 2);
+ encoders.add("Left Encoder", m_leftEncoder);
+ encoders.add("Right Encoder", m_rightEncoder);
+
+ // Add the elevator motor and potentiometer to an 'Elevator' tab
+ ShuffleboardTab elevatorTab = Shuffleboard.getTab("Elevator");
+ elevatorTab.add("Motor", m_elevatorMotor);
+ elevatorTab.add("Potentiometer", m_elevatorPot);
+ }
+
+ @Override
+ public void autonomousInit() {
+ // Read the value of the 'max speed' widget from the dashboard
+ m_tankDrive.setMaxOutput(m_maxSpeed.getDouble(1.0));
+ }
+
+}