Skip to content

Commit f65cb0f

Browse files
committed
Merge pull request #386 from ThomasJClark/deploy-ui
Fix the deploy UI
2 parents 5f6d528 + f287e4f commit f65cb0f

24 files changed

Lines changed: 444 additions & 1099 deletions

build.gradle

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -199,9 +199,7 @@ project(":ui") {
199199
compile project(path: ':core', configuration: 'shadow')
200200
ideProvider project(path: ':core', configuration: 'compile')
201201
compile group: 'org.controlsfx', name: 'controlsfx', version: '8.40.10'
202-
compile group: 'org.apache.ant', name: 'ant-jsch', version: '1.8.1'
203-
compile group: 'com.jcabi', name: 'jcabi-ssh', version: '1.5'
204-
compile group: 'org.jdeferred', name: 'jdeferred-core', version: '1.2.4'
202+
compile group: 'com.hierynomus', name: 'sshj', version: '0.15.0'
205203
testCompile files(project(':core').sourceSets.test.output.classesDir)
206204
testCompile files(project(':core').sourceSets.test.output.resourcesDir)
207205
testCompile group: 'org.testfx', name: 'testfx-core', version: '4.0.+'

core/src/main/java/edu/wpi/grip/core/Main.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
import edu.wpi.grip.core.events.ExceptionEvent;
99
import edu.wpi.grip.core.operations.Operations;
1010
import edu.wpi.grip.core.serialization.Project;
11+
import edu.wpi.grip.core.util.SafeShutdown;
1112
import edu.wpi.grip.generated.CVOperations;
13+
import sun.misc.Signal;
1214

1315
import javax.inject.Inject;
1416
import java.io.File;
@@ -30,6 +32,10 @@ public class Main {
3032

3133
@SuppressWarnings("PMD.SystemPrintln")
3234
public static void main(String[] args) throws IOException, InterruptedException {
35+
// Close GRIP when we get SIGHUP. This signal is sent, for example, when GRIP is run in an SSH session
36+
// and the session is closed.
37+
Signal.handle(new Signal("HUP"), signal -> SafeShutdown.exit(0));
38+
3339
System.out.println("Loading Dependency Injection Framework");
3440
final Injector injector = Guice.createInjector(new GRIPCoreModule());
3541
injector.getInstance(Main.class).start(args);

core/src/main/java/edu/wpi/grip/core/operations/networktables/NTManager.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ public void updateSettings(ProjectSettingsChangedEvent event) {
6464

6565
synchronized (NetworkTable.class) {
6666
NetworkTable.shutdown();
67-
NetworkTable.setIPAddress(projectSettings.computePublishAddress());
67+
NetworkTable.setIPAddress(projectSettings.getPublishAddress());
6868
}
6969
}
7070

core/src/main/java/edu/wpi/grip/core/settings/ProjectSettings.java

Lines changed: 62 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,78 +4,112 @@
44
import com.google.common.base.Throwables;
55

66
import static com.google.common.base.Preconditions.checkArgument;
7-
import static com.google.common.base.Preconditions.checkNotNull;
87

98
/**
109
* This object holds settings that are saved in project files. This includes things like team numbers, which need to
1110
* be preserved when deploying the project.
1211
*/
1312
public class ProjectSettings implements Cloneable {
1413

15-
@Setting(label = "FRC Team Number", description = "The team number, if used for FRC")
14+
@Setting(label = "FRC team number", description = "The team number, if used for FRC")
1615
private int teamNumber = 0;
1716

18-
@Setting(label = "NetworkTables Server Address", description = "The host that runs the Network Protocol server. " +
19-
"If not specified and NetworkTables is specified as the protocol, the hostname is derived from the team " +
20-
"number.")
21-
private String publishAddress = "";
17+
@Setting(label = "NetworkTables server address", description = "The host that runs the NetworkTables server. If " +
18+
"not specified and NetworkTables is used, the hostname is derived from the team number.")
19+
private String publishAddress = computeFRCAddress(teamNumber);
2220

23-
@Setting(label = "Deploy Address", description = "The remote host that grip should be remotely deployed to. If " +
24-
"not specified, the hostname is derived from the team number.")
25-
private String deployAddress = "";
21+
@Setting(label = "Deploy address", description = "The remote host that grip should be deployed to. " +
22+
"If not specified, the hostname is derived from the team number.")
23+
private String deployAddress = computeFRCAddress(teamNumber);
2624

25+
@Setting(label = "Deploy directory", description = "The directory on the remote host to deploy GRIP to.")
26+
private String deployDir = "/home/lvuser";
27+
28+
@Setting(label = "Deploy user", description = "The username to log in with when deploying over SSH.")
29+
private String deployUser = "lvuser";
30+
31+
@Setting(label = "Deploy Java home", description = "Where Java is installed on the robot.")
32+
private String deployJavaHome = "/usr/local/frc/JRE/";
33+
34+
/**
35+
* Set the FRC team number. If the deploy address and NetworkTables server address haven't been manually
36+
* overridden, this also changes them to the mDNS hostname of the team's roboRIO.
37+
*/
2738
public void setTeamNumber(int teamNumber) {
2839
checkArgument(teamNumber >= 0, "Team number cannot be negative");
40+
41+
final String oldFrcAddress = computeFRCAddress(this.teamNumber);
42+
final String newFrcAddress = computeFRCAddress(teamNumber);
43+
2944
this.teamNumber = teamNumber;
45+
46+
// If the deploy address and/or NetworkTables server address was previously the default for the old team
47+
// number (ie: it was roborio-xxx-frc.local), update it with the new team number
48+
if (oldFrcAddress.equals(getDeployAddress())) {
49+
setDeployAddress(newFrcAddress);
50+
}
51+
52+
if (oldFrcAddress.equals(getPublishAddress())) {
53+
setPublishAddress(newFrcAddress);
54+
}
3055
}
3156

3257
public int getTeamNumber() {
3358
return teamNumber;
3459
}
3560

3661
public void setPublishAddress(String publishAddress) {
37-
this.publishAddress =
38-
checkNotNull(publishAddress, "Network Protocol Server Address cannot be null");
62+
if (publishAddress != null) this.publishAddress = publishAddress;
3963
}
4064

4165
public String getPublishAddress() {
4266
return publishAddress;
4367
}
4468

4569
public void setDeployAddress(String deployAddress) {
46-
this.deployAddress = checkNotNull(deployAddress, "Deploy Address can not be null");
70+
if (deployAddress != null) this.deployAddress = deployAddress;
4771
}
4872

4973
public String getDeployAddress() {
5074
return deployAddress;
5175
}
5276

53-
/**
54-
* @return The address of the machine that the NetworkTables server is running on. If
55-
* {@link #setPublishAddress} is specified, that is returned, otherwise this is based on the team
56-
* number.
57-
*/
58-
public String computePublishAddress() {
59-
return computeFRCAddress(publishAddress);
77+
public String getDeployDir() {
78+
return deployDir;
6079
}
6180

62-
public String computeDeployAddress() {
63-
return computeFRCAddress(deployAddress);
81+
public void setDeployDir(String deployDir) {
82+
if (deployDir != null) this.deployDir = deployDir;
6483
}
6584

66-
private String computeFRCAddress(String address) {
67-
if (address == null || address.isEmpty()) {
68-
return "roborio-" + teamNumber + "-frc.local";
69-
} else {
70-
return address;
71-
}
85+
public String getDeployUser() {
86+
return deployUser;
87+
}
88+
89+
public void setDeployUser(String deployUser) {
90+
if (deployUser != null) this.deployUser = deployUser;
91+
}
92+
93+
public String getDeployJavaHome() {
94+
return deployJavaHome;
95+
}
96+
97+
public void setDeployJavaHome(String deployJavaHome) {
98+
if (deployJavaHome != null) this.deployJavaHome = deployJavaHome;
99+
}
100+
101+
private String computeFRCAddress(int teamNumber) {
102+
return "roborio-" + teamNumber + "-frc.local";
72103
}
73104

74105
@Override
75106
public String toString() {
76107
return MoreObjects.toStringHelper(this)
77-
.add("publishAddress", publishAddress)
78108
.add("deployAddress", deployAddress)
109+
.add("deployDir", deployDir)
110+
.add("deployUser", deployUser)
111+
.add("deployJavaHome", deployJavaHome)
112+
.add("publishAddress", publishAddress)
79113
.add("teamNumber", teamNumber)
80114
.toString();
81115
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
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

Comments
 (0)