|
| 1 | +package edu.wpi.grip.ui; |
| 2 | + |
| 3 | +import com.google.common.eventbus.EventBus; |
| 4 | +import com.google.common.eventbus.Subscribe; |
| 5 | +import com.google.common.io.LineReader; |
| 6 | +import edu.wpi.grip.core.Pipeline; |
| 7 | +import edu.wpi.grip.core.events.ProjectSettingsChangedEvent; |
| 8 | +import edu.wpi.grip.core.events.StopPipelineEvent; |
| 9 | +import edu.wpi.grip.core.serialization.Project; |
| 10 | +import edu.wpi.grip.core.settings.ProjectSettings; |
| 11 | +import edu.wpi.grip.ui.util.StringInMemoryFile; |
| 12 | +import javafx.application.Platform; |
| 13 | +import javafx.beans.property.BooleanProperty; |
| 14 | +import javafx.beans.property.SimpleBooleanProperty; |
| 15 | +import javafx.fxml.FXML; |
| 16 | +import javafx.scene.control.*; |
| 17 | +import net.schmizz.sshj.SSHClient; |
| 18 | +import net.schmizz.sshj.common.StreamCopier; |
| 19 | +import net.schmizz.sshj.connection.channel.direct.Session; |
| 20 | +import net.schmizz.sshj.userauth.UserAuthException; |
| 21 | +import net.schmizz.sshj.xfer.FileSystemFile; |
| 22 | +import net.schmizz.sshj.xfer.LoggingTransferListener; |
| 23 | +import net.schmizz.sshj.xfer.scp.SCPFileTransfer; |
| 24 | + |
| 25 | +import javax.inject.Inject; |
| 26 | +import java.io.IOException; |
| 27 | +import java.io.InputStreamReader; |
| 28 | +import java.io.InterruptedIOException; |
| 29 | +import java.io.StringWriter; |
| 30 | +import java.net.UnknownHostException; |
| 31 | +import java.util.Optional; |
| 32 | +import java.util.logging.Level; |
| 33 | +import java.util.logging.Logger; |
| 34 | + |
| 35 | +/** |
| 36 | + * JavaFX controller for the deploy tool. |
| 37 | + * <p> |
| 38 | + * This basically uploads a headless version of GRIP and the current project to a remote target using SSH, then |
| 39 | + * runs it remotely. The default values for all fields are based on typical settings for FRC, with the address |
| 40 | + * based on the project settings. |
| 41 | + */ |
| 42 | +public class DeployController { |
| 43 | + private final static String GRIP_JAR = "grip.jar"; |
| 44 | + private final static String PROJECT_FILE = "project.grip"; |
| 45 | + |
| 46 | + @FXML private TextField address; |
| 47 | + @FXML private TextField user; |
| 48 | + @FXML private TextField password; |
| 49 | + @FXML private TextField javaHome; |
| 50 | + @FXML private TextField deployDir; |
| 51 | + @FXML private ProgressIndicator progress; |
| 52 | + @FXML private Button deployButton; |
| 53 | + @FXML private Label status; |
| 54 | + @FXML private TextArea console; |
| 55 | + |
| 56 | + @Inject private EventBus eventBus; |
| 57 | + @Inject private Project project; |
| 58 | + @Inject private Pipeline pipeline; |
| 59 | + @Inject private Logger logger; |
| 60 | + |
| 61 | + private final BooleanProperty deploying = new SimpleBooleanProperty(this, "deploying", false); |
| 62 | + private Optional<Thread> deployThread = Optional.empty(); |
| 63 | + |
| 64 | + @FXML |
| 65 | + public void initialize() { |
| 66 | + loadSettings(pipeline.getProjectSettings()); |
| 67 | + |
| 68 | + deployButton.disableProperty().bind(deploying); |
| 69 | + progress.disableProperty().bind(deploying.not()); |
| 70 | + deploying.addListener((o, b, d) -> progress.setProgress(d ? ProgressIndicator.INDETERMINATE_PROGRESS : 0)); |
| 71 | + } |
| 72 | + |
| 73 | + @Subscribe |
| 74 | + public void onSettingsChanged(ProjectSettingsChangedEvent event) { |
| 75 | + Platform.runLater(() -> loadSettings(event.getProjectSettings())); |
| 76 | + } |
| 77 | + |
| 78 | + private void loadSettings(ProjectSettings settings) { |
| 79 | + // Almost all of the deploy settings can be persistently saved in the project settings. Whenever the project |
| 80 | + // settings are updated (either the user has edited them or a new project has been opened), we should update |
| 81 | + // the fields with the new setting values. |
| 82 | + address.setText(settings.getDeployAddress()); |
| 83 | + user.setText(settings.getDeployUser()); |
| 84 | + javaHome.setText(settings.getDeployJavaHome()); |
| 85 | + deployDir.setText(settings.getDeployDir()); |
| 86 | + } |
| 87 | + |
| 88 | + private void saveSettings() { |
| 89 | + // If the settings are updated in the deploy dialog, we still want to save them in the persistent project |
| 90 | + // settings, so they don't get reset the next time the project is opened to the settings are edited. |
| 91 | + final ProjectSettings settings = pipeline.getProjectSettings(); |
| 92 | + settings.setDeployAddress(address.getText()); |
| 93 | + settings.setDeployUser(user.getText()); |
| 94 | + settings.setDeployJavaHome(javaHome.getText()); |
| 95 | + settings.setDeployDir(deployDir.getText()); |
| 96 | + eventBus.post(new ProjectSettingsChangedEvent(settings)); |
| 97 | + } |
| 98 | + |
| 99 | + @FXML |
| 100 | + public void onDeploy() { |
| 101 | + saveSettings(); |
| 102 | + |
| 103 | + deploying.setValue(true); |
| 104 | + console.clear(); |
| 105 | + |
| 106 | + // Start the deploy in a new thread, so the GUI doesn't freeze |
| 107 | + deployThread = Optional.of(new Thread(() -> |
| 108 | + deploy(address.getText(), user.getText(), password.getText(), javaHome.getText(), deployDir.getText()))); |
| 109 | + deployThread.get().setDaemon(true); |
| 110 | + deployThread.get().start(); |
| 111 | + } |
| 112 | + |
| 113 | + @FXML |
| 114 | + public void onStop() { |
| 115 | + deployThread.ifPresent(Thread::interrupt); |
| 116 | + deploying.setValue(false); |
| 117 | + status.setText(""); |
| 118 | + } |
| 119 | + |
| 120 | + /** |
| 121 | + * Upload and run the GRIP project using the current deploy settings. This is run in a separate thread, and it |
| 122 | + * periodically updates the GUI to inform the user of the current status of the deployment. |
| 123 | + */ |
| 124 | + private void deploy(String address, String user, String password, String javaHome, String deployDir) { |
| 125 | + setStatusAsync("Connecting to " + address, false); |
| 126 | + |
| 127 | + try (SSHClient ssh = new SSHClient()) { |
| 128 | + ssh.loadKnownHosts(); |
| 129 | + ssh.connect(address); |
| 130 | + ssh.authPassword(user, password); |
| 131 | + |
| 132 | + // Update the progress bar and status text while uploading files |
| 133 | + SCPFileTransfer scp = ssh.newSCPFileTransfer(); |
| 134 | + scp.setTransferListener(new LoggingTransferListener() { |
| 135 | + @Override |
| 136 | + public StreamCopier.Listener file(String name, long size) { |
| 137 | + setStatusAsync("Uploading " + name, false); |
| 138 | + return transferred -> { |
| 139 | + if (isNotCanceled()) |
| 140 | + Platform.runLater(() -> progress.setProgress((double) transferred / size)); |
| 141 | + }; |
| 142 | + } |
| 143 | + }); |
| 144 | + |
| 145 | + // The project may or may not be saved to a file (and even if it was saved, it might be modified), so we |
| 146 | + // serialize it to a string before deploying. |
| 147 | + StringWriter projectWriter = new StringWriter(); |
| 148 | + project.save(projectWriter); |
| 149 | + |
| 150 | + // Upload the GRIP core JAR and the serialized project to the robot |
| 151 | + scp.upload(new StringInMemoryFile(PROJECT_FILE, projectWriter.toString()), deployDir + "/"); |
| 152 | + scp.upload(new FileSystemFile(Project.class.getProtectionDomain().getCodeSource().getLocation().getPath()), |
| 153 | + deployDir + "/" + GRIP_JAR); |
| 154 | + |
| 155 | + // Stop the pipeline before running it remotely, so the two instances of GRIP don't try to publish to the |
| 156 | + // same NetworkTables keys. |
| 157 | + eventBus.post(new StopPipelineEvent()); |
| 158 | + |
| 159 | + // Run the project! |
| 160 | + setStatusAsync("Running GRIP", false); |
| 161 | + Session session = ssh.startSession(); |
| 162 | + session.allocateDefaultPTY(); |
| 163 | + Session.Command cmd = session.exec(javaHome + "/bin/java -jar " + deployDir + "/" + GRIP_JAR + " " + deployDir + "/" + PROJECT_FILE); |
| 164 | + |
| 165 | + LineReader inputReader = new LineReader(new InputStreamReader(cmd.getInputStream())); |
| 166 | + while (isNotCanceled()) { |
| 167 | + String line = inputReader.readLine(); |
| 168 | + if (line == null) { |
| 169 | + return; |
| 170 | + } |
| 171 | + |
| 172 | + Platform.runLater(() -> console.setText(console.getText() + line + "\n")); |
| 173 | + } |
| 174 | + } catch (UnknownHostException e) { |
| 175 | + setStatusAsync("Unknown host: " + address, true); |
| 176 | + } catch (UserAuthException e) { |
| 177 | + setStatusAsync("Invalid username or password (should be \"lvuser\" and blank for roboRIO)", true); |
| 178 | + } catch (InterruptedIOException e) { |
| 179 | + logger.info("Deploy canceled"); |
| 180 | + } catch (IOException e) { |
| 181 | + logger.log(Level.WARNING, "Unexpected error deploying", e); |
| 182 | + setStatusAsync(e.getMessage(), true); |
| 183 | + } finally { |
| 184 | + if (isNotCanceled()) { |
| 185 | + Platform.runLater(() -> deploying.setValue(false)); |
| 186 | + } |
| 187 | + } |
| 188 | + } |
| 189 | + |
| 190 | + /** |
| 191 | + * Called in the deploy thread to check if the deploy has been canceled. This prevents the thread from continuing |
| 192 | + * to run commands and updating UI elements after the cancel button has been pressed, but without the need for |
| 193 | + * the GUI thread to join on it (and therefore block) |
| 194 | + */ |
| 195 | + private boolean isNotCanceled() { |
| 196 | + return !Thread.currentThread().isInterrupted(); |
| 197 | + } |
| 198 | + |
| 199 | + /** |
| 200 | + * Show a message, asynchronously in the GUI thread. This is called periodically by the deploy thread to provide |
| 201 | + * feedback without locking up the GUI. This does nothing if the current deploy has been canceled. |
| 202 | + */ |
| 203 | + private void setStatusAsync(String statusText, boolean error) { |
| 204 | + if (isNotCanceled()) { |
| 205 | + logger.log(error ? Level.WARNING : Level.INFO, statusText); |
| 206 | + Platform.runLater(() -> { |
| 207 | + status.getStyleClass().setAll("label", error ? "error-label" : "info-label"); |
| 208 | + status.setText(statusText); |
| 209 | + }); |
| 210 | + } |
| 211 | + } |
| 212 | +} |
0 commit comments