Merge "Spin down finisher faster"
diff --git a/aos/events/simulated_event_loop.cc b/aos/events/simulated_event_loop.cc
index 9121a8f..11e10a6 100644
--- a/aos/events/simulated_event_loop.cc
+++ b/aos/events/simulated_event_loop.cc
@@ -1275,7 +1275,10 @@
   scheduler_.ScheduleOnStartup([this]() {
     UUID next_uuid = scheduler_.boot_uuid();
     if (boot_uuid_ != next_uuid) {
-      CHECK_EQ(boot_uuid_, UUID::Zero());
+      CHECK_EQ(boot_uuid_, UUID::Zero())
+          << ": Boot UUID changed without restarting.  Did TimeConverter "
+             "change the boot UUID without signaling a restart, or did you "
+             "change TimeConverter?";
       boot_uuid_ = next_uuid;
     }
     VLOG(1) << scheduler_.distributed_now() << " " << NodeName(this->node())
diff --git a/aos/starter/BUILD b/aos/starter/BUILD
index 8097f6a..73353f6 100644
--- a/aos/starter/BUILD
+++ b/aos/starter/BUILD
@@ -1,4 +1,5 @@
 load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
+load("//aos:config.bzl", "aos_config")
 
 # This target is everything which should get deployed to the robot.
 filegroup(
@@ -36,21 +37,47 @@
     ],
 )
 
+aos_config(
+    name = "multinode_pingpong_config",
+    src = "multinode_pingpong.json",
+    flatbuffers = [
+        "//aos/events:ping_fbs",
+        "//aos/events:pong_fbs",
+        ":starter_rpc_fbs",
+        ":starter_fbs",
+        "//aos/logging:log_message_fbs",
+        "//aos/events:event_loop_fbs",
+        "//aos/network:message_bridge_client_fbs",
+        "//aos/network:remote_message_fbs",
+        "//aos/network:timestamp_fbs",
+        "//aos/network:message_bridge_server_fbs",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+)
+
 cc_test(
     name = "starter_test",
     srcs = ["starter_test.cc"],
     data = [
+        ":multinode_pingpong_config",
         "//aos/events:ping",
         "//aos/events:pingpong_config",
         "//aos/events:pong",
     ],
+    linkopts = ["-lstdc++fs"],
     shard_count = 3,
-    target_compatible_with = ["@platforms//os:linux"],
+    # The roborio compiler doesn't support <filesystem>.
+    target_compatible_with =
+        select({
+            "//tools/platforms/hardware:roborio": ["@platforms//:incompatible"],
+            "//conditions:default": ["@platforms//os:linux"],
+        }),
     deps = [
         ":starter_rpc_lib",
         ":starterd_lib",
         "//aos/events:ping_fbs",
         "//aos/events:pong_fbs",
+        "//aos/events:simulated_event_loop",
         "//aos/testing:googletest",
         "//aos/testing:path",
         "//aos/testing:tmpdir",
@@ -73,9 +100,11 @@
     srcs = ["starter_rpc_lib.cc"],
     hdrs = ["starter_rpc_lib.h"],
     target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
     deps = [
         ":starter_fbs",
         ":starter_rpc_fbs",
+        ":starterd_lib",
         "//aos:configuration",
         "//aos:init",
         "//aos/events:shm_event_loop",
diff --git a/aos/starter/multinode_pingpong.json b/aos/starter/multinode_pingpong.json
new file mode 100644
index 0000000..0bb1ee0
--- /dev/null
+++ b/aos/starter/multinode_pingpong.json
@@ -0,0 +1,200 @@
+{
+  "channels": [
+    {
+      "name": "/pi1/aos",
+      "type": "aos.logging.LogMessageFbs",
+      "source_node": "pi1",
+      "frequency": 200,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.logging.LogMessageFbs",
+      "source_node": "pi2",
+      "frequency": 200,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.starter.Status",
+      "source_node": "pi1",
+      "frequency": 10,
+      "num_senders": 2,
+      "destination_nodes": [
+        {
+          "name": "pi2",
+          "timestamp_logger": "NOT_LOGGED"
+        }
+      ]
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.starter.Status",
+      "source_node": "pi2",
+      "frequency": 10,
+      "num_senders": 2,
+      "destination_nodes": [
+        {
+          "name": "pi1",
+          "timestamp_logger": "NOT_LOGGED"
+        }
+      ]
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.starter.StarterRpc",
+      "source_node": "pi1",
+      "frequency": 10,
+      "num_senders": 2,
+      "destination_nodes": [
+        {
+          "name": "pi2",
+          "timestamp_logger": "NOT_LOGGED"
+        }
+      ]
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.starter.StarterRpc",
+      "source_node": "pi2",
+      "frequency": 10,
+      "num_senders": 2,
+      "destination_nodes": [
+        {
+          "name": "pi1",
+          "timestamp_logger": "NOT_LOGGED"
+        }
+      ]
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.message_bridge.Timestamp",
+      "source_node": "pi1",
+      "frequency": 10,
+      "num_senders": 2,
+      "max_size": 200,
+      "destination_nodes": [
+        {
+          "name": "pi2",
+          "priority": 1,
+          "time_to_live": 5000000,
+          "timestamp_logger": "LOCAL_AND_REMOTE_LOGGER",
+          "timestamp_logger_nodes": ["pi1"]
+        }
+      ]
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.message_bridge.Timestamp",
+      "source_node": "pi2",
+      "frequency": 10,
+      "num_senders": 2,
+      "max_size": 200,
+      "destination_nodes": [
+        {
+          "name": "pi1",
+          "priority": 1,
+          "time_to_live": 5000000,
+          "timestamp_logger": "LOCAL_AND_REMOTE_LOGGER",
+          "timestamp_logger_nodes": ["pi2"]
+        }
+      ]
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.message_bridge.ServerStatistics",
+      "source_node": "pi1",
+      "frequency": 2
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.message_bridge.ServerStatistics",
+      "source_node": "pi2",
+      "frequency": 2
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.message_bridge.ClientStatistics",
+      "source_node": "pi1",
+      "frequency": 2
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.message_bridge.ClientStatistics",
+      "source_node": "pi2",
+      "frequency": 2
+    },
+    {
+      "name": "/pi1/aos/remote_timestamps/pi2",
+      "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
+      "source_node": "pi1"
+    },
+    {
+      "name": "/pi2/aos/remote_timestamps/pi1",
+      "type": "aos.message_bridge.RemoteMessage",
+      "logger": "NOT_LOGGED",
+      "source_node": "pi2"
+    },
+    {
+      "name": "/pi1/aos",
+      "type": "aos.timing.Report",
+      "source_node": "pi1",
+      "frequency": 50,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/pi2/aos",
+      "type": "aos.timing.Report",
+      "source_node": "pi2",
+      "frequency": 50,
+      "num_senders": 20,
+      "max_size": 2048
+    },
+    {
+      "name": "/test",
+      "type": "aos.examples.Ping",
+      "source_node": "pi1"
+    },
+    {
+      "name": "/test",
+      "type": "aos.examples.Pong",
+      "source_node": "pi1"
+    }
+  ],
+  "maps": [
+    {
+      "match": {
+        "name": "/aos*",
+        "source_node": "pi1"
+      },
+      "rename": {
+        "name": "/pi1/aos"
+      }
+    },
+    {
+      "match": {
+        "name": "/aos*",
+        "source_node": "pi2"
+      },
+      "rename": {
+        "name": "/pi2/aos"
+      }
+    }
+  ],
+  "nodes": [
+    {
+      "name": "pi1",
+      "hostname": "pi1",
+      "port": 9971
+    },
+    {
+      "name": "pi2",
+      "hostname": "pi2",
+      "port": 9971
+    }
+  ]
+}
diff --git a/aos/starter/starter_cmd.cc b/aos/starter/starter_cmd.cc
index d306306..ae65ff0 100644
--- a/aos/starter/starter_cmd.cc
+++ b/aos/starter/starter_cmd.cc
@@ -6,6 +6,7 @@
 #include <unordered_map>
 
 #include "absl/strings/str_format.h"
+#include "absl/strings/str_join.h"
 #include "aos/init.h"
 #include "aos/json_to_flatbuffer.h"
 #include "aos/time/time.h"
@@ -13,6 +14,11 @@
 #include "starter_rpc_lib.h"
 
 DEFINE_string(config, "./config.json", "File path of aos configuration");
+// TODO(james): Bash autocompletion for node names.
+DEFINE_string(
+    node, "",
+    "Node to interact with. If empty, just interact with local node.");
+DEFINE_bool(all_nodes, false, "Interact with all nodes.");
 
 DEFINE_bool(_bash_autocomplete, false,
             "Internal use: Outputs commands or applications for use with "
@@ -29,47 +35,71 @@
                         {"stop", aos::starter::Command::STOP},
                         {"restart", aos::starter::Command::RESTART}};
 
-const aos::Node *MaybeMyNode(const aos::Configuration *configuration) {
+std::vector<const aos::Node *> InteractNodes(
+    const aos::Configuration *configuration) {
   if (!configuration->has_nodes()) {
-    return nullptr;
+    return {nullptr};
   }
 
-  return aos::configuration::GetMyNode(configuration);
+  if (!FLAGS_node.empty()) {
+    CHECK(!FLAGS_all_nodes) << "Can't specify both --node and --all_nodes.";
+    return {aos::configuration::GetNode(configuration, FLAGS_node)};
+  }
+
+  if (FLAGS_all_nodes) {
+    return aos::configuration::GetNodes(configuration);
+  }
+
+  return {aos::configuration::GetMyNode(configuration)};
 }
 
-bool ValidApplication(const aos::Configuration *config,
-                      std::string_view application_name) {
-  const aos::Node *node = MaybeMyNode(config);
-  const aos::Application *application =
-      aos::configuration::GetApplication(config, node, application_name);
-  if (application == nullptr) {
-    if (node) {
-      std::cout << "Unknown application '" << application_name << "' on node '"
-                << node->name()->string_view() << "'" << std::endl;
-    } else {
-      std::cout << "Unknown application '" << application_name << "'"
-                << std::endl;
+std::vector<const aos::Node *> InteractNodesForApplication(
+    const aos::Configuration *config, std::string_view application_name) {
+  const std::vector<const aos::Node *> interact_nodes = InteractNodes(config);
+  std::vector<const aos::Node *> application_nodes;
+  std::vector<std::string> debug_node_names;
+  for (const aos::Node *node : interact_nodes) {
+    if (aos::configuration::GetApplication(config, node, application_name) !=
+        nullptr) {
+      application_nodes.push_back(node);
     }
-    return false;
+    if (node != nullptr) {
+      debug_node_names.push_back(node->name()->str());
+    }
   }
-  return true;
+
+  if (application_nodes.empty()) {
+    if (interact_nodes.size() == 1 && interact_nodes[0] == nullptr) {
+      std::cout << "Unknown application " << application_name << std::endl;
+    } else {
+      std::cout << "Unknown application " << application_name
+                << " on any of node(s) "
+                << absl::StrJoin(debug_node_names, ", ") << std::endl;
+    }
+  }
+  return application_nodes;
 }
 
 void PrintKey() {
-  absl::PrintF("%-30s %-8s %-6s %-9s\n", "Name", "State", "PID", "Uptime");
+  absl::PrintF("%-30s %-10s %-8s %-6s %-9s\n", "Name", "Node", "State", "PID",
+               "Uptime");
 }
 
 void PrintApplicationStatus(const aos::starter::ApplicationStatus *app_status,
-                            const aos::monotonic_clock::time_point &time) {
+                            const aos::monotonic_clock::time_point &time,
+                            const aos::Node *node) {
   const auto last_start_time = aos::monotonic_clock::time_point(
       chrono::nanoseconds(app_status->last_start_time()));
   const auto time_running =
       chrono::duration_cast<chrono::seconds>(time - last_start_time);
   if (app_status->state() == aos::starter::State::STOPPED) {
-    absl::PrintF("%-30s %-8s\n", app_status->name()->string_view(),
+    absl::PrintF("%-30s %-10s %-8s\n", app_status->name()->string_view(),
+                 (node == nullptr) ? "none" : node->name()->string_view(),
                  aos::starter::EnumNameState(app_status->state()));
   } else {
-    absl::PrintF("%-30s %-8s %-6d %-9s\n", app_status->name()->string_view(),
+    absl::PrintF("%-30s %-10s %-8s %-6d %-9s\n",
+                 app_status->name()->string_view(),
+                 (node == nullptr) ? "none" : node->name()->string_view(),
                  aos::starter::EnumNameState(app_status->state()),
                  app_status->pid(), std::to_string(time_running.count()) + 's');
   }
@@ -77,18 +107,30 @@
 
 // Prints the status for all applications.
 void GetAllStarterStatus(const aos::Configuration *config) {
-  // Print status for all processes.
-  const auto optional_status = aos::starter::GetStarterStatus(config);
-  if (optional_status) {
-    auto status = *optional_status;
-    const auto time = aos::monotonic_clock::now();
-    PrintKey();
-    for (const aos::starter::ApplicationStatus *app_status :
-         *status.message().statuses()) {
-      PrintApplicationStatus(app_status, time);
+  PrintKey();
+  std::vector<const aos::Node *> missing_nodes;
+  for (const aos::Node *node : InteractNodes(config)) {
+    // Print status for all processes.
+    const auto optional_status = aos::starter::GetStarterStatus(config, node);
+    if (optional_status) {
+      const aos::FlatbufferVector<aos::starter::Status> &status =
+          optional_status->second;
+      const aos::monotonic_clock::time_point time = optional_status->first;
+      for (const aos::starter::ApplicationStatus *app_status :
+           *status.message().statuses()) {
+        PrintApplicationStatus(app_status, time, node);
+      }
+    } else {
+      missing_nodes.push_back(node);
     }
-  } else {
-    LOG(WARNING) << "No status found";
+  }
+  for (const aos::Node *node : missing_nodes) {
+    if (node == nullptr) {
+      LOG(WARNING) << "No status found.";
+    } else {
+      LOG(WARNING) << "No status found for node "
+                   << node->name()->string_view();
+    }
   }
 }
 
@@ -106,12 +148,17 @@
       return false;
     }
 
-    if (!ValidApplication(config, application_name)) {
+    const std::vector<const aos::Node *> application_nodes =
+        InteractNodesForApplication(config, application_name);
+    if (application_nodes.empty()) {
       return false;
     }
-    auto status = aos::starter::GetStatus(application_name, config);
     PrintKey();
-    PrintApplicationStatus(&status.message(), aos::monotonic_clock::now());
+    for (const aos::Node *node : application_nodes) {
+      auto status = aos::starter::GetStatus(application_name, config, node);
+      PrintApplicationStatus(&status.message(), aos::monotonic_clock::now(),
+                             node);
+    }
   } else {
     LOG(ERROR) << "The \"status\" command requires zero or one arguments.";
     return true;
@@ -125,34 +172,69 @@
                      const aos::starter::Command command,
                      std::string_view success_text,
                      std::string_view failure_text) {
-  const auto optional_status = aos::starter::GetStarterStatus(config);
-  if (optional_status) {
-    auto status = *optional_status;
-    const aos::Node *my_node = MaybeMyNode(config);
-    std::vector<std::pair<aos::starter::Command, std::string_view>> commands;
+  std::map<const aos::Node *,
+           std::unique_ptr<aos::FlatbufferVector<aos::starter::Status>>>
+      statuses;
+
+  for (const aos::Node *node : InteractNodes(config)) {
+    std::optional<std::pair<aos::monotonic_clock::time_point,
+                            const aos::FlatbufferVector<aos::starter::Status>>>
+        optional_status = aos::starter::GetStarterStatus(config, node);
+    if (optional_status.has_value()) {
+      statuses[node] =
+          std::make_unique<aos::FlatbufferVector<aos::starter::Status>>(
+              optional_status.value().second);
+    } else {
+      if (node == nullptr) {
+        LOG(WARNING) << "Starter not running";
+      } else {
+        LOG(WARNING) << "Starter not running on node "
+                     << node->name()->string_view();
+      }
+    }
+  }
+
+  if (!statuses.empty()) {
+    std::vector<aos::starter::ApplicationCommand> commands;
 
     for (const aos::Application *application : *config->applications()) {
-      // Ignore any applications which aren't supposed to be started on this
-      // node.
-      if (!aos::configuration::ApplicationShouldStart(config, my_node,
-                                                      application)) {
+      const std::string_view application_name =
+          application->name()->string_view();
+      const std::vector<const aos::Node *> application_nodes =
+          InteractNodesForApplication(config, application_name);
+      // Ignore any applications which aren't supposed to be started.
+      if (application_nodes.empty()) {
         continue;
       }
 
-      const std::string_view application_name =
-          application->name()->string_view();
-      if (!application->autostart()) {
-        const aos::starter::ApplicationStatus *application_status =
-            aos::starter::FindApplicationStatus(status.message(),
-                                                application_name);
-        if (application_status->state() == aos::starter::State::STOPPED) {
-          std::cout << "Skipping " << application_name
-                    << " because it is STOPPED\n";
-          continue;
+      std::vector<const aos::Node *> running_nodes;
+      if (application->autostart()) {
+        running_nodes = application_nodes;
+      } else {
+        for (const aos::Node *node : application_nodes) {
+          const aos::starter::ApplicationStatus *application_status =
+              aos::starter::FindApplicationStatus(statuses[node]->message(),
+                                                  application_name);
+          if (application_status->state() == aos::starter::State::STOPPED) {
+            if (node == nullptr) {
+              std::cout << "Skipping " << application_name
+                        << " because it is STOPPED\n";
+            } else {
+              std::cout << "Skipping " << application_name << " on "
+                        << node->name()->string_view()
+                        << " because it is STOPPED\n";
+            }
+            continue;
+          } else {
+            running_nodes.push_back(node);
+          }
         }
       }
 
-      commands.emplace_back(command, application_name);
+      if (!running_nodes.empty()) {
+        commands.emplace_back(aos::starter::ApplicationCommand{
+            command, application_name, running_nodes});
+      }
     }
 
     // Restart each running process
@@ -163,7 +245,7 @@
       std::cout << failure_text << "all \n";
     }
   } else {
-    LOG(WARNING) << "Starter not running";
+    LOG(WARNING) << "None of the starters we care about are running.";
   }
 }
 
@@ -206,12 +288,16 @@
     InteractWithAll(config, command, success_text, failure_text);
     return false;
   }
-  if (!ValidApplication(config, application_name)) {
+
+  const std::vector<const aos::Node *> application_nodes =
+      InteractNodesForApplication(config, application_name);
+  if (application_nodes.empty()) {
     return false;
   }
 
   if (aos::starter::SendCommandBlocking(command, application_name, config,
-                                        chrono::seconds(5))) {
+                                        chrono::seconds(5),
+                                        application_nodes)) {
     std::cout << success_text << application_name << '\n';
   } else {
     std::cout << failure_text << application_name << '\n';
diff --git a/aos/starter/starter_rpc.fbs b/aos/starter/starter_rpc.fbs
index 21b7117..bbf0605 100644
--- a/aos/starter/starter_rpc.fbs
+++ b/aos/starter/starter_rpc.fbs
@@ -17,11 +17,15 @@
 }
 
 table StarterRpc {
-  command : Command (id: 0);
+  command:Command (id: 0);
 
   // The name of the application to send the command to. Command is ignored if
   // the given application does not exist.
-  name: string (id: 1);
+  name:string (id: 1);
+
+  // This set of nodes to start/stop the application on. If empty, indicates that applications
+  // should be restarted on all nodes.
+  nodes:[string] (id: 2);
 }
 
 root_type StarterRpc;
diff --git a/aos/starter/starter_rpc_lib.cc b/aos/starter/starter_rpc_lib.cc
index eb9a403..b0b9db3 100644
--- a/aos/starter/starter_rpc_lib.cc
+++ b/aos/starter/starter_rpc_lib.cc
@@ -2,10 +2,24 @@
 
 #include "aos/events/shm_event_loop.h"
 #include "aos/flatbuffer_merge.h"
+#include "aos/starter/starterd_lib.h"
 
 namespace aos {
 namespace starter {
 
+namespace {
+State ExpectedStateForCommand(Command command) {
+  switch (command) {
+    case Command::START:
+    case Command::RESTART:
+      return State::RUNNING;
+    case Command::STOP:
+      return State::STOPPED;
+  }
+  return State::STOPPED;
+}
+}  // namespace
+
 const aos::starter::ApplicationStatus *FindApplicationStatus(
     const aos::starter::Status &status, std::string_view name) {
   if (!status.has_statuses()) {
@@ -39,121 +53,203 @@
   return app_name;
 }
 
-bool SendCommandBlocking(aos::starter::Command command, std::string_view name,
-                         const aos::Configuration *config,
-                         std::chrono::milliseconds timeout) {
-  return SendCommandBlocking(
-      std::vector<std::pair<aos::starter::Command, std::string_view>>{
-          {command, name}},
-      config, timeout);
+StarterClient::StarterClient(EventLoop *event_loop)
+    : event_loop_(event_loop),
+      timeout_timer_(event_loop_->AddTimer([this]() { Timeout(); })),
+      cmd_sender_(event_loop_->MakeSender<StarterRpc>("/aos")) {
+  if (configuration::MultiNode(event_loop_->configuration())) {
+    for (const aos::Node *node :
+         configuration::GetNodes(event_loop_->configuration())) {
+      const Channel *channel =
+          StatusChannelForNode(event_loop_->configuration(), node);
+      CHECK(channel != nullptr) << ": Failed to find channel /aos for "
+                                << Status::GetFullyQualifiedName() << " on "
+                                << node->name()->string_view();
+      if (!configuration::ChannelIsReadableOnNode(channel,
+                                                  event_loop_->node())) {
+        VLOG(1) << "Status channel "
+                << configuration::StrippedChannelToString(channel)
+                << " is not readable on "
+                << event_loop_->node()->name()->string_view();
+      } else if (!configuration::ChannelIsReadableOnNode(
+                     StarterRpcChannelForNode(event_loop_->configuration(),
+                                              event_loop_->node()),
+                     node)) {
+        // Don't attempt to construct a status fetcher if the other node won't
+        // even be able to receive our commands.
+        VLOG(1) << "StarterRpc channel for "
+                << event_loop_->node()->name()->string_view()
+                << " is not readable on " << node->name()->string_view();
+      } else {
+        status_fetchers_[node->name()->str()] =
+            event_loop_->MakeFetcher<Status>(channel->name()->string_view());
+        event_loop_->MakeNoArgWatcher<Status>(channel->name()->string_view(),
+                                              [this]() {
+                                                if (CheckCommandsSucceeded()) {
+                                                  Succeed();
+                                                }
+                                              });
+      }
+    }
+  } else {
+    status_fetchers_[""] = event_loop_->MakeFetcher<Status>("/aos");
+    event_loop_->MakeNoArgWatcher<Status>("/aos", [this]() {
+      if (CheckCommandsSucceeded()) {
+        Succeed();
+      }
+    });
+  }
 }
 
-bool SendCommandBlocking(
-    std::vector<std::pair<aos::starter::Command, std::string_view>> commands,
-    const aos::Configuration *config, std::chrono::milliseconds timeout) {
+void StarterClient::SendCommands(
+    const std::vector<ApplicationCommand> &commands,
+    monotonic_clock::duration timeout) {
+  CHECK(current_commands_.empty());
+  for (auto &pair : status_fetchers_) {
+    pair.second.Fetch();
+  }
+  const bool is_multi_node =
+      aos::configuration::MultiNode(event_loop_->configuration());
+  for (const auto &command : commands) {
+    auto builder = cmd_sender_.MakeBuilder();
+    const auto application_offset =
+        builder.fbb()->CreateString(command.application);
+    std::vector<flatbuffers::Offset<flatbuffers::String>> node_offsets;
+    CHECK(!command.nodes.empty())
+        << "At least one node must be specified for application "
+        << command.application;
+    for (const aos::Node *node : command.nodes) {
+      const std::string node_name((node == nullptr) ? "" : node->name()->str());
+      if (status_fetchers_.count(node_name) == 0) {
+        if (is_multi_node) {
+          LOG(FATAL) << "Node \"" << node_name
+                     << "\" must be configured to both receive StarterRpc "
+                        "messages from \""
+                     << event_loop_->node()->name()->string_view()
+                     << "\" as well as to send starter Status messages back.";
+        } else {
+          LOG(FATAL) << "On single-node configs, use an empty string for the "
+                        "node name.";
+        }
+      }
+      CHECK(status_fetchers_[node_name].get() != nullptr)
+          << ": No status available for node " << node_name;
+      if (is_multi_node) {
+        node_offsets.push_back(builder.fbb()->CreateString(node_name));
+      }
+      const ApplicationStatus *last_status =
+          CHECK_NOTNULL(FindApplicationStatus(*status_fetchers_[node_name],
+                                              command.application));
+      current_commands_[node_name].push_back(CommandStatus{
+          .expected_state = ExpectedStateForCommand(command.command),
+          .application = std::string(command.application),
+          .old_id = std::nullopt});
+      // If we are restarting, then we need to track what the current ID of the
+      // process is to ensure that it actually got restarted. For just starting,
+      // we leave the application running and so don't care.
+      if (command.command == Command::RESTART && last_status->has_id()) {
+        current_commands_[node_name].back().old_id = last_status->id();
+      }
+    }
+    flatbuffers::Offset<
+        flatbuffers::Vector<flatbuffers::Offset<flatbuffers::String>>>
+        nodes_offset;
+    if (is_multi_node) {
+      nodes_offset = builder.fbb()->CreateVector(node_offsets);
+    }
+    auto command_builder = builder.MakeBuilder<StarterRpc>();
+    command_builder.add_command(command.command);
+    command_builder.add_name(application_offset);
+    if (is_multi_node) {
+      command_builder.add_nodes(nodes_offset);
+    }
+    CHECK(builder.Send(command_builder.Finish()));
+  }
+
+  timeout_timer_->Setup(event_loop_->monotonic_now() + timeout);
+}
+
+bool StarterClient::CheckCommandsSucceeded() {
+  if (current_commands_.empty()) {
+    return false;
+  }
+
+  for (auto &pair : status_fetchers_) {
+    pair.second.Fetch();
+  }
+
+  bool succeeded = true;
+
+  for (const auto &pair : current_commands_) {
+    if (pair.second.empty()) {
+      continue;
+    }
+    CHECK(status_fetchers_[pair.first].get() != nullptr)
+        << ": No status available for node " << pair.first;
+    const Status &status = *status_fetchers_[pair.first];
+    for (const auto &command : pair.second) {
+      const ApplicationStatus *application_status =
+          CHECK_NOTNULL(FindApplicationStatus(status, command.application));
+      if (application_status->state() == command.expected_state) {
+        if (command.expected_state == State::RUNNING &&
+            application_status->id() == command.old_id) {
+          succeeded = false;
+        }
+      } else {
+        succeeded = false;
+      }
+    }
+  }
+  return succeeded;
+}
+
+void StarterClient::Timeout() {
+  // Clear commands prior to calling handlers to allow the handler to call
+  // SendCommands() again if desired.
+  current_commands_.clear();
+  if (timeout_handler_) {
+    timeout_handler_();
+  }
+}
+
+void StarterClient::Succeed() {
+  // Clear commands prior to calling handlers to allow the handler to call
+  // SendCommands() again if desired.
+  current_commands_.clear();
+  if (success_handler_) {
+    success_handler_();
+  }
+  timeout_timer_->Disable();
+}
+
+bool SendCommandBlocking(aos::starter::Command command, std::string_view name,
+                         const aos::Configuration *config,
+                         std::chrono::milliseconds timeout,
+                         std::vector<const aos::Node *> nodes) {
+  return SendCommandBlocking({{command, name, nodes}}, config, timeout);
+}
+
+bool SendCommandBlocking(const std::vector<ApplicationCommand> &commands,
+                         const aos::Configuration *config,
+                         std::chrono::milliseconds timeout) {
   aos::ShmEventLoop event_loop(config);
   event_loop.SkipAosLog();
 
-  ::aos::Sender<aos::starter::StarterRpc> cmd_sender =
-      event_loop.MakeSender<aos::starter::StarterRpc>("/aos");
+  StarterClient client(&event_loop);
 
   // Wait until event loop starts to send all commands so the watcher is ready
-  event_loop.OnRun([&cmd_sender, &commands] {
-    for (const std::pair<aos::starter::Command, std::string_view>
-             &command_pair : commands) {
-      const aos::starter::Command command = command_pair.first;
-      const std::string_view name = command_pair.second;
-      aos::Sender<aos::starter::StarterRpc>::Builder builder =
-          cmd_sender.MakeBuilder();
-
-      auto name_str = builder.fbb()->CreateString(name);
-
-      aos::starter::StarterRpc::Builder cmd_builder =
-          builder.MakeBuilder<aos::starter::StarterRpc>();
-
-      cmd_builder.add_name(name_str);
-      cmd_builder.add_command(command);
-
-      builder.Send(cmd_builder.Finish());
-    }
+  event_loop.OnRun([&commands, &client, timeout]() {
+    client.SendCommands(commands, timeout);
   });
 
   // If still waiting after timeout milliseconds, exit the loop
-  event_loop.AddTimer([&event_loop] { event_loop.Exit(); })
-      ->Setup(event_loop.monotonic_now() + timeout);
+  client.SetTimeoutHandler([&event_loop]() { event_loop.Exit(); });
 
-  // Fetch the last list of statuses.  The id field changes every time the
-  // application restarts.  By detecting when the application is running with a
-  // different ID, we can detect restarts.
-  auto initial_status_fetcher =
-      event_loop.MakeFetcher<aos::starter::Status>("/aos");
-  initial_status_fetcher.Fetch();
-
-  std::vector<std::optional<uint64_t>> initial_ids;
-
-  for (const std::pair<aos::starter::Command, std::string_view> &command_pair :
-       commands) {
-    const std::string_view name = command_pair.second;
-    auto initial_status =
-        initial_status_fetcher.get()
-            ? FindApplicationStatus(*initial_status_fetcher, name)
-            : nullptr;
-
-    initial_ids.emplace_back(
-        (initial_status != nullptr && initial_status->has_id())
-            ? std::make_optional(initial_status->id())
-            : std::nullopt);
-  }
-
-  std::vector<bool> successes(commands.size(), false);
   bool success = false;
-  event_loop.MakeWatcher("/aos", [&event_loop, &commands, &initial_ids, &success,
-                                  &successes](
-                                     const aos::starter::Status &status) {
-    size_t index = 0;
-    for (const std::pair<aos::starter::Command, std::string_view>
-             &command_pair : commands) {
-      const aos::starter::Command command = command_pair.first;
-      const std::string_view name = command_pair.second;
 
-      const aos::starter::ApplicationStatus *app_status =
-          FindApplicationStatus(status, name);
-
-      const std::optional<aos::starter::State> state =
-          (app_status != nullptr && app_status->has_state())
-              ? std::make_optional(app_status->state())
-              : std::nullopt;
-
-      switch (command) {
-        case aos::starter::Command::START: {
-          if (state == aos::starter::State::RUNNING) {
-            successes[index] = true;
-          }
-          break;
-        }
-        case aos::starter::Command::STOP: {
-          if (state == aos::starter::State::STOPPED) {
-            successes[index] = true;
-          }
-          break;
-        }
-        case aos::starter::Command::RESTART: {
-          if (state == aos::starter::State::RUNNING && app_status->has_id() &&
-              app_status->id() != initial_ids[index]) {
-            successes[index] = true;
-          }
-          break;
-        }
-      }
-      ++index;
-    }
-
-    // Wait until all applications are ready.
-    if (std::count(successes.begin(), successes.end(), true) ==
-        static_cast<ssize_t>(successes.size())) {
-      event_loop.Exit();
-      success = true;
-    }
+  client.SetSuccessHandler([&event_loop, &success]() {
+    success = true;
+    event_loop.Exit();
   });
 
   event_loop.Run();
@@ -162,11 +258,12 @@
 }
 
 const FlatbufferDetachedBuffer<aos::starter::ApplicationStatus> GetStatus(
-    std::string_view name, const Configuration *config) {
+    std::string_view name, const Configuration *config, const aos::Node *node) {
   ShmEventLoop event_loop(config);
   event_loop.SkipAosLog();
 
-  auto status_fetcher = event_loop.MakeFetcher<aos::starter::Status>("/aos");
+  auto status_fetcher = event_loop.MakeFetcher<aos::starter::Status>(
+      StatusChannelForNode(config, node)->name()->string_view());
   status_fetcher.Fetch();
   auto status = status_fetcher.get()
                     ? FindApplicationStatus(*status_fetcher, name)
@@ -176,16 +273,20 @@
                       aos::starter::ApplicationStatus>::Empty();
 }
 
-std::optional<const aos::FlatbufferVector<aos::starter::Status>>
-GetStarterStatus(const aos::Configuration *config) {
+std::optional<std::pair<aos::monotonic_clock::time_point,
+                        const aos::FlatbufferVector<aos::starter::Status>>>
+GetStarterStatus(const aos::Configuration *config, const aos::Node *node) {
   ShmEventLoop event_loop(config);
   event_loop.SkipAosLog();
 
-  auto status_fetcher = event_loop.MakeFetcher<aos::starter::Status>("/aos");
+  auto status_fetcher = event_loop.MakeFetcher<aos::starter::Status>(
+      StatusChannelForNode(config, node)->name()->string_view());
   status_fetcher.Fetch();
-  return (status_fetcher.get()
-              ? std::make_optional(status_fetcher.CopyFlatBuffer())
-              : std::nullopt);
+  return (status_fetcher.get() == nullptr)
+             ? std::nullopt
+             : std::make_optional(std::make_pair(
+                   status_fetcher.context().monotonic_remote_time,
+                   status_fetcher.CopyFlatBuffer()));
 }
 
 }  // namespace starter
diff --git a/aos/starter/starter_rpc_lib.h b/aos/starter/starter_rpc_lib.h
index fdea0b7..7b93e24 100644
--- a/aos/starter/starter_rpc_lib.h
+++ b/aos/starter/starter_rpc_lib.h
@@ -2,15 +2,72 @@
 #define AOS_STARTER_STARTER_RPC_LIB_H_
 
 #include <chrono>
+#include <map>
 #include <optional>
+#include <vector>
 
 #include "aos/configuration.h"
+#include "aos/events/event_loop.h"
 #include "aos/starter/starter_generated.h"
 #include "aos/starter/starter_rpc_generated.h"
 
 namespace aos {
 namespace starter {
 
+// Data required to command that starter start/stop/restart a given application.
+struct ApplicationCommand {
+  Command command;
+  std::string_view application;
+  std::vector<const aos::Node *> nodes;
+};
+
+// This class manages interacting with starterd so that you can conveniently
+// start/stop applications programmatically.
+// Note that the StarterClient only maintains internal state for a single set of
+// commands at once, so once the user calls SendCommands() they must wait for
+// the timeout or success handler to be called before calling SendCommands
+// again.
+class StarterClient {
+ public:
+  StarterClient(EventLoop *event_loop);
+
+  void SendCommands(const std::vector<ApplicationCommand> &commands,
+                    monotonic_clock::duration timeout);
+
+  void SetTimeoutHandler(std::function<void()> handler) {
+    timeout_handler_ = handler;
+  }
+
+  void SetSuccessHandler(std::function<void()> handler) {
+    success_handler_ = handler;
+  }
+
+ private:
+  struct CommandStatus {
+    State expected_state;
+    std::string application;
+    std::optional<uint64_t> old_id;
+  };
+
+  bool CheckCommandsSucceeded();
+
+  void Timeout();
+
+  void Succeed();
+
+  EventLoop *event_loop_;
+  TimerHandler *timeout_timer_;
+  Sender<StarterRpc> cmd_sender_;
+  // Map of fetchers by node name.
+  std::map<std::string, Fetcher<Status>> status_fetchers_;
+
+  // Mapping of node name to a list of applications with pending commands.
+  std::map<std::string, std::vector<CommandStatus>> current_commands_;
+
+  std::function<void()> timeout_handler_;
+  std::function<void()> success_handler_;
+};
+
 // Finds the status of an individual application within a starter status message
 // Returns nullptr if no application found by the given name.
 const aos::starter::ApplicationStatus *FindApplicationStatus(
@@ -28,26 +85,35 @@
 // achieved within timeout.
 bool SendCommandBlocking(aos::starter::Command, std::string_view name,
                          const aos::Configuration *config,
-                         std::chrono::milliseconds timeout);
+                         std::chrono::milliseconds timeout,
+                         std::vector<const aos::Node *> nodes = {});
 
 // Sends lots of commands and waits for them all to succeed.  There must not be
 // more than 1 conflicting command in here which modifies the state of a single
 // application otherwise it will never succeed.  An example is having both a
 // start and stop command for a single application.
-bool SendCommandBlocking(
-    std::vector<std::pair<aos::starter::Command, std::string_view>> commands,
-    const aos::Configuration *config, std::chrono::milliseconds timeout);
+bool SendCommandBlocking(const std::vector<ApplicationCommand> &commands,
+                         const aos::Configuration *config,
+                         std::chrono::milliseconds timeout);
 
 // Fetches the status of the application with the given name. Creates a
 // temporary event loop from the provided config for fetching. Returns an empty
 // flatbuffer if the application is not found.
 const aos::FlatbufferDetachedBuffer<aos::starter::ApplicationStatus> GetStatus(
-    std::string_view name, const aos::Configuration *config);
+    std::string_view name, const aos::Configuration *config,
+    const aos::Node *node);
 
 // Fetches the entire status message of starter. Creates a temporary event loop
 // from the provided config for fetching.
-std::optional<const aos::FlatbufferVector<aos::starter::Status>>
-GetStarterStatus(const aos::Configuration *config);
+// The returned pair is the time at which the Status was sent on the node it was
+// sent from, to allow calculating uptimes on remote nodes.
+// TODO(james): Use the ServerStatistics message and return the monotonic offset
+// instead, so that we can correctly handle high message latencies. Because
+// people don't generally care about ultra-high-precision uptime calculations,
+// this hasn't been prioritized.
+std::optional<std::pair<aos::monotonic_clock::time_point,
+                        const aos::FlatbufferVector<aos::starter::Status>>>
+GetStarterStatus(const aos::Configuration *config, const aos::Node *node);
 
 }  // namespace starter
 }  // namespace aos
diff --git a/aos/starter/starter_test.cc b/aos/starter/starter_test.cc
index a6f9204..3a61fe7 100644
--- a/aos/starter/starter_test.cc
+++ b/aos/starter/starter_test.cc
@@ -1,9 +1,11 @@
 #include <csignal>
+#include <experimental/filesystem>
 #include <future>
 #include <thread>
 
 #include "aos/events/ping_generated.h"
 #include "aos/events/pong_generated.h"
+#include "aos/network/team_number.h"
 #include "aos/testing/path.h"
 #include "aos/testing/tmpdir.h"
 #include "gtest/gtest.h"
@@ -15,31 +17,57 @@
 namespace aos {
 namespace starter {
 
-TEST(StarterdTest, StartStopTest) {
-  const std::string config_file =
-      ArtifactPath("aos/events/pingpong_config.json");
+class StarterdTest : public ::testing::Test {
+ public:
+  StarterdTest() : shm_dir_(aos::testing::TestTmpDir() + "/aos") {
+    FLAGS_shm_base = shm_dir_;
+
+    // Nuke the shm dir:
+    std::experimental::filesystem::remove_all(shm_dir_);
+  }
+
+ protected:
+  gflags::FlagSaver flag_saver_;
+  std::string shm_dir_;
+};
+
+struct TestParams {
+  std::string config;
+  std::string hostname;
+};
+
+class StarterdConfigParamTest
+    : public StarterdTest,
+      public ::testing::WithParamInterface<TestParams> {};
+
+TEST_P(StarterdConfigParamTest, MultiNodeStartStopTest) {
+  gflags::FlagSaver flag_saver;
+  FLAGS_override_hostname = GetParam().hostname;
+  const std::string config_file = ArtifactPath(GetParam().config);
 
   aos::FlatbufferDetachedBuffer<aos::Configuration> config =
       aos::configuration::ReadConfig(config_file);
 
-  const std::string test_dir = aos::testing::TestTmpDir();
-
   auto new_config = aos::configuration::MergeWithConfig(
-      &config.message(), absl::StrFormat(
-                             R"({"applications": [
+      &config.message(),
+      absl::StrFormat(
+          R"({"applications": [
                                   {
                                     "name": "ping",
                                     "executable_name": "%s",
-                                    "args": ["--shm_base", "%s/aos"]
+                                    "nodes": ["pi1"],
+                                    "args": ["--shm_base", "%s", "--config", "%s", "--override_hostname", "%s"]
                                   },
                                   {
                                     "name": "pong",
                                     "executable_name": "%s",
-                                    "args": ["--shm_base", "%s/aos"]
+                                    "nodes": ["pi1"],
+                                    "args": ["--shm_base", "%s", "--config", "%s", "--override_hostname", "%s"]
                                   }
                                 ]})",
-                             ArtifactPath("aos/events/ping"), test_dir,
-                             ArtifactPath("aos/events/pong"), test_dir));
+          ArtifactPath("aos/events/ping"), shm_dir_, config_file,
+          GetParam().hostname, ArtifactPath("aos/events/pong"), shm_dir_,
+          config_file, GetParam().hostname));
 
   const aos::Configuration *config_msg = &new_config.message();
 
@@ -51,6 +79,17 @@
   aos::ShmEventLoop watcher_loop(config_msg);
   watcher_loop.SkipAosLog();
 
+  aos::ShmEventLoop client_loop(config_msg);
+  client_loop.SkipAosLog();
+  StarterClient client(&client_loop);
+  client.SetTimeoutHandler(
+      []() { FAIL() << ": Command should not have timed out."; });
+  bool success = false;
+  client.SetSuccessHandler([&success, &client_loop]() {
+    client_loop.Exit();
+    success = true;
+  });
+
   watcher_loop
       .AddTimer([&watcher_loop] {
         watcher_loop.Exit();
@@ -58,27 +97,25 @@
       })
       ->Setup(watcher_loop.monotonic_now() + std::chrono::seconds(7));
 
-  int test_stage = 0;
-  watcher_loop.MakeWatcher(
-      "/test", [&test_stage, config_msg](const aos::examples::Ping &) {
-        switch (test_stage) {
-          case 1: {
-            test_stage = 2;
-            break;
-          }
-          case 2: {
-            std::thread([config_msg] {
-              LOG(INFO) << "Send command";
-              ASSERT_TRUE(aos::starter::SendCommandBlocking(
-                  aos::starter::Command::STOP, "ping", config_msg,
-                  std::chrono::seconds(3)));
-            })
-                .detach();
-            test_stage = 3;
-            break;
-          }
+  std::atomic<int> test_stage = 0;
+  // Watch on the client loop since we need to interact with the StarterClient.
+  client_loop.MakeWatcher("/test", [&test_stage, &client,
+                                    &client_loop](const aos::examples::Ping &) {
+    switch (test_stage) {
+      case 1: {
+        test_stage = 2;
+        break;
+      }
+      case 2: {
+        {
+          client.SendCommands({{Command::STOP, "ping", {client_loop.node()}}},
+                              std::chrono::seconds(3));
         }
-      });
+        test_stage = 3;
+        break;
+      }
+    }
+  });
 
   watcher_loop.MakeWatcher(
       "/aos", [&test_stage, &watcher_loop](const aos::starter::Status &status) {
@@ -109,37 +146,43 @@
       });
 
   std::thread starterd_thread([&starter] { starter.Run(); });
+  std::thread client_thread([&client_loop] { client_loop.Run(); });
   watcher_loop.Run();
 
   starter.Cleanup();
+  client_thread.join();
   starterd_thread.join();
 }
 
-TEST(StarterdTest, DeathTest) {
+INSTANTIATE_TEST_SUITE_P(
+    StarterdConfigParamTest, StarterdConfigParamTest,
+    ::testing::Values(TestParams{"aos/events/pingpong_config.json", ""},
+                      TestParams{"aos/starter/multinode_pingpong_config.json",
+                                 "pi1"}));
+
+TEST_F(StarterdTest, DeathTest) {
   const std::string config_file =
       ArtifactPath("aos/events/pingpong_config.json");
 
   aos::FlatbufferDetachedBuffer<aos::Configuration> config =
       aos::configuration::ReadConfig(config_file);
 
-  const std::string test_dir = aos::testing::TestTmpDir();
-
   auto new_config = aos::configuration::MergeWithConfig(
       &config.message(), absl::StrFormat(
                              R"({"applications": [
                                   {
                                     "name": "ping",
                                     "executable_name": "%s",
-                                    "args": ["--shm_base", "%s/aos"]
+                                    "args": ["--shm_base", "%s"]
                                   },
                                   {
                                     "name": "pong",
                                     "executable_name": "%s",
-                                    "args": ["--shm_base", "%s/aos"]
+                                    "args": ["--shm_base", "%s"]
                                   }
                                 ]})",
-                             ArtifactPath("aos/events/ping"), test_dir,
-                             ArtifactPath("aos/events/pong"), test_dir));
+                             ArtifactPath("aos/events/ping"), shm_dir_,
+                             ArtifactPath("aos/events/pong"), shm_dir_));
 
   const aos::Configuration *config_msg = &new_config.message();
 
@@ -203,32 +246,30 @@
   starterd_thread.join();
 }
 
-TEST(StarterdTest, Autostart) {
+TEST_F(StarterdTest, Autostart) {
   const std::string config_file =
       ArtifactPath("aos/events/pingpong_config.json");
 
   aos::FlatbufferDetachedBuffer<aos::Configuration> config =
       aos::configuration::ReadConfig(config_file);
 
-  const std::string test_dir = aos::testing::TestTmpDir();
-
   auto new_config = aos::configuration::MergeWithConfig(
       &config.message(), absl::StrFormat(
                              R"({"applications": [
                                   {
                                     "name": "ping",
                                     "executable_name": "%s",
-                                    "args": ["--shm_base", "%s/aos"],
+                                    "args": ["--shm_base", "%s"],
                                     "autostart": false
                                   },
                                   {
                                     "name": "pong",
                                     "executable_name": "%s",
-                                    "args": ["--shm_base", "%s/aos"]
+                                    "args": ["--shm_base", "%s"]
                                   }
                                 ]})",
-                             ArtifactPath("aos/events/ping"), test_dir,
-                             ArtifactPath("aos/events/pong"), test_dir));
+                             ArtifactPath("aos/events/ping"), shm_dir_,
+                             ArtifactPath("aos/events/pong"), shm_dir_));
 
   const aos::Configuration *config_msg = &new_config.message();
 
diff --git a/aos/starter/starterd_lib.cc b/aos/starter/starterd_lib.cc
index 19051bb..bc83768 100644
--- a/aos/starter/starterd_lib.cc
+++ b/aos/starter/starterd_lib.cc
@@ -8,6 +8,8 @@
 #include <algorithm>
 #include <utility>
 
+#include "absl/strings/str_format.h"
+#include "aos/json_to_flatbuffer.h"
 #include "glog/logging.h"
 #include "glog/stl_logging.h"
 
@@ -406,6 +408,15 @@
 
 SignalListener::~SignalListener() { loop_->epoll()->DeleteFd(signalfd_.fd()); }
 
+const aos::Channel *StatusChannelForNode(const aos::Configuration *config,
+                                         const aos::Node *node) {
+  return configuration::GetChannel<Status>(config, "/aos", "", node);
+}
+const aos::Channel *StarterRpcChannelForNode(const aos::Configuration *config,
+                                             const aos::Node *node) {
+  return configuration::GetChannel<StarterRpc>(config, "/aos", "", node);
+}
+
 Starter::Starter(const aos::Configuration *event_loop_config)
     : config_msg_(event_loop_config),
       event_loop_(event_loop_config),
@@ -427,20 +438,30 @@
                          std::chrono::milliseconds(1000));
   });
 
-  event_loop_.MakeWatcher("/aos", [this](const aos::starter::StarterRpc &cmd) {
-    if (!cmd.has_command() || !cmd.has_name() || exiting_) {
-      return;
+  if (!aos::configuration::MultiNode(config_msg_)) {
+    event_loop_.MakeWatcher(
+        "/aos",
+        [this](const aos::starter::StarterRpc &cmd) { HandleStarterRpc(cmd); });
+  } else {
+    for (const aos::Node *node : aos::configuration::GetNodes(config_msg_)) {
+      const Channel *channel = StarterRpcChannelForNode(config_msg_, node);
+      CHECK(channel != nullptr) << ": Failed to find channel /aos for "
+                                << StarterRpc::GetFullyQualifiedName() << " on "
+                                << node->name()->string_view();
+      if (!aos::configuration::ChannelIsReadableOnNode(channel,
+                                                       event_loop_.node())) {
+        LOG(INFO) << "StarterRpc channel "
+                  << aos::configuration::StrippedChannelToString(channel)
+                  << " is not readable on "
+                  << event_loop_.node()->name()->string_view();
+      } else {
+        event_loop_.MakeWatcher(channel->name()->string_view(),
+                                [this](const aos::starter::StarterRpc &cmd) {
+                                  HandleStarterRpc(cmd);
+                                });
+      }
     }
-    LOG(INFO) << "Received command "
-              << aos::starter::EnumNameCommand(cmd.command()) << ' '
-              << cmd.name()->string_view();
-
-    auto search = applications_.find(cmd.name()->str());
-    if (search != applications_.end()) {
-      // If an applicatione exists by the given name, dispatch the command
-      search->second.HandleCommand(cmd.command());
-    }
-  });
+  }
 
   if (config_msg_->has_applications()) {
     const flatbuffers::Vector<flatbuffers::Offset<aos::Application>>
@@ -465,6 +486,34 @@
   }
 }
 
+void Starter::HandleStarterRpc(const StarterRpc &command) {
+  if (!command.has_command() || !command.has_name() || exiting_) {
+    return;
+  }
+
+  LOG(INFO) << "Received " << aos::FlatbufferToJson(&command);
+
+  if (command.has_nodes()) {
+    CHECK(aos::configuration::MultiNode(config_msg_));
+    bool relevant_to_this_node = false;
+    for (const flatbuffers::String *node : *command.nodes()) {
+      if (node->string_view() == event_loop_.node()->name()->string_view()) {
+        relevant_to_this_node = true;
+      }
+    }
+    if (!relevant_to_this_node) {
+      return;
+    }
+  }
+  // If not populated, restart regardless of node.
+
+  auto search = applications_.find(command.name()->str());
+  if (search != applications_.end()) {
+    // If an applicatione exists by the given name, dispatch the command
+    search->second.HandleCommand(command.command());
+  }
+}
+
 void Starter::MaybeSendStatus() {
   if (status_count_ < max_status_count_) {
     SendStatus();
diff --git a/aos/starter/starterd_lib.h b/aos/starter/starterd_lib.h
index b5888e0..1809326 100644
--- a/aos/starter/starterd_lib.h
+++ b/aos/starter/starterd_lib.h
@@ -159,6 +159,11 @@
   DISALLOW_COPY_AND_ASSIGN(SignalListener);
 };
 
+const aos::Channel *StatusChannelForNode(const aos::Configuration *config,
+                                         const aos::Node *node);
+const aos::Channel *StarterRpcChannelForNode(const aos::Configuration *config,
+                                             const aos::Node *node);
+
 class Starter {
  public:
   Starter(const aos::Configuration *event_loop_config);
@@ -180,6 +185,7 @@
       SIGSEGV, SIGPIPE, SIGTERM, SIGBUS, SIGXCPU};
 
   void OnSignal(signalfd_siginfo signal);
+  void HandleStarterRpc(const StarterRpc &command);
 
   // Sends the Status message if it wouldn't exceed the rate limit.
   void MaybeSendStatus();
diff --git a/y2020/control_loops/drivetrain/drivetrain_base.cc b/y2020/control_loops/drivetrain/drivetrain_base.cc
index 9cd7494..952c3dc 100644
--- a/y2020/control_loops/drivetrain/drivetrain_base.cc
+++ b/y2020/control_loops/drivetrain/drivetrain_base.cc
@@ -60,7 +60,7 @@
           .finished() /*imu_transform*/,
   };
 
-  if (::aos::network::GetTeamNumber() == constants::Values::kCompTeamNumber) {
+  if (::aos::network::GetTeamNumber() != constants::Values::kCodingRobotTeamNumber) {
     // TODO(james): Check X/Y axis
     // transformations.
     kDrivetrainConfig.imu_transform = (Eigen::Matrix<double, 3, 3>() << 1.0,
diff --git a/y2020/control_loops/python/finisher.py b/y2020/control_loops/python/finisher.py
index 7ccccd6..8c33cc3 100644
--- a/y2020/control_loops/python/finisher.py
+++ b/y2020/control_loops/python/finisher.py
@@ -20,7 +20,7 @@
 G = 44.0 / 40.0
 # Overall flywheel inertia.
 J = 0.00507464
-J = 0.008
+J = 0.0035
 
 def AddResistance(motor, resistance):
     motor.resistance += resistance
@@ -40,7 +40,7 @@
     q_vel=10.0,
     q_voltage=4.0,
     r_pos=0.01,
-    controller_poles=[.89])
+    controller_poles=[.93])
 
 
 def main(argv):
@@ -58,4 +58,5 @@
 
 if __name__ == '__main__':
     argv = FLAGS(sys.argv)
+    glog.init()
     sys.exit(main(argv))
diff --git a/y2020/control_loops/python/flywheel.py b/y2020/control_loops/python/flywheel.py
index caaee96..6f41a17 100755
--- a/y2020/control_loops/python/flywheel.py
+++ b/y2020/control_loops/python/flywheel.py
@@ -70,6 +70,10 @@
 
         self.Kff = controls.TwoStateFeedForwards(self.B, self.Qff)
 
+        glog.debug('K: %s', str(self.K))
+        glog.debug('Poles: %s',
+                   str(numpy.linalg.eig(self.A - self.B * self.K)[0]))
+
 
 class Flywheel(VelocityFlywheel):
     def __init__(self, params, name="Flywheel"):
diff --git a/y2020/control_loops/superstructure/shooter/shooter.cc b/y2020/control_loops/superstructure/shooter/shooter.cc
index 99854fe..9be6273 100644
--- a/y2020/control_loops/superstructure/shooter/shooter.cc
+++ b/y2020/control_loops/superstructure/shooter/shooter.cc
@@ -116,6 +116,7 @@
       // come back up close to the last local maximum or is greater than it, the
       // ball has been shot.
       balls_shot_++;
+      VLOG(1) << "Shot ball at " << position_timestamp;
       ball_in_finisher_ = false;
     } else if (!ball_in_finisher_ &&
                (finisher_goal() > kVelocityToleranceFinisher)) {
diff --git a/y2020/control_loops/superstructure/superstructure.cc b/y2020/control_loops/superstructure/superstructure.cc
index 4fb5c1d..b3c36f0 100644
--- a/y2020/control_loops/superstructure/superstructure.cc
+++ b/y2020/control_loops/superstructure/superstructure.cc
@@ -245,7 +245,8 @@
         // the climber on.
         CHECK(unsafe_goal->has_turret());
         if (std::abs(unsafe_goal->turret()->unsafe_goal() -
-                     turret_.position()) > 0.1) {
+                     turret_.position()) > 0.1 &&
+            has_turret_) {
           output_struct.climber_voltage = 0;
         }
       }
diff --git a/y2020/control_loops/superstructure/superstructure_lib_test.cc b/y2020/control_loops/superstructure/superstructure_lib_test.cc
index c154c3d..0e51b7b 100644
--- a/y2020/control_loops/superstructure/superstructure_lib_test.cc
+++ b/y2020/control_loops/superstructure/superstructure_lib_test.cc
@@ -1087,18 +1087,21 @@
           ball_in_finisher_ = false;
           finisher_velocity_below_goal = false;
           balls_shot++;
+          LOG(INFO) << "Shot a ball at " << test_event_loop_->monotonic_now();
         }
         // Since here we are calculating the dip from the goal instead of the
         // local maximum, the shooter could have calculated that a ball was shot
         // slightly before we did if the local maximum was slightly below the
         // goal.
-        EXPECT_TRUE((superstructure_status_fetcher_->shooter()->balls_shot() ==
-                     balls_shot) ||
-                    ((superstructure_status_fetcher_->shooter()->balls_shot() ==
-                      balls_shot + 1) &&
-                     (finisher_velocity_dip -
-                          shooter::Shooter::kVelocityToleranceFinisher <
-                      0.2)));
+        if (superstructure_status_fetcher_->shooter()->balls_shot() !=
+            balls_shot) {
+          EXPECT_EQ(superstructure_status_fetcher_->shooter()->balls_shot(),
+                    balls_shot + 1)
+              << ": Failed at " << test_event_loop_->monotonic_now();
+          EXPECT_LT(finisher_velocity_dip,
+                    0.2 + shooter::Shooter::kVelocityToleranceFinisher)
+              << ": Failed at " << test_event_loop_->monotonic_now();
+        }
       },
       dt());
 
@@ -1108,8 +1111,8 @@
   // Maximum (since it is negative) flywheel voltage offsets for simulating the
   // friction of a ball at different finisher speeds.
   // Slower speeds require a higher magnitude of voltage offset.
-  static constexpr double kFastSpeedVoltageOffsetWithBall = -4.1;
-  static constexpr double kSlowSpeedVoltageOffsetWithBall = -4.5;
+  static constexpr double kFastSpeedVoltageOffsetWithBall = -3.1;
+  static constexpr double kSlowSpeedVoltageOffsetWithBall = -3.5;
 
   SetFinisherGoalAfter(kFastShootingSpeed, chrono::seconds(1));
   // Simulate shooting balls by applying friction to the finisher
@@ -1131,7 +1134,7 @@
   ApplyFrictionToFinisherAfter(kSlowSpeedVoltageOffsetWithBall, true,
                                chrono::seconds(31));
   // This smaller decrease in velocity shouldn't be counted as a ball
-  ApplyFrictionToFinisherAfter(kSlowSpeedVoltageOffsetWithBall / 2, false,
+  ApplyFrictionToFinisherAfter(kSlowSpeedVoltageOffsetWithBall / 10.0, false,
                                chrono::seconds(34));
 
   SetFinisherGoalAfter(kFastShootingSpeed, chrono::seconds(38));
@@ -1142,7 +1145,7 @@
   // This slow positive voltage offset that speeds up the flywheel instead of
   // slowing it down shouldn't be counted as a ball.
   // We wouldn't expect a positive voltage offset of more than ~2 volts.
-  ApplyFrictionToFinisherAfter(2, false, chrono::seconds(47));
+  ApplyFrictionToFinisherAfter(1, false, chrono::seconds(47));
 
   RunFor(chrono::seconds(50));
 
diff --git a/y2020/joystick_reader.cc b/y2020/joystick_reader.cc
index b101337..dd98e63 100644
--- a/y2020/joystick_reader.cc
+++ b/y2020/joystick_reader.cc
@@ -76,10 +76,6 @@
         setpoint_fetcher_(event_loop->MakeFetcher<y2020::joysticks::Setpoint>(
             "/superstructure")) {}
 
-  void AutoEnded() override {
-    AOS_LOG(INFO, "Auto ended, assuming disc and have piece\n");
-  }
-
   void BlueResetLocalizer() {
     auto builder = localizer_control_sender_.MakeBuilder();
 
@@ -187,7 +183,7 @@
         finisher_speed = setpoint_fetcher_->finisher();
       }
     } else if (data.IsPressed(kShootSlow)) {
-      accelerator_speed = 150.0;
+      accelerator_speed = 400.0;
       finisher_speed = 200.0;
     }
 
diff --git a/y2020/wpilib_interface.cc b/y2020/wpilib_interface.cc
index 67afb1d..1b732bc 100644
--- a/y2020/wpilib_interface.cc
+++ b/y2020/wpilib_interface.cc
@@ -625,7 +625,7 @@
     std::unique_ptr<frc971::wpilib::ADIS16448> old_imu;
     std::unique_ptr<frc971::wpilib::ADIS16470> new_imu;
     std::unique_ptr<frc::SPI> imu_spi;
-    if (::aos::network::GetTeamNumber() == constants::Values::kCompTeamNumber) {
+    if (::aos::network::GetTeamNumber() != constants::Values::kCodingRobotTeamNumber) {
       old_imu = make_unique<frc971::wpilib::ADIS16448>(
           &imu_event_loop, spi_port, imu_trigger.get());
       old_imu->SetDummySPI(frc::SPI::Port::kOnboardCS2);
diff --git a/y2020/www/field.html b/y2020/www/field.html
index e37d810..d3f5810 100644
--- a/y2020/www/field.html
+++ b/y2020/www/field.html
@@ -68,6 +68,10 @@
           <td>Right Accelerator</td>
           <td id="right_accelerator"> NA </td>
         </tr>
+        <tr>
+          <td>Balls shot </td>
+          <td id="balls_shot"> NA </td>
+        </tr>
       </table>
 
       <table>
diff --git a/y2020/www/field_handler.ts b/y2020/www/field_handler.ts
index eb5ece5..c1170c4 100644
--- a/y2020/www/field_handler.ts
+++ b/y2020/www/field_handler.ts
@@ -77,9 +77,9 @@
   // Image information indexed by timestamp (seconds since the epoch), so that
   // we can stop displaying images after a certain amount of time.
   private localizerImageMatches = new Map<number, LocalizerDebug>();
-  private outer_target: HTMLElement =
+  private outerTarget: HTMLElement =
       (document.getElementById('outer_target') as HTMLElement);
-  private inner_target: HTMLElement =
+  private innerTarget: HTMLElement =
       (document.getElementById('inner_target') as HTMLElement);
   private x: HTMLElement = (document.getElementById('x') as HTMLElement);
   private y: HTMLElement = (document.getElementById('y') as HTMLElement);
@@ -98,6 +98,8 @@
   private hood: HTMLElement = (document.getElementById('hood') as HTMLElement);
   private turret: HTMLElement =
       (document.getElementById('turret') as HTMLElement);
+  private ballsShot: HTMLElement =
+      (document.getElementById('balls_shot') as HTMLElement);
   private intake: HTMLElement =
       (document.getElementById('intake') as HTMLElement);
   private imagesAcceptedCounter: HTMLElement =
@@ -499,12 +501,12 @@
 
         if (this.superstructureStatus.aimer().aimingForInnerPort()) {
           this.innerPort.innerHTML = 'true';
-          this.outer_target.classList.remove('targetted');
-          this.inner_target.classList.add('targetted');
+          this.outerTarget.classList.remove('targetted');
+          this.innerTarget.classList.add('targetted');
         } else {
           this.innerPort.innerHTML = 'false';
-          this.outer_target.classList.add('targetted');
-          this.inner_target.classList.remove('targetted');
+          this.outerTarget.classList.add('targetted');
+          this.innerTarget.classList.remove('targetted');
         }
 
         if (!this.superstructureStatus.hood().zeroed()) {
@@ -519,6 +521,9 @@
               1e-3);
         }
 
+        this.ballsShot.innerHTML =
+            this.superstructureStatus.shooter().ballsShot().toString();
+
         if (!this.superstructureStatus.turret().zeroed()) {
           this.setZeroing(this.turret);
         } else if (this.superstructureStatus.turret().estopped()) {
diff --git a/y2020/y2020_logger.json b/y2020/y2020_logger.json
index 0281711..a068b10 100644
--- a/y2020/y2020_logger.json
+++ b/y2020/y2020_logger.json
@@ -1,6 +1,40 @@
 {
   "channels": [
     {
+      "name": "/roborio/aos",
+      "type": "aos.message_bridge.Timestamp",
+      "source_node": "roborio",
+      "logger": "LOCAL_AND_REMOTE_LOGGER",
+      "logger_nodes": [
+        "logger"
+      ],
+      "destination_nodes": [
+        {
+          "name": "logger",
+          "priority": 1,
+          "time_to_live": 5000000,
+          "timestamp_logger" : "LOCAL_AND_REMOTE_LOGGER",
+          "timestamp_logger_nodes" : ["roborio"]
+        }
+      ]
+   },
+    {
+      "name": "/drivetrain",
+      "type": "frc971.IMUValuesBatch",
+      "source_node": "roborio",
+      "logger": "LOCAL_AND_REMOTE_LOGGER",
+      "logger_nodes": [
+        "logger"
+      ],
+      "destination_nodes": [
+        {
+          "name": "logger",
+          "priority": 2,
+          "time_to_live": 50000000
+        }
+      ]
+    },
+    {
       "name": "/pi1/aos",
       "type": "aos.message_bridge.Timestamp",
       "source_node": "pi1",
@@ -176,10 +210,28 @@
           "timestamp_logger_nodes": [
             "logger"
           ]
+        },
+        {
+          "name": "roborio",
+          "priority": 1,
+          "time_to_live": 5000000,
+          "timestamp_logger": "LOCAL_AND_REMOTE_LOGGER",
+          "timestamp_logger_nodes": [
+            "logger"
+          ]
         }
       ]
     },
     {
+      "name": "/logger/aos/remote_timestamps/roborio/logger/aos/aos-message_bridge-Timestamp",
+      "type": "aos.message_bridge.RemoteMessage",
+      "source_node": "logger",
+      "logger": "NOT_LOGGED",
+      "frequency": 20,
+      "num_senders": 2,
+      "max_size": 200
+    },
+    {
       "name": "/logger/aos/remote_timestamps/pi1/logger/aos/aos-message_bridge-Timestamp",
       "type": "aos.message_bridge.RemoteMessage",
       "source_node": "logger",
@@ -235,7 +287,7 @@
       "destination_nodes": [
         {
           "name": "logger",
-          "priority": 1,
+          "priority": 3,
           "time_to_live": 5000000
         }
       ]
@@ -267,7 +319,7 @@
       "destination_nodes": [
         {
           "name": "logger",
-          "priority": 1,
+          "priority": 3,
           "time_to_live": 5000000
         }
       ]
@@ -299,7 +351,7 @@
       "destination_nodes": [
         {
           "name": "logger",
-          "priority": 1,
+          "priority": 3,
           "time_to_live": 5000000
         }
       ]
@@ -331,7 +383,7 @@
       "destination_nodes": [
         {
           "name": "logger",
-          "priority": 1,
+          "priority": 3,
           "time_to_live": 5000000
         }
       ]
@@ -363,7 +415,7 @@
       "destination_nodes": [
         {
           "name": "logger",
-          "priority": 1,
+          "priority": 3,
           "time_to_live": 5000000
         }
       ]
@@ -441,6 +493,9 @@
       "name": "pi3"
     },
     {
+      "name": "roborio"
+    },
+    {
       "name": "pi4"
     },
     {
diff --git a/y2020/y2020_roborio.json b/y2020/y2020_roborio.json
index 5417302..9a4842e 100644
--- a/y2020/y2020_roborio.json
+++ b/y2020/y2020_roborio.json
@@ -239,7 +239,8 @@
       "type": "frc971.control_loops.drivetrain.fb.Trajectory",
       "source_node": "roborio",
       "max_size": 600000,
-      "frequency": 4,
+      "frequency": 10,
+      "logger": "NOT_LOGGED",
       "num_senders": 2,
       "read_method": "PIN",
       "num_readers": 10