Log GPIO assert events in device and CSV logs

Add log messages when GPIO pins are pulled low by an external source.

Bug: b/324483249
Change-Id: Iedb509c1640c7f32797386d456b42fb75e0d7e6e
Reviewed-on: https://pigweed-review.googlesource.com/c/gonk/+/229391
Commit-Queue: Anthony DiGirolamo <tonymd@google.com>
Lint: Lint 🤖 <android-build-ayeaye@system.gserviceaccount.com>
Reviewed-by: Taylor Cramer <cramertj@google.com>
diff --git a/README.md b/README.md
index 158ae6d..1a16e1e 100644
--- a/README.md
+++ b/README.md
@@ -168,6 +168,44 @@
 
 ## Appendix
 
+### CSV Log Format
+
+#### Fields
+
+1. Host timestamp with format `%Y%m%d %H:%M:%S.%f`
+2. Delta microseconds (elapsed microseconds since the last ADC read) on the STM32 microcontroller side.
+3. 7 Voltage values
+4. 7 Current values
+5. 7 Power values
+6. Comment text
+
+Example separated into multiple lines (with an empty comment field)
+```
+20240813 14:16:35.176433,
+283,
+0.8148437500000001, 0.8892578125, 5.212109375000001, 1.087109375, 0.8904296875000001, 3.3820312500000003, 1.8125,
+0.058447916666666676, 0.031546875, 0.13443125, 0.015902343750000002, 0.0009143518518518518, 0.16081250000000002, 0.004143750000000001,
+0.04762591959635418, 0.02805330505371094, 0.7006703784179689, 0.01728758697509766, 0.0008141660337094908, 0.5438729003906251, 0.007510546875000001,
+
+```
+
+#### GPIO assert events in the CSV
+
+When GPIOs are pulled LOW (to ground) a line in the CSV will appear
+with delta_micros = 0 and empty measurement values. The Comment field will
+contain the GPIO assert log message.
+
+Example:
+
+```
+20240813 14:12:32.930888, 0,
+,,,,,,,
+,,,,,,,
+,,,,,,,
+Header pin assert: 2
+```
+
+
 ### Compiling the FPGA Toolchain
 
 #### yosys
@@ -231,6 +269,28 @@
 |--------|-----------|----------------|----------|
 | STATUS | PB13      | GPIO_Output    | STAT LED |
 
+### Top 6 pin header (0.1inch pitch 2x3)
+
+Visual header pin positions:
+
+```
+                             +-------------
++------+--------+-----+      |
+|  2   | Ground |  6  |      |    USB-C
++------+--------+-----+      |    Port
+|  1   |   3    | VCC |      |
++------+--------+-----+      +-------------
+```
+
+| Header Pin | Net         | STM32 Pin |
+|------------|-------------|-----------|
+|          1 | SPI_HDR_IO3 | PB0       |
+|          2 | SPI_HDR_IO1 | PA2       |
+|          3 | SPI_HDR_IO2 | PA3       |
+|          4 | GND         |           |
+|          5 | VCC_GONK    |           |
+|          6 | SPI_HDR_IO0 | PA1       |
+
 ### SPI Flash Connection
 
 | Net          | FPGA IO# | STM32 Pin | STM32 Function | Flash Pin  |
diff --git a/lib/pin_config/pin_config.cc b/lib/pin_config/pin_config.cc
index 1dc187d..a058fd7 100644
--- a/lib/pin_config/pin_config.cc
+++ b/lib/pin_config/pin_config.cc
@@ -10,6 +10,32 @@
 
 #include "pw_log/log.h"
 
+namespace {
+
+volatile uint8_t gpio1_assert_ = 0;
+volatile uint8_t gpio2_assert_ = 0;
+volatile uint8_t gpio3_assert_ = 0;
+volatile uint8_t gpio6_assert_ = 0;
+
+void Gpio1Interrupt() {
+  gpio1_assert_ = 1;
+  PW_LOG_INFO("Header pin assert: 1");
+}
+void Gpio2Interrupt() {
+  gpio2_assert_ = 1;
+  PW_LOG_INFO("Header pin assert: 2");
+}
+void Gpio3Interrupt() {
+  gpio3_assert_ = 1;
+  PW_LOG_INFO("Header pin assert: 3");
+}
+void Gpio6Interrupt() {
+  gpio6_assert_ = 1;
+  PW_LOG_INFO("Header pin assert: 6");
+}
+
+} // namespace
+
 namespace gonk::pin_config {
 
 PinConfig::PinConfig()
@@ -25,6 +51,24 @@
   // Default CS pin to output high.
   pinMode(FlashCS, OUTPUT);
   digitalWrite(FlashCS, HIGH);
+
+  // Top header GPIO inputs.
+  pinMode(HeaderPin1, INPUT_PULLUP);
+  pinMode(HeaderPin2, INPUT_PULLUP);
+  pinMode(HeaderPin3, INPUT_PULLUP);
+  pinMode(HeaderPin6, INPUT_PULLUP);
+  attachInterrupt(/*pin=*/HeaderPin1,
+                  /*callback=*/&Gpio1Interrupt,
+                  /*mode=*/LOW);
+  attachInterrupt(/*pin=*/HeaderPin2,
+                  /*callback=*/&Gpio2Interrupt,
+                  /*mode=*/LOW);
+  attachInterrupt(/*pin=*/HeaderPin3,
+                  /*callback=*/&Gpio3Interrupt,
+                  /*mode=*/LOW);
+  attachInterrupt(/*pin=*/HeaderPin6,
+                  /*callback=*/&Gpio6Interrupt,
+                  /*mode=*/LOW);
 }
 
 void PinConfig::InitFpgaPins() {
@@ -123,4 +167,11 @@
   digitalWrite(FlashMOSI, HIGH);
 }
 
+void PinConfig::ClearGpioInterrupts() {
+  gpio1_assert_ = 0;
+  gpio2_assert_ = 0;
+  gpio3_assert_ = 0;
+  gpio6_assert_ = 0;
+}
+
 } // namespace gonk::pin_config
diff --git a/lib/pin_config/public/gonk/pin_config.h b/lib/pin_config/public/gonk/pin_config.h
index bdb4398..67683ce 100644
--- a/lib/pin_config/public/gonk/pin_config.h
+++ b/lib/pin_config/public/gonk/pin_config.h
@@ -24,6 +24,11 @@
 constexpr uint16_t FpgaIoMode = PB11;
 constexpr uint16_t FpgaIoValid = PB6; // was PB10;
 
+constexpr uint16_t HeaderPin1 = PB0;
+constexpr uint16_t HeaderPin2 = PA2;
+constexpr uint16_t HeaderPin3 = PA3;
+constexpr uint16_t HeaderPin6 = PA1;
+
 class PinConfig {
 public:
   PinConfig();
@@ -41,6 +46,7 @@
   void FpgaEnable();
   void SPIEnable();
   void SPIDisable();
+  void ClearGpioInterrupts();
 
   SPIClass flash_spi;
   SPIClass fpga_spi;
diff --git a/tools/gonk_tools/gonk_log_stream.py b/tools/gonk_tools/gonk_log_stream.py
index 9a6d049..a4d7637 100644
--- a/tools/gonk_tools/gonk_log_stream.py
+++ b/tools/gonk_tools/gonk_log_stream.py
@@ -16,6 +16,7 @@
 from datetime import datetime
 import logging
 import time
+from typing import Callable
 
 from google.protobuf.message import DecodeError
 from pw_hdlc.decode import FrameDecoder
@@ -26,6 +27,7 @@
 
 from gonk_adc.adc_measurement_pb2 import FramedProto
 from gonk_tools.adc import (
+    ADC_COUNT,
     calc_power,
     get_bus_voltages,
     get_shunt_currents,
@@ -64,6 +66,8 @@
         self,
         detokenizer: detokenize.Detokenizer | None = None,
         adc_time_format: str = '%Y%m%d %H:%M:%S.%f',
+        log_message_handlers: list[Callable[[pw_log.log_decoder.Log], None]]
+        | None = None,
     ) -> None:
         self.detokenizer = detokenizer
         self.log_decoder: LogStreamDecoder | None = None
@@ -80,6 +84,14 @@
 
         self.proto_update_count: int = 0
         self.proto_update_time = time.monotonic()
+        self.log_message_handlers: list[
+            Callable[[pw_log.log_decoder.Log], None]
+        ] = []
+        self.log_message_handlers.append(self._log_csv_gpio_assert)
+        self.empty_measurements_str = ',' * (ADC_COUNT * 3 - 1)
+
+        if log_message_handlers:
+            self.log_message_handlers.extend(log_message_handlers)
 
     def _maybe_log_update_rate(self) -> None:
         self.proto_update_count += 1
@@ -171,6 +183,8 @@
 
             log = self.log_decoder.parse_log_entry_proto(log_entry)
             emit_python_log(log)
+            for log_message_handler in self.log_message_handlers:
+                log_message_handler(log)
 
     def _handle_proto_packets(self) -> None:
         # If no proto has been found, check for a new one.
@@ -195,6 +209,9 @@
                 self._parse_and_log_adc_proto(proto_bytes)
                 self._maybe_log_update_rate()
 
+    def _get_host_time(self) -> str:
+        return datetime.now().strftime(self.adc_time_format)
+
     def _parse_and_log_adc_proto(self, proto_bytes: bytes) -> None:
         """Parse an ADC proto message and log."""
         framed_proto = FramedProto()
@@ -205,7 +222,7 @@
             _LOG.error('ADC FramedProto.DecodeError: %s', proto_bytes.hex())
             return
 
-        host_time = datetime.now().strftime(self.adc_time_format)
+        host_time = self._get_host_time()
         packet_size = len(proto_bytes)
         delta_micros = framed_proto.payload.timestamp
 
@@ -247,7 +264,7 @@
         )
 
         _CSV_LOG.info(
-            '%s, %s, %s, %s, %s',
+            '%s, %s, %s, %s, %s, %s',
             host_time,
             delta_micros_str,
             # Volts
@@ -256,4 +273,23 @@
             vshunt_list_str,
             # Power
             power_list_str,
+            # Empty Comment
+            ' ',
+        )
+
+    def _log_csv_gpio_assert(self, log: pw_log.log_decoder.Log) -> None:
+        if 'Header pin assert' not in log.message:
+            return
+
+        _CSV_LOG.info(
+            ', '.join(
+                [
+                    self._get_host_time(),
+                    '0',
+                    # Empty Volts, Current, and Power
+                    self.empty_measurements_str,
+                    # Comment is the log message
+                    log.message.replace(',', ' '),
+                ]
+            )
         )