tools: Basic ADC plotting tool

Change-Id: I29fed9ab26864455489cd0cda34cc9f9a1752e20
Reviewed-on: https://pigweed-review.googlesource.com/c/gonk/+/209092
Presubmit-Verified: CQ Bot Account <pigweed-scoped@luci-project-accounts.iam.gserviceaccount.com>
Pigweed-Auto-Submit: Anthony DiGirolamo <tonymd@google.com>
Lint: Lint 🤖 <android-build-ayeaye@system.gserviceaccount.com>
Commit-Queue: Auto-Submit <auto-submit@pigweed-service-accounts.iam.gserviceaccount.com>
Reviewed-by: Eric Holland <hollande@google.com>
diff --git a/README.md b/README.md
index a4f2e7b..9e66092 100644
--- a/README.md
+++ b/README.md
@@ -216,3 +216,43 @@
 | FPGA_IO_SPARE_2_1 |          |       48 | PA6       |                |                     |
 | FPGA_IO_SPARE_2_2 |          |       47 | PA5       |                |                     |
 | FPGA_IO_SPARE_2_3 |          |       45 | PA4       |                |                     |
+
+# Capture ADC samples and plot
+
+1. First bootstrap, run `pw build` and flash gonk using DFU.
+
+   ```sh
+   . ./bootstrap.sh
+   ```
+
+   ```sh
+   pw build
+   ```
+
+   Unplug gonk from USB and replug with MODE button held down.
+
+   Run `gonk-flash` on the MCU binary. This uses `pyfu-usb`.
+
+   ```sh
+   gonk-flash ./out/gn/arduino_size_optimized/obj/applications/fpga_config/fpga_config.bin
+   ```
+
+2. Start capturing ADC samples with:
+
+   ```sh
+   python tools/gonk_tools/write_fpga.py \
+     --bitstream-file ./out/gn/obj/fpga/toplevel/toplevel.bin \
+     --database ./out/gn/arduino_size_optimized/obj/applications/fpga_config/bin/fpga_config.elf \
+     --host-logfile gonk-host-logs.txt \
+     --device-logfile gonk-device-logs.txt \
+     --log-to-stderr
+   ```
+
+   - Press `Enter` to stop or start ADC continuous updates. It will begin automatically on startup.
+   - Press `ctrl-c` to quit.
+
+3. Plot the logs from `gonk-device-logs.txt` with:
+
+   ```sh
+   python tools/gonk_tools/plot.py -i gonk-device-logs.txt -o plot.svg
+   ```
diff --git a/tools/BUILD.gn b/tools/BUILD.gn
index dbf78b8..762fe97 100644
--- a/tools/BUILD.gn
+++ b/tools/BUILD.gn
@@ -30,6 +30,7 @@
     "gonk_tools/firmware_files.py",
     "gonk_tools/flash.py",
     "gonk_tools/gonk_log_stream.py",
+    "gonk_tools/plot.py",
     "gonk_tools/presubmit_checks.py",
     "gonk_tools/write_fpga.py",
   ]
diff --git a/tools/gonk_tools/plot.py b/tools/gonk_tools/plot.py
new file mode 100644
index 0000000..0c38ca2
--- /dev/null
+++ b/tools/gonk_tools/plot.py
@@ -0,0 +1,139 @@
+# Copyright 2024 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Plot ADC updates as an SVG from a device logfile."""
+
+import argparse
+from datetime import datetime
+from datetime import timedelta
+import logging
+from pathlib import Path
+import sys
+from typing import Optional
+
+import matplotlib.pyplot as plt
+import numpy as np
+
+
+_LOG = logging.getLogger(__package__)
+ADC_COUNT = 5
+
+
+def _parse_args():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        '-i',
+        '--input-text',
+        type=Path,
+        default=Path('gonk-device-logs.txt'),
+        help='Input log text file.',
+    )
+    parser.add_argument(
+        '-o',
+        '--output-svg',
+        type=Path,
+        default=Path('plot.svg'),
+        help='Output svg file.',
+    )
+    return parser.parse_args()
+
+
+def main(
+    input_text: Path,
+    output_svg: Path,
+) -> int:
+    """Plot ADC values."""
+    # pylint: disable=too-many-locals
+
+    start_time: Optional[datetime] = None
+    time_values = []
+    vbus_values: list[list[int]] = []
+    vshunt_values: list[list[int]] = []
+    for i in range(ADC_COUNT):
+        vbus_values.append([])
+        vshunt_values.append([])
+
+    with input_text.open() as f:
+        current_time = datetime.now()
+
+        for line in f.readlines():
+            # Skip lines that are not ADC updates.
+            if 'delta_microseconds' not in line:
+                continue
+
+            parts = line.split()
+
+            # Extract the host timestamp
+            dtstr = parts[5] + ' ' + parts[6]
+            dt = datetime.strptime(dtstr, '%Y%m%d %H:%M:%S.%f')
+            # Extract delta_micros
+            delta_micros = int(parts[10])
+
+            # Set start timestamp if not found already.
+            if not start_time:
+                start_time = dt
+                current_time = start_time - timedelta(microseconds=delta_micros)
+
+            # Increment delta_micros
+            current_time += timedelta(microseconds=delta_micros)
+
+            # Save the timestamp and vbus vshunt values for plotting.
+            time_values.append((current_time - start_time).total_seconds())
+            vbus = list(int(i) for i in parts[12].split(','))
+            vshunt = list(int(i) for i in parts[14].split(','))
+
+            for i in range(ADC_COUNT):
+                vbus_values[i].append(vbus[i])
+                vshunt_values[i].append(vshunt[i])
+
+    # Plot vbus and vshunt values.
+    _fig, (ax1, ax2) = plt.subplots(
+        2, 1, layout='constrained', figsize=[11.67, 8.27]
+    )
+
+    times = np.asarray(time_values)
+    ax1.set_xlabel('Time (s)')
+    ax2.set_xlabel('Time (s)')
+
+    linewidth = 0.7
+    ax1.set_ylabel('vbus')
+    for i in range(ADC_COUNT):
+        ax1.plot(
+            times,
+            np.asarray(vbus_values[i]),
+            label=f'vbus-{i}',
+            linestyle='dotted',
+            linewidth=linewidth,
+        )
+
+    ax2.set_ylabel('vshunt')
+    for i in range(ADC_COUNT):
+        ax2.plot(
+            times,
+            np.asarray(vshunt_values[i]),
+            label=f'vshunt-{i}',
+            linestyle='dotted',
+            linewidth=linewidth,
+        )
+
+    ax1.legend()
+    ax1.grid(True)
+    ax2.legend()
+    ax2.grid(True)
+
+    plt.savefig(output_svg)
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main(**vars(_parse_args())))
diff --git a/tools/setup.cfg b/tools/setup.cfg
index 9aef744..2263b55 100644
--- a/tools/setup.cfg
+++ b/tools/setup.cfg
@@ -22,6 +22,8 @@
 packages = find:
 zip_safe = False
 install_requires =
+    matplotlib
+    numpy
     pyfu-usb
     pyserial