Move generic packages from frc971/analysis to aos

To eliminate a dependency of aos on frc971.

Signed-off-by: Stephan Pleines <pleines.stephan@gmail.com>
Change-Id: Ic4a8f75da29f8e8c6675d96f02824cd08a9b99be
diff --git a/aos/analysis/BUILD b/aos/analysis/BUILD
new file mode 100644
index 0000000..63f2d57
--- /dev/null
+++ b/aos/analysis/BUILD
@@ -0,0 +1,108 @@
+load("//aos/flatbuffers:generate.bzl", "static_flatbuffer")
+load("//tools/build_rules:js.bzl", "ts_project")
+load("@com_github_google_flatbuffers//:typescript.bzl", "flatbuffer_ts_library")
+load("//aos:config.bzl", "aos_config")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_binary(
+    name = "py_log_reader.so",
+    srcs = ["py_log_reader.cc"],
+    linkshared = True,
+    target_compatible_with = ["@platforms//os:linux"],
+    deps = [
+        "//aos:configuration",
+        "//aos:json_to_flatbuffer",
+        "//aos/events:shm_event_loop",
+        "//aos/events:simulated_event_loop",
+        "//aos/events/logging:log_reader",
+        "//third_party/python",
+        "@com_github_google_glog//:glog",
+    ],
+)
+
+py_test(
+    name = "log_reader_test",
+    srcs = ["log_reader_test.py"],
+    data = [
+        ":py_log_reader.so",
+        "@sample_logfile//file",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+    deps = ["//aos:configuration_fbs_python"],
+)
+
+static_flatbuffer(
+    name = "plot_data_fbs",
+    srcs = [
+        "plot_data.fbs",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+)
+
+flatbuffer_ts_library(
+    name = "plot_data_ts_fbs",
+    srcs = [
+        "plot_data.fbs",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+)
+
+ts_project(
+    name = "plot_data_utils",
+    srcs = ["plot_data_utils.ts"],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":plot_data_ts_fbs",
+        "//aos:configuration_ts_fbs",
+        "//aos/network/www:aos_plotter",
+        "//aos/network/www:plotter",
+        "//aos/network/www:proxy",
+        "@com_github_google_flatbuffers//reflection:reflection_ts_fbs",
+        "@com_github_google_flatbuffers//ts:flatbuffers_ts",
+    ],
+)
+
+aos_config(
+    name = "plotter",
+    src = "plotter_config.json",
+    flatbuffers = [":plot_data_fbs"],
+    target_compatible_with = ["@platforms//os:linux"],
+    deps = ["//aos/events:aos_config"],
+)
+
+cc_library(
+    name = "in_process_plotter",
+    srcs = ["in_process_plotter.cc"],
+    hdrs = ["in_process_plotter.h"],
+    data = [
+        ":plotter",
+        "//aos/analysis/cpp_plot:cpp_plot_files",
+    ],
+    deps = [
+        ":plot_data_fbs",
+        "//aos/events:simulated_event_loop",
+        "//aos/network:web_proxy",
+    ],
+)
+
+cc_binary(
+    name = "in_process_plotter_demo",
+    srcs = ["in_process_plotter_demo.cc"],
+    deps = [
+        ":in_process_plotter",
+        "//aos:init",
+    ],
+)
+
+cc_binary(
+    name = "local_foxglove",
+    srcs = ["local_foxglove.cc"],
+    data = ["@foxglove_studio"],
+    deps = [
+        "//aos:init",
+        "//aos/network:gen_embedded",
+        "//aos/seasocks:seasocks_logger",
+        "//third_party/seasocks",
+    ],
+)
diff --git a/aos/analysis/cpp_plot/BUILD b/aos/analysis/cpp_plot/BUILD
new file mode 100644
index 0000000..7286aaf
--- /dev/null
+++ b/aos/analysis/cpp_plot/BUILD
@@ -0,0 +1,42 @@
+load("//tools/build_rules:js.bzl", "rollup_bundle", "ts_project")
+
+package(default_visibility = ["//visibility:public"])
+
+ts_project(
+    name = "cpp_plot",
+    srcs = ["cpp_plot.ts"],
+    target_compatible_with = ["@platforms//os:linux"],
+    deps = [
+        "//aos:configuration_ts_fbs",
+        "//aos/analysis:plot_data_utils",
+        "//aos/network/www:proxy",
+    ],
+)
+
+rollup_bundle(
+    name = "cpp_plot_bundle",
+    entry_point = "cpp_plot.ts",
+    target_compatible_with = ["@platforms//os:linux"],
+    deps = [
+        ":cpp_plot",
+    ],
+)
+
+genrule(
+    name = "copy_css",
+    srcs = [
+        "//aos/network/www:styles.css",
+    ],
+    outs = ["styles.css"],
+    cmd = "cp $< $@",
+)
+
+filegroup(
+    name = "cpp_plot_files",
+    srcs = [
+        "cpp_plot_bundle.js",
+        "cpp_plot_bundle.min.js",
+        "index.html",
+        "styles.css",
+    ],
+)
diff --git a/aos/analysis/cpp_plot/cpp_plot.ts b/aos/analysis/cpp_plot/cpp_plot.ts
new file mode 100644
index 0000000..08e2b1c
--- /dev/null
+++ b/aos/analysis/cpp_plot/cpp_plot.ts
@@ -0,0 +1,15 @@
+// Plotter for the C++ in-process plotter.
+import {Configuration} from '../../../aos/configuration_generated';
+import {Connection} from '../../../aos/network/www/proxy';
+import {plotData} from '../plot_data_utils';
+
+const rootDiv = document.createElement('div');
+rootDiv.classList.add('aos_cpp_plot');
+document.body.appendChild(rootDiv);
+
+const conn = new Connection();
+conn.connect();
+
+conn.addConfigHandler((config: Configuration) => {
+  plotData(conn, rootDiv);
+});
diff --git a/aos/analysis/cpp_plot/index.html b/aos/analysis/cpp_plot/index.html
new file mode 100644
index 0000000..fbb5199
--- /dev/null
+++ b/aos/analysis/cpp_plot/index.html
@@ -0,0 +1,9 @@
+<html>
+  <head>
+    <script src="cpp_plot_bundle.min.js" defer></script>
+    <link rel="stylesheet" href="styles.css">
+  </head>
+  <body>
+  </body>
+</html>
+
diff --git a/aos/analysis/in_process_plotter.cc b/aos/analysis/in_process_plotter.cc
new file mode 100644
index 0000000..a2ed68c
--- /dev/null
+++ b/aos/analysis/in_process_plotter.cc
@@ -0,0 +1,170 @@
+#include "aos/analysis/in_process_plotter.h"
+
+#include "aos/configuration.h"
+
+namespace frc971::analysis {
+
+namespace {
+const char *kDataPath = "frc971/analysis/cpp_plot";
+const char *kConfigPath = "frc971/analysis/plotter.json";
+}  // namespace
+
+Plotter::Plotter()
+    : config_(aos::configuration::ReadConfig(kConfigPath)),
+      event_loop_factory_(&config_.message()),
+      event_loop_(event_loop_factory_.MakeEventLoop("plotter")),
+      plot_sender_(event_loop_->MakeSender<Plot>("/analysis")),
+      web_proxy_(event_loop_.get(), event_loop_factory_.scheduler_epoll(),
+                 aos::web_proxy::StoreHistory::kYes, -1),
+      builder_(plot_sender_.MakeBuilder()) {
+  web_proxy_.SetDataPath(kDataPath);
+  event_loop_->SkipTimingReport();
+
+  color_wheel_.emplace_back(ColorWheelColor{.name = "red", .color = {1, 0, 0}});
+  color_wheel_.emplace_back(
+      ColorWheelColor{.name = "green", .color = {0, 1, 0}});
+  color_wheel_.emplace_back(
+      ColorWheelColor{.name = "purple", .color = {0.54, 0.3, 0.75}});
+  color_wheel_.emplace_back(
+      ColorWheelColor{.name = "blue", .color = {0, 0, 1}});
+  color_wheel_.emplace_back(
+      ColorWheelColor{.name = "yellow", .color = {1, 1, 0}});
+  color_wheel_.emplace_back(
+      ColorWheelColor{.name = "teal", .color = {0, 1, 1}});
+  color_wheel_.emplace_back(
+      ColorWheelColor{.name = "pink", .color = {1, 0, 1}});
+  color_wheel_.emplace_back(
+      ColorWheelColor{.name = "orange", .color = {1, 0.6, 0}});
+  color_wheel_.emplace_back(
+      ColorWheelColor{.name = "brown", .color = {0.6, 0.3, 0}});
+  color_wheel_.emplace_back(
+      ColorWheelColor{.name = "white", .color = {1, 1, 1}});
+}
+
+void Plotter::Spin() {
+  // Set non-infinite replay rate to avoid pegging a full CPU.
+  event_loop_factory_.SetRealtimeReplayRate(1.0);
+  event_loop_factory_.Run();
+}
+
+void Plotter::Title(std::string_view title) {
+  title_ = builder_.fbb()->CreateString(title);
+}
+
+void Plotter::AddFigure(std::string_view title, double width, double height) {
+  MaybeFinishFigure();
+
+  if (!title.empty()) {
+    figure_title_ = builder_.fbb()->CreateString(title);
+  }
+
+  // For positioning, just stack figures vertically.
+  auto position_builder = builder_.MakeBuilder<Position>();
+  position_builder.add_top(next_top_);
+  position_builder.add_left(0);
+  position_builder.add_width(width);
+  position_builder.add_height(height);
+  position_ = position_builder.Finish();
+
+  next_top_ += height;
+}
+
+void Plotter::XLabel(std::string_view label) {
+  xlabel_ = builder_.fbb()->CreateString(label);
+}
+
+void Plotter::YLabel(std::string_view label) {
+  ylabel_ = builder_.fbb()->CreateString(label);
+}
+
+void Plotter::AddLine(const std::vector<double> &x,
+                      const std::vector<double> &y, LineOptions options) {
+  CHECK_EQ(x.size(), y.size()) << ": " << options.label;
+  CHECK(!position_.IsNull())
+      << "You must call AddFigure() before calling AddLine().";
+
+  flatbuffers::Offset<flatbuffers::String> label_offset;
+  if (!options.label.empty()) {
+    label_offset = builder_.fbb()->CreateString(options.label);
+  }
+
+  std::vector<Point> points;
+  for (size_t ii = 0; ii < x.size(); ++ii) {
+    points.emplace_back(x[ii], y[ii]);
+  }
+  const flatbuffers::Offset<flatbuffers::Vector<const Point *>> points_offset =
+      builder_.fbb()->CreateVectorOfStructs(points);
+
+  const Color *color;
+  if (options.color.empty()) {
+    color = &color_wheel_.at(color_wheel_position_).color;
+    color_wheel_position_ = (color_wheel_position_ + 1) % color_wheel_.size();
+  } else {
+    auto it = std::find_if(
+        color_wheel_.begin(), color_wheel_.end(),
+        [options_color = options.color](const ColorWheelColor &color) {
+          return color.name == options_color;
+        });
+    CHECK(it != color_wheel_.end()) << ": Failed to find " << options.color;
+    color = &(it->color);
+  }
+
+  LineStyle::Builder style_builder = builder_.MakeBuilder<LineStyle>();
+  if (options.line_style.find('*') != options.line_style.npos) {
+    style_builder.add_point_size(options.point_size);
+  } else {
+    style_builder.add_point_size(0.0);
+  }
+  style_builder.add_draw_line(options.line_style.find('-') !=
+                              options.line_style.npos);
+  const flatbuffers::Offset<LineStyle> style_offset = style_builder.Finish();
+
+  auto line_builder = builder_.MakeBuilder<Line>();
+  line_builder.add_label(label_offset);
+  line_builder.add_points(points_offset);
+  line_builder.add_color(color);
+  line_builder.add_style(style_offset);
+  lines_.push_back(line_builder.Finish());
+}
+
+void Plotter::MaybeFinishFigure() {
+  if (!lines_.empty()) {
+    const flatbuffers::Offset<flatbuffers::Vector<flatbuffers::Offset<Line>>>
+        lines_offset = builder_.fbb()->CreateVector(lines_);
+    auto figure_builder = builder_.MakeBuilder<Figure>();
+    figure_builder.add_title(figure_title_);
+    figure_builder.add_position(position_);
+    figure_builder.add_lines(lines_offset);
+    figure_builder.add_xlabel(xlabel_);
+    figure_builder.add_share_x_axis(share_x_axis_);
+    figure_builder.add_ylabel(ylabel_);
+    figures_.push_back(figure_builder.Finish());
+  }
+  lines_.clear();
+  figure_title_.o = 0;
+  xlabel_.o = 0;
+  ylabel_.o = 0;
+  position_.o = 0;
+  share_x_axis_ = false;
+  color_wheel_position_ = 0;
+}
+
+void Plotter::Publish() {
+  MaybeFinishFigure();
+  const flatbuffers::Offset<flatbuffers::Vector<flatbuffers::Offset<Figure>>>
+      figures_offset = builder_.fbb()->CreateVector(figures_);
+
+  auto plot_builder = builder_.MakeBuilder<Plot>();
+  plot_builder.add_title(title_);
+  plot_builder.add_figures(figures_offset);
+
+  CHECK_EQ(builder_.Send(plot_builder.Finish()), aos::RawSender::Error::kOk);
+
+  builder_ = plot_sender_.MakeBuilder();
+
+  title_.o = 0;
+  figures_.clear();
+  next_top_ = 0;
+}
+
+}  // namespace frc971::analysis
diff --git a/aos/analysis/in_process_plotter.h b/aos/analysis/in_process_plotter.h
new file mode 100644
index 0000000..fdfde8c
--- /dev/null
+++ b/aos/analysis/in_process_plotter.h
@@ -0,0 +1,99 @@
+#ifndef FRC971_ANALYSIS_IN_PROCESS_PLOTTER_H_
+#define FRC971_ANALYSIS_IN_PROCESS_PLOTTER_H_
+
+#include <vector>
+
+#include "aos/analysis/plot_data_generated.h"
+#include "aos/events/simulated_event_loop.h"
+#include "aos/network/web_proxy.h"
+
+namespace frc971::analysis {
+
+// This class wraps the WebProxy class to provide a convenient C++ interface to
+// dynamically generate plots.
+// Currently, the main useful interface that this provides is a matplotlib-like
+// interface--see in_process_plotter_demo.cc for sample usage. It doesn't
+// precisely follow matplotlib's conventions, but the basic style does mimic
+// matplotlib. Future iterations may ditch this in favor of a more modern
+// interface where we actually return handles for plots and lines and the such.
+//
+// Note that currently the port for the seb server is hard-coded to 8080, so
+// only one instance of the Plotter can be present at once.
+//
+// You must call Spin() for the web server to actually do anything helpful.
+class Plotter {
+ public:
+  Plotter();
+
+  // matplotlib-like interface
+  // The basic pattern is:
+  // 1) Call Figure()
+  // 2) Set up the lines, labels, etc. for the figure.
+  // 3) Repeat 1-2 however many times.
+  // 4) Call Publish().
+  // 5) Repeat 1-5 however many times.
+  //
+  // Publish() actually pushes the figures that you setup to the web-page,
+  // either with an autogenerated title or the title set by Title(). All state
+  // is cleared (or should be cleared) by the call to Publish().
+
+  // Sets the title for the current set of plots; if you
+  void Title(std::string_view title);
+  void AddFigure(std::string_view title = "", double width = 0,
+                 double height = 0);
+  struct LineOptions {
+    std::string_view label = "";
+    std::string_view line_style = "*-";
+    std::string_view color = "";
+    double point_size = 3.0;
+  };
+
+  void AddLine(const std::vector<double> &x, const std::vector<double> &y,
+               std::string_view label) {
+    AddLine(x, y, LineOptions{.label = label});
+  }
+  void AddLine(const std::vector<double> &x, const std::vector<double> &y,
+               std::string_view label, std::string_view line_style) {
+    AddLine(x, y, LineOptions{.label = label, .line_style = line_style});
+  }
+  void AddLine(const std::vector<double> &x, const std::vector<double> &y,
+               LineOptions options);
+
+  void ShareXAxis(bool share) { share_x_axis_ = share; }
+  void XLabel(std::string_view label);
+  void YLabel(std::string_view label);
+  void Publish();
+
+  void Spin();
+
+ private:
+  void MaybeFinishFigure();
+
+  aos::FlatbufferDetachedBuffer<aos::Configuration> config_;
+  aos::SimulatedEventLoopFactory event_loop_factory_;
+  std::unique_ptr<aos::EventLoop> event_loop_;
+  aos::Sender<Plot> plot_sender_;
+  aos::web_proxy::WebProxy web_proxy_;
+
+  aos::Sender<Plot>::Builder builder_;
+  flatbuffers::Offset<flatbuffers::String> title_;
+  flatbuffers::Offset<flatbuffers::String> figure_title_;
+  flatbuffers::Offset<flatbuffers::String> xlabel_;
+  flatbuffers::Offset<flatbuffers::String> ylabel_;
+  bool share_x_axis_ = false;
+  float next_top_ = 0;
+  flatbuffers::Offset<Position> position_;
+  std::vector<flatbuffers::Offset<Figure>> figures_;
+  std::vector<flatbuffers::Offset<Line>> lines_;
+
+  struct ColorWheelColor {
+    std::string name;
+    Color color;
+  };
+
+  size_t color_wheel_position_ = 0;
+  std::vector<ColorWheelColor> color_wheel_;
+};
+
+}  // namespace frc971::analysis
+#endif  // FRC971_ANALYSIS_IN_PROCESS_PLOTTER_H_
diff --git a/aos/analysis/in_process_plotter_demo.cc b/aos/analysis/in_process_plotter_demo.cc
new file mode 100644
index 0000000..a02451a
--- /dev/null
+++ b/aos/analysis/in_process_plotter_demo.cc
@@ -0,0 +1,41 @@
+#include "aos/analysis/in_process_plotter.h"
+#include "aos/init.h"
+
+// To run this example, do:
+// bazel run -c opt //frc971/analysis:in_process_plotter_demo
+// And then open localhost:8080, select "C++ Plotter" from the drop-down, and
+// then select "TITLE!" or "Trig Functions" from the second drop-down to see
+// each plot.
+int main(int argc, char *argv[]) {
+  aos::InitGoogle(&argc, &argv);
+  frc971::analysis::Plotter plotter;
+  plotter.Title("TITLE!");
+  plotter.AddFigure("Fig Foo");
+  plotter.ShareXAxis(true);
+  plotter.AddLine({1, 2, 3, 4, 5}, {1, 2, 3, 4, 5}, "y = x");
+  plotter.AddLine({5, 4, 3, 2, 1}, {1, 2, 3, 4, 5}, "y = -x");
+  plotter.YLabel("Y Axis");
+  plotter.AddFigure("Fig Bar");
+  plotter.ShareXAxis(true);
+  plotter.AddLine({1, 2, 3}, {3, 4, 5}, "y = x + 2");
+  plotter.XLabel("X Axis (Linked to both above plots)");
+  plotter.Publish();
+
+  plotter.Title("Trig Functions");
+
+  plotter.AddFigure("Sin & Cos");
+  std::vector<double> x;
+  std::vector<double> sinx;
+  std::vector<double> cosx;
+  constexpr int kNumPoints = 100000;
+  for (int ii = 0; ii < kNumPoints; ++ii) {
+    x.push_back(ii * 2 * M_PI / kNumPoints);
+    sinx.push_back(std::sin(x.back()));
+    cosx.push_back(std::cos(x.back()));
+  }
+  plotter.AddLine(x, sinx, "sin(x)");
+  plotter.AddLine(x, cosx, "cos(x)");
+  plotter.Publish();
+
+  plotter.Spin();
+}
diff --git a/aos/analysis/local_foxglove.cc b/aos/analysis/local_foxglove.cc
new file mode 100644
index 0000000..ade56a5
--- /dev/null
+++ b/aos/analysis/local_foxglove.cc
@@ -0,0 +1,19 @@
+#include "glog/logging.h"
+
+#include "aos/init.h"
+#include "aos/seasocks/seasocks_logger.h"
+#include "internal/Embedded.h"
+#include "seasocks/Server.h"
+
+DEFINE_string(data_path, "external/foxglove_studio",
+              "Path to foxglove studio files to serve.");
+DEFINE_uint32(port, 8000, "Port to serve files at.");
+
+int main(int argc, char *argv[]) {
+  aos::InitGoogle(&argc, &argv);
+  // Magic for seasocks.
+  findEmbeddedContent("");
+  ::seasocks::Server server(std::make_shared<aos::seasocks::SeasocksLogger>(
+      ::seasocks::Logger::Level::Info));
+  server.serve(FLAGS_data_path.c_str(), FLAGS_port);
+}
diff --git a/aos/analysis/log_reader_test.py b/aos/analysis/log_reader_test.py
new file mode 100644
index 0000000..69627aa
--- /dev/null
+++ b/aos/analysis/log_reader_test.py
@@ -0,0 +1,115 @@
+#!/usr/bin/python3
+import json
+import unittest
+
+from aos.analysis.py_log_reader import LogReader
+
+
+class LogReaderTest(unittest.TestCase):
+
+    def setUp(self):
+        self.reader = LogReader("external/sample_logfile/file/log.fbs")
+        # A list of all the channels in the logfile--this is used to confirm that
+        # we did indeed read the config correctly.
+        self.all_channels = [
+            ("/aos", "aos.JoystickState"), ("/aos", "aos.RobotState"),
+            ("/aos", "aos.timing.Report"), ("/aos", "frc971.PDPValues"),
+            ("/aos", "frc971.wpilib.PneumaticsToLog"),
+            ("/autonomous", "aos.common.actions.Status"),
+            ("/autonomous", "frc971.autonomous.AutonomousMode"),
+            ("/autonomous", "frc971.autonomous.Goal"),
+            ("/camera", "y2019.CameraLog"),
+            ("/camera", "y2019.control_loops.drivetrain.CameraFrame"),
+            ("/drivetrain", "frc971.IMUValues"),
+            ("/drivetrain", "frc971.control_loops.drivetrain.Goal"),
+            ("/drivetrain",
+             "frc971.control_loops.drivetrain.LocalizerControl"),
+            ("/drivetrain", "frc971.control_loops.drivetrain.Output"),
+            ("/drivetrain", "frc971.control_loops.drivetrain.Position"),
+            ("/drivetrain", "frc971.control_loops.drivetrain.Status"),
+            ("/drivetrain", "frc971.sensors.GyroReading"),
+            ("/drivetrain",
+             "y2019.control_loops.drivetrain.TargetSelectorHint"),
+            ("/superstructure", "y2019.StatusLight"),
+            ("/superstructure", "y2019.control_loops.superstructure.Goal"),
+            ("/superstructure", "y2019.control_loops.superstructure.Output"),
+            ("/superstructure", "y2019.control_loops.superstructure.Position"),
+            ("/superstructure", "y2019.control_loops.superstructure.Status")
+        ]
+        # A channel that is known to have data on it which we will use for testing.
+        self.test_channel = ("/aos", "aos.timing.Report")
+        # A non-existent channel
+        self.bad_channel = ("/aos", "aos.timing.FooBar")
+
+    def test_do_nothing(self):
+        """Tests that we sanely handle doing nothing.
+
+        A previous iteration of the log reader seg faulted when doing this."""
+        pass
+
+    @unittest.skip("broken by flatbuffer upgrade")
+    def test_read_config(self):
+        """Tests that we can read the configuration from the logfile."""
+        config_bytes = self.reader.configuration()
+        config = Configuration.GetRootAsConfiguration(config_bytes, 0)
+
+        channel_set = set(self.all_channels)
+        for ii in range(config.ChannelsLength()):
+            channel = config.Channels(ii)
+            # Will raise KeyError if the channel does not exist
+            channel_set.remove((channel.Name().decode("utf-8"),
+                                channel.Type().decode("utf-8")))
+
+        self.assertEqual(0, len(channel_set))
+
+    def test_empty_process(self):
+        """Tests running process() without subscribing to anything succeeds."""
+        self.reader.process()
+        for channel in self.all_channels:
+            with self.assertRaises(ValueError) as context:
+                self.reader.get_data_for_channel(channel[0], channel[1])
+
+    def test_subscribe(self):
+        """Tests that we can subscribe to a channel and get data out."""
+        name = self.test_channel[0]
+        message_type = self.test_channel[1]
+        self.assertTrue(self.reader.subscribe(name, message_type))
+        self.reader.process()
+        data = self.reader.get_data_for_channel(name, message_type)
+        self.assertLess(100, len(data))
+        last_monotonic_time = 0
+        for entry in data:
+            monotonic_time = entry[0]
+            realtime_time = entry[1]
+            json_data = entry[2].replace('nan', '\"nan\"')
+            self.assertLess(last_monotonic_time, monotonic_time)
+            # Sanity check that the realtime times are in the correct range.
+            self.assertLess(1500000000e9, realtime_time)
+            self.assertGreater(2000000000e9, realtime_time)
+            parsed_json = json.loads(json_data)
+            self.assertIn("name", parsed_json)
+
+            last_monotonic_time = monotonic_time
+
+    def test_bad_subscribe(self):
+        """Tests that we return false when subscribing to a non-existent channel."""
+        self.assertFalse(
+            self.reader.subscribe(self.bad_channel[0], self.bad_channel[1]),
+            self.bad_channel)
+
+    def test_subscribe_after_process(self):
+        """Tests that an exception is thrown if we subscribe after calling process()."""
+        self.reader.process()
+        for channel in self.all_channels:
+            with self.assertRaises(RuntimeError) as context:
+                self.reader.subscribe(channel[0], channel[1])
+
+    def test_get_data_before_processj(self):
+        """Tests that an exception is thrown if we retrieve data before calling process()."""
+        for channel in self.all_channels:
+            with self.assertRaises(RuntimeError) as context:
+                self.reader.get_data_for_channel(channel[0], channel[1])
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/aos/analysis/plot_data.fbs b/aos/analysis/plot_data.fbs
new file mode 100644
index 0000000..641cd6e
--- /dev/null
+++ b/aos/analysis/plot_data.fbs
@@ -0,0 +1,59 @@
+// This flatbuffer defines the interface that is used by the in-process
+// web plotter to plot data dynamically. Both the structure of the plot and
+// the data to plot is all packaged within a single Plot message. Each Plot
+// message will correspond to a single view/tab on the web-page, and can have
+// multiple figures, each of which can have multiple lines.
+namespace frc971.analysis;
+
+// Position within the web-page to plot a figure at. [0, 0] will be the upper
+// left corner of the allowable places where plots can be put, and should
+// generally be the default location. All values in pixels.
+table Position {
+  top:float (id: 0);
+  left:float (id: 1);
+  width:float (id: 2);
+  height:float (id: 3);
+}
+
+struct Point {
+  x:double (id: 0);
+  y:double (id: 1);
+}
+
+// RGB values are in the range [0, 1].
+struct Color {
+  r:float (id: 0);
+  g:float (id: 1);
+  b:float (id: 2);
+}
+
+table LineStyle {
+  point_size:float (id: 0);
+  draw_line:bool (id: 1);
+}
+
+table Line {
+  label:string (id: 0);
+  points:[Point] (id: 1);
+  color:Color (id: 2);
+  style:LineStyle (id: 3);
+}
+
+table Figure {
+  position:Position (id: 0);
+  lines:[Line] (id: 1);
+  // Specifies whether to link the x-axis of this Figure with that of other
+  // figures in this Plot. Only the axes of Figure's with this flag set will
+  // be linked.
+  share_x_axis:bool (id: 2);
+  title:string (id: 3);
+  xlabel:string (id: 4);
+  ylabel:string (id: 5);
+}
+
+table Plot {
+  figures:[Figure] (id: 0);
+  title:string (id: 1);
+}
+
+root_type Plot;
diff --git a/aos/analysis/plot_data_utils.ts b/aos/analysis/plot_data_utils.ts
new file mode 100644
index 0000000..25131fb
--- /dev/null
+++ b/aos/analysis/plot_data_utils.ts
@@ -0,0 +1,110 @@
+// Provides a plot which handles plotting the plot defined by a
+// frc971.analysis.Plot message.
+import {Plot as PlotFb} from './plot_data_generated';
+import {MessageHandler, TimestampedMessage} from '../../aos/network/www/aos_plotter';
+import {ByteBuffer} from 'flatbuffers';
+import {Plot, Point} from '../../aos/network/www/plotter';
+import {Connection} from '../../aos/network/www/proxy';
+import {Schema} from 'flatbuffers_reflection/reflection_generated';
+
+export function plotData(conn: Connection, parentDiv: Element) {
+  // Set up a selection box to allow the user to choose between plots to show.
+  const plotSelect = document.createElement('select');
+  parentDiv.appendChild(plotSelect);
+  const plots = new Map<string, HTMLElement>();
+  const invalidSelectValue = 'null';
+  plotSelect.addEventListener('input', () => {
+    for (const plot of plots.values()) {
+      plot.style.display = 'none';
+    }
+    if (plotSelect.value == invalidSelectValue) {
+      return;
+    }
+    plots.get(plotSelect.value).style.display = 'block';
+  });
+  plotSelect.add(new Option('Select Plot', invalidSelectValue));
+
+  const plotDiv = document.createElement('div');
+  parentDiv.appendChild(plotDiv);
+
+  conn.addReliableHandler(
+      '/analysis', 'frc971.analysis.Plot', (data: Uint8Array, time: number) => {
+        const plotFb = PlotFb.getRootAsPlot(new ByteBuffer(data));
+        const name = (!plotFb.title()) ? 'Plot ' + plots.size : plotFb.title();
+        const div = document.createElement('div');
+        div.style.display = 'none';
+        plots.set(name, div);
+        plotDiv.appendChild(div);
+        plotSelect.add(new Option(name, name));
+
+        const linkedXAxes: Plot[] = [];
+
+        for (let ii = 0; ii < plotFb.figuresLength(); ++ii) {
+          const figure = plotFb.figures(ii);
+          const figureDiv = document.createElement('div');
+          if (figure.position().width() == 0) {
+            figureDiv.style.width = '100%';
+          } else {
+            figureDiv.style.width = figure.position().width().toString() + 'px';
+          }
+          if (figure.position().height() == 0) {
+            figureDiv.style.height = '100%';
+          } else {
+            figureDiv.style.height =
+                figure.position().height().toString() + 'px';
+          }
+          figureDiv.style.position = 'relative';
+          div.appendChild(figureDiv);
+          const plot = new Plot(figureDiv);
+
+          if (figure.title()) {
+            plot.getAxisLabels().setTitle(figure.title());
+          }
+          if (figure.xlabel()) {
+            plot.getAxisLabels().setXLabel(figure.xlabel());
+          }
+          if (figure.ylabel()) {
+            plot.getAxisLabels().setYLabel(figure.ylabel());
+          }
+          if (figure.shareXAxis()) {
+            for (const other of linkedXAxes) {
+              plot.linkXAxis(other);
+            }
+            linkedXAxes.push(plot);
+          }
+
+          for (let jj = 0; jj < figure.linesLength(); ++jj) {
+            const lineFb = figure.lines(jj);
+            const line = plot.getDrawer().addLine();
+            if (lineFb.label()) {
+              line.setLabel(lineFb.label());
+            }
+            const points = [];
+            for (let kk = 0; kk < lineFb.pointsLength(); ++kk) {
+              const point = lineFb.points(kk);
+              points.push(new Point(point.x(), point.y()));
+            }
+            if (lineFb.color()) {
+              line.setColor(
+                  [lineFb.color().r(), lineFb.color().g(), lineFb.color().b()]);
+            }
+            if (lineFb.style()) {
+              if (lineFb.style().pointSize() !== null) {
+                line.setPointSize(lineFb.style().pointSize());
+              }
+              if (lineFb.style().drawLine() !== null) {
+                line.setDrawLine(lineFb.style().drawLine());
+              }
+            }
+            line.setPoints(points);
+          }
+        }
+
+        // If this is the first new element (ignoring the placeholder up top),
+        // select it by default.
+        if (plotSelect.length == 2) {
+          plotSelect.value = name;
+          plotSelect.dispatchEvent(new Event('input'));
+        }
+      });
+}
diff --git a/aos/analysis/plotter_config.json b/aos/analysis/plotter_config.json
new file mode 100644
index 0000000..fef4a50
--- /dev/null
+++ b/aos/analysis/plotter_config.json
@@ -0,0 +1,12 @@
+{
+  "channels": [
+    {
+      "name": "/analysis",
+      "type": "frc971.analysis.Plot",
+      "max_size": 1000000000
+    }
+  ],
+  "imports": [
+    "../../aos/events/aos.json"
+  ]
+}
diff --git a/aos/analysis/py_log_reader.cc b/aos/analysis/py_log_reader.cc
new file mode 100644
index 0000000..57d57d0
--- /dev/null
+++ b/aos/analysis/py_log_reader.cc
@@ -0,0 +1,306 @@
+// This file provides a Python module for reading logfiles. See
+// log_reader_test.py for usage.
+//
+// NOTE: This code has not been maintained recently, and so is missing key
+// features to support reading multi-node logfiles (namely, it assumes the the
+// logfile is just a single file). Updating this code should not be difficult,
+// but hasn't been needed thus far.
+//
+// This reader works by having the user specify exactly what channels they want
+// data for. We then process the logfile and store all the data on that channel
+// into a list of timestamps + JSON message data. The user can then use an
+// accessor method (get_data_for_channel) to retrieve the cached data.
+
+// Defining PY_SSIZE_T_CLEAN seems to be suggested by most of the Python
+// documentation.
+#define PY_SSIZE_T_CLEAN
+// Note that Python.h needs to be included before anything else.
+#include <Python.h>
+
+#include <cerrno>
+#include <memory>
+
+#include "aos/configuration.h"
+#include "aos/events/logging/log_reader.h"
+#include "aos/events/simulated_event_loop.h"
+#include "aos/flatbuffer_merge.h"
+#include "aos/init.h"
+#include "aos/json_to_flatbuffer.h"
+
+namespace frc971::analysis {
+namespace {
+
+// All the data corresponding to a single message.
+struct MessageData {
+  aos::monotonic_clock::time_point monotonic_sent_time;
+  aos::realtime_clock::time_point realtime_sent_time;
+  // JSON representation of the message.
+  std::string json_data;
+};
+
+// Data corresponding to an entire channel.
+struct ChannelData {
+  std::string name;
+  std::string type;
+  // Each message published on the channel, in order by monotonic time.
+  std::vector<MessageData> messages;
+};
+
+// All the objects that we need for managing reading a logfile.
+struct LogReaderTools {
+  std::unique_ptr<aos::logger::LogReader> reader;
+  // Event loop to use for subscribing to buses.
+  std::unique_ptr<aos::EventLoop> event_loop;
+  std::vector<ChannelData> channel_data;
+  // Whether we have called process() on the reader yet.
+  bool processed = false;
+};
+
+struct LogReaderType {
+  PyObject_HEAD;
+  LogReaderTools *tools = nullptr;
+};
+
+void LogReader_dealloc(LogReaderType *self) {
+  LogReaderTools *tools = self->tools;
+  delete tools;
+  Py_TYPE(self)->tp_free((PyObject *)self);
+}
+
+PyObject *LogReader_new(PyTypeObject *type, PyObject * /*args*/,
+                        PyObject * /*kwds*/) {
+  LogReaderType *self;
+  self = (LogReaderType *)type->tp_alloc(type, 0);
+  if (self != nullptr) {
+    self->tools = new LogReaderTools();
+    if (self->tools == nullptr) {
+      return nullptr;
+    }
+  }
+  return (PyObject *)self;
+}
+
+int LogReader_init(LogReaderType *self, PyObject *args, PyObject *kwds) {
+  int count = 1;
+  if (!aos::IsInitialized()) {
+    // Fake out argc and argv to let InitGoogle run properly to instrument
+    // malloc, setup glog, and such.
+    char *name = program_invocation_name;
+    char **argv = &name;
+    aos::InitGoogle(&count, &argv);
+  }
+
+  const char *kwlist[] = {"log_file_name", nullptr};
+
+  const char *log_file_name;
+  if (!PyArg_ParseTupleAndKeywords(args, kwds, "s", const_cast<char **>(kwlist),
+                                   &log_file_name)) {
+    return -1;
+  }
+
+  LogReaderTools *tools = CHECK_NOTNULL(self->tools);
+  tools->reader = std::make_unique<aos::logger::LogReader>(log_file_name);
+  tools->reader->Register();
+
+  if (aos::configuration::MultiNode(tools->reader->configuration())) {
+    tools->event_loop = tools->reader->event_loop_factory()->MakeEventLoop(
+        "data_fetcher",
+        aos::configuration::GetNode(tools->reader->configuration(), "roborio"));
+  } else {
+    tools->event_loop =
+        tools->reader->event_loop_factory()->MakeEventLoop("data_fetcher");
+  }
+  tools->event_loop->SkipTimingReport();
+  tools->event_loop->SkipAosLog();
+
+  return 0;
+}
+
+PyObject *LogReader_get_data_for_channel(LogReaderType *self, PyObject *args,
+                                         PyObject *kwds) {
+  const char *kwlist[] = {"name", "type", nullptr};
+
+  const char *name;
+  const char *type;
+  if (!PyArg_ParseTupleAndKeywords(args, kwds, "ss",
+                                   const_cast<char **>(kwlist), &name, &type)) {
+    return nullptr;
+  }
+
+  LogReaderTools *tools = CHECK_NOTNULL(self->tools);
+
+  if (!tools->processed) {
+    PyErr_SetString(PyExc_RuntimeError,
+                    "Called get_data_for_bus before calling process().");
+    return nullptr;
+  }
+
+  for (const auto &channel : tools->channel_data) {
+    if (channel.name == name && channel.type == type) {
+      PyObject *list = PyList_New(channel.messages.size());
+      for (size_t ii = 0; ii < channel.messages.size(); ++ii) {
+        const auto &message = channel.messages[ii];
+        PyObject *monotonic_time = PyLong_FromLongLong(
+            std::chrono::duration_cast<std::chrono::nanoseconds>(
+                message.monotonic_sent_time.time_since_epoch())
+                .count());
+        PyObject *realtime_time = PyLong_FromLongLong(
+            std::chrono::duration_cast<std::chrono::nanoseconds>(
+                message.realtime_sent_time.time_since_epoch())
+                .count());
+        PyObject *json_data = PyUnicode_FromStringAndSize(
+            message.json_data.data(), message.json_data.size());
+        PyObject *entry =
+            PyTuple_Pack(3, monotonic_time, realtime_time, json_data);
+        if (PyList_SetItem(list, ii, entry) != 0) {
+          return nullptr;
+        }
+      }
+      return list;
+    }
+  }
+  PyErr_SetString(PyExc_ValueError,
+                  "The provided channel was never subscribed to.");
+  return nullptr;
+}
+
+PyObject *LogReader_subscribe(LogReaderType *self, PyObject *args,
+                              PyObject *kwds) {
+  const char *kwlist[] = {"name", "type", nullptr};
+
+  const char *name;
+  const char *type;
+  if (!PyArg_ParseTupleAndKeywords(args, kwds, "ss",
+                                   const_cast<char **>(kwlist), &name, &type)) {
+    return nullptr;
+  }
+
+  LogReaderTools *tools = CHECK_NOTNULL(self->tools);
+
+  if (tools->processed) {
+    PyErr_SetString(PyExc_RuntimeError,
+                    "Called subscribe after calling process().");
+    return nullptr;
+  }
+
+  const aos::Channel *const channel = aos::configuration::GetChannel(
+      tools->reader->configuration(), name, type, "", nullptr);
+  if (channel == nullptr) {
+    return Py_False;
+  }
+  const int index = tools->channel_data.size();
+  tools->channel_data.push_back({.name = name, .type = type, .messages = {}});
+  tools->event_loop->MakeRawWatcher(
+      channel, [channel, index, tools](const aos::Context &context,
+                                       const void *message) {
+        tools->channel_data[index].messages.push_back(
+            {.monotonic_sent_time = context.monotonic_event_time,
+             .realtime_sent_time = context.realtime_event_time,
+             .json_data = aos::FlatbufferToJson(
+                 channel->schema(), static_cast<const uint8_t *>(message))});
+      });
+  return Py_True;
+}
+
+static PyObject *LogReader_process(LogReaderType *self,
+                                   PyObject *Py_UNUSED(ignored)) {
+  LogReaderTools *tools = CHECK_NOTNULL(self->tools);
+
+  if (tools->processed) {
+    PyErr_SetString(PyExc_RuntimeError, "process() may only be called once.");
+    return nullptr;
+  }
+
+  tools->processed = true;
+
+  tools->reader->event_loop_factory()->Run();
+
+  Py_RETURN_NONE;
+}
+
+static PyObject *LogReader_configuration(LogReaderType *self,
+                                         PyObject *Py_UNUSED(ignored)) {
+  LogReaderTools *tools = CHECK_NOTNULL(self->tools);
+
+  // I have no clue if the Configuration that we get from the log reader is in a
+  // contiguous chunk of memory, and I'm too lazy to either figure it out or
+  // figure out how to extract the actual data buffer + offset.
+  // Instead, copy the flatbuffer and return a copy of the new buffer.
+  aos::FlatbufferDetachedBuffer<aos::Configuration> buffer =
+      aos::CopyFlatBuffer(tools->reader->configuration());
+
+  return PyBytes_FromStringAndSize(
+      reinterpret_cast<const char *>(buffer.span().data()),
+      buffer.span().size());
+}
+
+static PyMethodDef LogReader_methods[] = {
+    {"configuration", (PyCFunction)LogReader_configuration, METH_NOARGS,
+     "Return a bytes buffer for the Configuration of the logfile."},
+    {"process", (PyCFunction)LogReader_process, METH_NOARGS,
+     "Processes the logfile and all the subscribed to channels."},
+    {"subscribe", (PyCFunction)LogReader_subscribe,
+     METH_VARARGS | METH_KEYWORDS,
+     "Attempts to subscribe to the provided channel name + type. Returns True "
+     "if successful."},
+    {"get_data_for_channel", (PyCFunction)LogReader_get_data_for_channel,
+     METH_VARARGS | METH_KEYWORDS,
+     "Returns the logged data for a given channel. Raises an exception if you "
+     "did not subscribe to the provided channel. Returned data is a list of "
+     "tuples where each tuple is of the form (monotonic_nsec, realtime_nsec, "
+     "json_message_data)."},
+    {nullptr, 0, 0, nullptr} /* Sentinel */
+};
+
+#ifdef __clang__
+// These extensions to C++ syntax do surprising things in C++, but for these
+// uses none of them really matter I think, and the alternatives are really
+// annoying.
+#pragma clang diagnostic ignored "-Wc99-designator"
+#endif
+
+static PyTypeObject LogReaderType = {
+    PyVarObject_HEAD_INIT(NULL, 0)
+        // The previous macro initializes some fields, leave a comment to help
+        // clang-format not make this uglier.
+        .tp_name = "py_log_reader.LogReader",
+    .tp_basicsize = sizeof(LogReaderType),
+    .tp_itemsize = 0,
+    .tp_dealloc = (destructor)LogReader_dealloc,
+    .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE,
+    .tp_doc = "LogReader objects",
+    .tp_methods = LogReader_methods,
+    .tp_init = (initproc)LogReader_init,
+    .tp_new = LogReader_new,
+};
+
+static PyModuleDef log_reader_module = {
+    PyModuleDef_HEAD_INIT,
+    .m_name = "py_log_reader",
+    .m_doc = "Example module that creates an extension type.",
+    .m_size = -1,
+};
+
+PyObject *InitModule() {
+  PyObject *m;
+  if (PyType_Ready(&LogReaderType) < 0) return nullptr;
+
+  m = PyModule_Create(&log_reader_module);
+  if (m == nullptr) return nullptr;
+
+  Py_INCREF(&LogReaderType);
+  if (PyModule_AddObject(m, "LogReader", (PyObject *)&LogReaderType) < 0) {
+    Py_DECREF(&LogReaderType);
+    Py_DECREF(m);
+    return nullptr;
+  }
+
+  return m;
+}
+
+}  // namespace
+}  // namespace frc971::analysis
+
+PyMODINIT_FUNC PyInit_py_log_reader(void) {
+  return frc971::analysis::InitModule();
+}