Configure the FPGA over USB Serial

This CL adds provisioning the FPGA with a bitstream sent over USB
serial via the write_fpga.py script.

The implementation uses only raw serial input and does no
verification. Followup CLs will switch to using pw_hdlc or another way
to check integrity.

Change-Id: I714dfd8b797261b600e9e4b045a7327e82cac309
Reviewed-on: https://pigweed-review.googlesource.com/c/gonk/+/183228
Reviewed-by: Umang Shah <umangshah@google.com>
Commit-Queue: Auto-Submit <auto-submit@pigweed-service-accounts.iam.gserviceaccount.com>
Reviewed-by: Eric Holland <hollande@google.com>
Pigweed-Auto-Submit: Anthony DiGirolamo <tonymd@google.com>
diff --git a/README.md b/README.md
index 477707d..c9e7157 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@
 
 The build commands are defined in: `//tools/gonk_tools/build_project.py`.
 
-## Verilog:
+## Gonk Verilog:
 
 The Verilog build requires the following to be installed on Linux:
 
@@ -58,6 +58,39 @@
 The bitstream files will be written with the `.bin` extenson under
 `./out/gn/obj/fpga/*/*.bin`.
 
+## Gonk `fpga_config` Example
+
+Flash the stm32f7 and launch the `write_fpga.py` script on a bitstream file.
+
+### Flash with `dfu-util`
+
+1. Create a bin file from the elf. This will be automated later.
+
+   ```sh
+   arm-none-eabi-objcopy -O binary \
+    ./out/gn/arduino_size_optimized/obj/applications/fpga_config/bin/fpga_config.elf  \
+    ./out/gn/arduino_size_optimized/obj/applications/fpga_config/bin/fpga_config.bin
+   ```
+
+1. Unplug gonk from USB and replug with MODE button held down.
+
+1. Run dfu-util to flash.
+
+   ```sh
+   dfu-util -d 0483:df11 -s 0x08000000:leave \
+     --serial STM32FxSTM32 -a 0 \
+     -D ./out/gn/arduino_size_optimized/obj/applications/fpga_config/bin/fpga_config.bin
+   ```
+
+### Write the FPGA bitstream
+
+1. Write an FPGA bitstream with the `write_fpga.py` script:
+
+   ```sh
+   python ./tools/gonk_tools/write_fpga.py ./applications/fpga_config/fpga_blinky.bin
+   ```
+
+
 ## Gonk `spi_flash_test` Example
 
 ### Flash with `dfu-util`
diff --git a/applications/fpga_config/BUILD.gn b/applications/fpga_config/BUILD.gn
index 7bc6b85..934f794 100644
--- a/applications/fpga_config/BUILD.gn
+++ b/applications/fpga_config/BUILD.gn
@@ -30,7 +30,9 @@
     "$dir_pw_log",
     "$dir_pw_string",
     "$dir_pw_third_party/arduino:arduino_core_sources",
+    "//lib/fpga_control",
     "//lib/pin_config",
+    "//lib/spi_flash",
   ]
 
   ldflags = [ "-Wl,--print-memory-usage" ]
diff --git a/applications/fpga_config/fpga_blinky.bin b/applications/fpga_config/fpga_blinky.bin
new file mode 100644
index 0000000..442d775
--- /dev/null
+++ b/applications/fpga_config/fpga_blinky.bin
Binary files differ
diff --git a/applications/fpga_config/main.cc b/applications/fpga_config/main.cc
index 89adae2..d028721 100644
--- a/applications/fpga_config/main.cc
+++ b/applications/fpga_config/main.cc
@@ -12,37 +12,64 @@
 // License for the specific language governing permissions and limitations under
 // the License.
 
+#include <bitset>
 #include <cstdint>
 
 #include <Arduino.h>
 #include <SPI.h>
 
 #include "pw_log/log.h"
+#include "pw_result/result.h"
+#include "pw_string/format.h"
 
+#include "gonk/fpga_control.h"
 #include "gonk/pin_config.h"
+#include "gonk/spi_flash.h"
 
+using gonk::fpga_control::FpgaControl;
+using gonk::pin_config::FlashCS;
+using gonk::pin_config::ICE40Done;
 using gonk::pin_config::PinConfig;
 using gonk::pin_config::StatusLed;
+using gonk::spi_flash::SpiFlash;
 
 namespace {
 
 PinConfig pin_config = PinConfig();
+FpgaControl fpga_control = FpgaControl(&pin_config);
+SpiFlash spi_flash = SpiFlash(FlashCS, /*baudrate=*/1000000);
+
+void ice40_done_rising_isr() { PW_LOG_DEBUG("ICE40 Done: HIGH"); }
+
+void (*current_task)();
 
 } // namespace
 
-int main() {
-  pin_config.Init();
-  pin_config.InitFpgaPins();
-  pin_config.FpgaHalt();
-  pin_config.FpgaEnable();
-  delay(10);
+void IdleTask();
+void FpgaConfigTask();
 
-  uint16_t update_count = 0;
+void IdleTask() {
+  static uint32_t last_update = millis();
+  static uint32_t this_update = millis();
+  static uint16_t update_count = 0;
 
-  while (true) {
-    PW_LOG_INFO("update: %u", update_count);
-    delay(1000);
+  this_update = millis();
 
+  // Check for serial input.
+  if (Serial.available()) {
+    int data_in = Serial.read();
+    // Press enter to restart the FPGA config.
+    if (data_in == '\n') {
+      PW_LOG_INFO("Restarting FPGA Config.");
+      current_task = &FpgaConfigTask;
+    }
+  }
+
+  // Output an idle state heartbeat message each second.
+  if (this_update > last_update + 1000) {
+    PW_LOG_INFO("Idle update: %d", update_count);
+
+    last_update = this_update;
     update_count = (update_count + 1) % UINT16_MAX;
 
     // Toggle status LED each loop.
@@ -52,6 +79,34 @@
       digitalWrite(StatusLed, LOW);
     }
   }
+}
 
+void FpgaConfigTask() {
+  auto result = fpga_control.StartConfig();
+  if (result.ok()) {
+    current_task = &IdleTask;
+  } else {
+    PW_LOG_INFO("Restarting FPGA Config.");
+    current_task = &FpgaConfigTask;
+  }
+}
+
+int main() {
+  // Debug interrupt to watch when ICE40Done goes high.
+  attachInterrupt(/*pin=*/ICE40Done, /*callback=*/&ice40_done_rising_isr,
+                  /*mode=*/HIGH);
+
+  pin_config.Init();
+  pin_config.InitFpgaPins();
+  delay(500);
+
+  current_task = &FpgaConfigTask;
+
+  // Start the task loop.
+  while (true) {
+    current_task();
+  }
+
+  PW_UNREACHABLE;
   return 0;
 }
diff --git a/lib/fpga_control/BUILD.gn b/lib/fpga_control/BUILD.gn
new file mode 100644
index 0000000..61c7987
--- /dev/null
+++ b/lib/fpga_control/BUILD.gn
@@ -0,0 +1,37 @@
+# Copyright 2023 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.
+
+import("//build_overrides/pigweed.gni")
+
+import("$dir_pw_build/target_types.gni")
+
+config("default_config") {
+  include_dirs = [ "public" ]
+}
+
+pw_source_set("fpga_control") {
+  public_configs = [ ":default_config" ]
+  public = [ "public/gonk/fpga_control.h" ]
+
+  sources = [ "fpga_control.cc" ]
+
+  deps = [
+    "$dir_pw_log",
+    "$dir_pw_result",
+    "$dir_pw_span",
+    "$dir_pw_status",
+    "$dir_pw_third_party/arduino:arduino_core_sources",
+    "//lib/pin_config",
+  ]
+}
diff --git a/lib/fpga_control/fpga_control.cc b/lib/fpga_control/fpga_control.cc
new file mode 100644
index 0000000..7d4ff6a
--- /dev/null
+++ b/lib/fpga_control/fpga_control.cc
@@ -0,0 +1,184 @@
+#include "public/gonk/fpga_control.h"
+#include <Arduino.h>
+#include <bitset>
+#include <cstddef>
+#include <stdint.h>
+
+#include "gonk/fpga_control.h"
+
+#define PW_LOG_LEVEL PW_LOG_LEVEL_DEBUG
+#define PW_LOG_MODULE_NAME "FpgaControl"
+
+#include "pw_log/log.h"
+#include "pw_result/result.h"
+#include "pw_span/span.h"
+#include "pw_status/status.h"
+
+using gonk::pin_config::FlashCLK;
+using gonk::pin_config::FlashCS;
+using gonk::pin_config::FlashMISO;
+using gonk::pin_config::ICE40Done;
+using gonk::pin_config::PinConfig;
+
+namespace gonk::fpga_control {
+
+FpgaControl::FpgaControl(PinConfig *pin_config) : pin_config_(pin_config) {}
+
+Status FpgaControl::StartConfig() {
+  bitstream_file_position = 0;
+  memset(read_buffer, 0, 4);
+  memset(bitstream_file, 0, BitstreamFileLength);
+  bytes_written = 0;
+
+  WaitForBitstreamStart();
+  ReadBitstream();
+  auto verify_result = VerifyBitstream();
+  if (!verify_result.ok()) {
+    return verify_result;
+  }
+  return SendBitstreamToFpga();
+}
+
+Status FpgaControl::WaitForBitstreamStart() {
+  uint32_t last_update = millis();
+  uint32_t this_update = millis();
+
+  while (true) {
+    // Output a heartbeat message while waiting for data.
+    this_update = millis();
+    if (bytes_written == 0 && this_update > last_update + 1000) {
+      last_update = this_update;
+      PW_LOG_INFO("Waiting for bitstream");
+    }
+
+    // If no data waiting start over.
+    if (!Serial.available()) {
+      continue;
+    }
+
+    uint8_t data_in = Serial.read();
+    read_buffer[bytes_written % 4] = data_in;
+    bytes_written++;
+
+    PW_LOG_INFO("Discard byte: %d", bytes_written);
+    // If the last 4 bytes recieved match the start sequence, break.
+    if (read_buffer[(bytes_written + 0) % 4] == 0xff &&
+        read_buffer[(bytes_written + 1) % 4] == 0x00 &&
+        read_buffer[(bytes_written + 2) % 4] == 0x00 &&
+        read_buffer[(bytes_written + 3) % 4] == 0xff) {
+      PW_LOG_INFO("Start Sequence found.");
+      break;
+    }
+  }
+
+  return pw::OkStatus();
+}
+
+Status FpgaControl::ReadBitstream() {
+  char bitstream_buffer[4096];
+
+  while (true) {
+    size_t read_byte_count = Serial.readBytes(bitstream_buffer, 4096);
+    PW_LOG_INFO("Got bytes: %d", read_byte_count);
+    bytes_written += read_byte_count;
+
+    memcpy(&bitstream_file[bitstream_file_position], bitstream_buffer,
+           read_byte_count);
+    bitstream_file_position += read_byte_count;
+
+    if (bytes_written >= 135100) {
+      PW_LOG_INFO("All 135100 bytes recieved.");
+      break;
+    }
+  }
+
+  return pw::OkStatus();
+}
+
+Status FpgaControl::VerifyBitstream() {
+  // TODO(tonymd): Verify the bitstream with somehow, perhaps move to pw_hdlc.
+  PW_LOG_INFO("File ready: %d", bitstream_file_position);
+
+  PW_LOG_INFO("First 12 bytes");
+  for (int i = 0; i < 12; i++) {
+    PW_LOG_INFO("%x", bitstream_file[i]);
+  }
+
+  PW_LOG_INFO("Last 12 bytes");
+  for (int i = bitstream_file_position - 12; i < bitstream_file_position; i++) {
+    PW_LOG_INFO("%x", bitstream_file[i]);
+  }
+
+  return pw::OkStatus();
+}
+
+Status FpgaControl::SendBitstreamToFpga() {
+  PW_LOG_INFO("Sending bitstream file to the FPGA.");
+
+  pin_config_->SPIDisable();
+  pin_config_->FpgaSpiConfigMode();
+
+  digitalWrite(FlashCS, LOW);
+
+  // Write out the bitstream file similar to an SPI transaction but using MISO
+  // as the output pin.
+  for (int i = 0; i < bitstream_file_position; i++) {
+    uint8_t d = bitstream_file[i];
+    // Write out each 8 bits.
+    for (int b = 0; b < 8; b++) {
+      if (d & 0x80) {
+        digitalWrite(FlashCLK, LOW);
+        digitalWrite(FlashMISO, HIGH);
+        digitalWrite(FlashCLK, HIGH);
+      } else {
+        digitalWrite(FlashCLK, LOW);
+        digitalWrite(FlashMISO, LOW);
+        digitalWrite(FlashCLK, HIGH);
+      }
+      d <<= 1;
+    }
+    digitalWrite(FlashCLK, HIGH);
+  }
+
+  digitalWrite(FlashCS, HIGH);
+
+  uint32_t last_update = millis();
+  uint32_t this_update = millis();
+  bool done = false;
+  int ice40_done = 0;
+
+  // Wait for ICE40Done (CDONE) signal to go high
+  while (!done) {
+    // Write one clock pulse
+    digitalWrite(FlashCLK, LOW);
+    delayMicroseconds(100);
+    digitalWrite(FlashCLK, HIGH);
+    delayMicroseconds(100);
+
+    ice40_done = digitalRead(ICE40Done);
+    if (ice40_done == 1) {
+      PW_LOG_INFO("FPGA Config Success.");
+      break;
+    }
+
+    this_update = millis();
+    // If more than two seconds have passed something likely went wrong.
+    if (this_update > last_update + 2000) {
+      PW_LOG_ERROR("FPGA Config failed.");
+      last_update = this_update;
+      return pw::Status::Aborted();
+    }
+  }
+
+  // Send 50 more clock pulses to release release the SPI bus to user control.
+  for (int i = 0; i < 50; i++) {
+    digitalWrite(FlashCLK, LOW);
+    delayMicroseconds(200);
+    digitalWrite(FlashCLK, HIGH);
+    delayMicroseconds(200);
+  }
+
+  return pw::OkStatus();
+}
+
+} // namespace gonk::fpga_control
diff --git a/lib/fpga_control/public/gonk/fpga_control.h b/lib/fpga_control/public/gonk/fpga_control.h
new file mode 100644
index 0000000..3aa35f6
--- /dev/null
+++ b/lib/fpga_control/public/gonk/fpga_control.h
@@ -0,0 +1,37 @@
+#pragma once
+
+#include <stdint.h>
+
+#include "gonk/pin_config.h"
+#include "pw_result/result.h"
+#include "pw_span/span.h"
+#include "pw_status/status.h"
+
+using gonk::pin_config::PinConfig;
+using pw::span;
+using pw::Status;
+
+namespace gonk::fpga_control {
+
+const int BitstreamFileLength = 135100;
+
+class FpgaControl {
+public:
+  FpgaControl(PinConfig *pin_config);
+
+  Status StartConfig();
+  Status FpgaConfigFromSerial();
+  Status WaitForBitstreamStart();
+  Status ReadBitstream();
+  Status VerifyBitstream();
+  Status SendBitstreamToFpga();
+
+private:
+  PinConfig *pin_config_;
+  int bitstream_file_position = 0;
+  char bitstream_file[BitstreamFileLength];
+  uint8_t read_buffer[4] = {0, 0, 0, 0};
+  int bytes_written = 0;
+};
+
+} // namespace gonk::fpga_control
diff --git a/lib/pin_config/pin_config.cc b/lib/pin_config/pin_config.cc
index 61719e0..5489b52 100644
--- a/lib/pin_config/pin_config.cc
+++ b/lib/pin_config/pin_config.cc
@@ -35,6 +35,7 @@
   // Set FlashCS to signal the FPGA it should load the bitstream from external
   // flash.
   pinMode(FlashCS, INPUT_FLOATING);
+  delay(10);
 
   // Hold in reset for a small amount of time.
   digitalWrite(ICE40ResetN, LOW);
@@ -48,6 +49,41 @@
   digitalWrite(ICE40ResetN, LOW);
 }
 
+void PinConfig::FpgaSpiConfigMode() {
+  // Turn on Flash Hold so it ignores any SPI communication.
+  pinMode(FlashHold, OUTPUT);
+  pinMode(FlashWP, OUTPUT);
+  digitalWrite(FlashHold, LOW);
+  digitalWrite(FlashWP, LOW);
+  delay(1);
+
+  // Set FlashCS (ICE_SPI_SS) low and pulse ICE40ResetN. This signals the FPGA
+  // to boot into SPI config mode.
+  pinMode(ICE40ResetN, OUTPUT);
+  pinMode(FlashCS, OUTPUT);
+
+  // Pulse ICE40ResetN
+  digitalWrite(ICE40ResetN, LOW);
+  digitalWrite(FlashCS, LOW);
+  delay(10);
+  digitalWrite(ICE40ResetN, HIGH);
+
+  // Enalble GPIO control of MOSI, MISO and CLK.
+  pinMode(FlashMOSI, OUTPUT);
+  pinMode(FlashMISO, OUTPUT);
+  pinMode(FlashCLK, OUTPUT);
+  delay(1);
+
+  // Send 8 clock pulses.
+  for (int i = 0; i < 8; i++) {
+    digitalWrite(FlashCLK, LOW);
+    delayMicroseconds(100);
+    digitalWrite(FlashCLK, HIGH);
+    delayMicroseconds(100);
+  }
+  delay(10);
+}
+
 void PinConfig::FpgaEnable() { digitalWrite(ICE40ResetN, HIGH); }
 
 void PinConfig::SPIEnable() {
@@ -60,6 +96,23 @@
   // Set FlashCS as output with initial state.
   pinMode(FlashCS, OUTPUT);
   digitalWrite(FlashCS, HIGH);
+
+  pinMode(FlashCLK, OUTPUT);
+  pinMode(FlashMOSI, OUTPUT);
+  pinMode(FlashMISO, INPUT_FLOATING);
+}
+
+void PinConfig::SPIDisable() {
+  SPI.end();
+
+  pinMode(FlashCS, OUTPUT);
+  pinMode(FlashCLK, OUTPUT);
+  pinMode(FlashMOSI, OUTPUT);
+  pinMode(FlashMISO, INPUT_FLOATING);
+
+  digitalWrite(FlashCS, HIGH);
+  digitalWrite(FlashCLK, HIGH);
+  digitalWrite(FlashMOSI, HIGH);
 }
 
 } // 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 3f6d72a..06c541f 100644
--- a/lib/pin_config/public/gonk/pin_config.h
+++ b/lib/pin_config/public/gonk/pin_config.h
@@ -27,8 +27,10 @@
   // Halt the FPGA with the SPI bus released so SPI flash is accessible from the
   // MCU.
   void FpgaHalt();
+  void FpgaSpiConfigMode();
   void FpgaEnable();
   void SPIEnable();
+  void SPIDisable();
 
 private:
 };
diff --git a/tools/BUILD.gn b/tools/BUILD.gn
index 90dc3d4..41bb95a 100644
--- a/tools/BUILD.gn
+++ b/tools/BUILD.gn
@@ -26,6 +26,7 @@
     "gonk_tools/build_project.py",
     "gonk_tools/find_serial_port.py",
     "gonk_tools/presubmit_checks.py",
+    "gonk_tools/write_fpga.py",
   ]
   python_deps = [
     "$dir_pw_arduino_build/py",
diff --git a/tools/gonk_tools/write_fpga.py b/tools/gonk_tools/write_fpga.py
new file mode 100644
index 0000000..a3852f0
--- /dev/null
+++ b/tools/gonk_tools/write_fpga.py
@@ -0,0 +1,177 @@
+# Copyright 2023 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.
+"""Write a binary over serial to provision the Gonk FPGA."""
+
+import argparse
+from itertools import islice
+import operator
+from pathlib import Path
+import sys
+import time
+from typing import Optional
+
+from serial import Serial
+from serial.tools.list_ports import comports
+from serial.tools.miniterm import Miniterm
+
+
+def _parse_args():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        '-p',
+        '--port',
+        type=str,
+        help='Serial port path.',
+    )
+    parser.add_argument(
+        '-b',
+        '--baudrate',
+        type=int,
+        default=1000000,
+        help='Sets the baudrate for serial communication.',
+    )
+    parser.add_argument(
+        '--product',
+        default='GENERIC_F730R8TX',
+        help='Use first serial port matching this product name.',
+    )
+    parser.add_argument(
+        '--serial-number',
+        help='Use the first serial port matching this number.',
+    )
+    parser.add_argument(
+        'bitstream_file',
+        type=Path,
+        help='FPGA Bitstream file.',
+    )
+    return parser.parse_args()
+
+
+def get_serial_port(
+    product: Optional[str] = None,
+    serial_number: Optional[str] = None,
+) -> str:
+
+    ports = sorted(comports(), key=operator.attrgetter('device'))
+
+    # Print matching devices
+    for port in ports:
+        if (product is not None and port.product is not None
+                and product in port.product):
+            return port.device
+
+        if (serial_number is not None and port.serial_number is not None
+                and serial_number in port.serial_number):
+            return port.device
+
+    return ''
+
+
+FILE_LENGTH = 135100
+FILE_START_STR = 'FF 00 00 FF'
+SYNC_START_STR = '7E AA 99 7E'
+FILE_START_BYTES = bytes.fromhex(FILE_START_STR)
+SYNC_START_BYTES = bytes.fromhex(SYNC_START_STR)
+
+
+class IncorrectBinaryFormat(Exception):
+    """Exception raised when FPGA bitstream file is in an unexpected format."""
+
+
+def batched(iterable, n):
+    """Batch data into tuples of length n. The last batch may be shorter.
+
+       Example usage:
+
+       .. code-block:: pycon
+
+          >>> list(batched('ABCDEFG', 3))
+          ['ABC', 'DEF', 'G']
+    """
+    if n < 1:
+        raise ValueError('n must be at least one')
+    it = iter(iterable)
+    while batch := tuple(islice(it, n)):
+        yield batch
+
+
+def main(
+    baudrate: int,
+    bitstream_file: Path,
+    port: Optional[str] = None,
+    product: Optional[str] = None,
+    serial_number: Optional[str] = None,
+) -> int:
+    """Write a bitstream file over serial while monitoring output."""
+
+    # Check for valid bitstream file.
+    if not bitstream_file.is_file():
+        raise FileNotFoundError(f'\nUnable to load "{bitstream_file}"')
+
+    bitstream_bytes = bitstream_file.read_bytes()
+    if (len(bitstream_bytes) != 135100
+            or bitstream_bytes[0:8] != FILE_START_BYTES + SYNC_START_BYTES):
+        raise IncorrectBinaryFormat(
+            f'the bitstream file must be:\n'
+            f'  {FILE_LENGTH} bytes in length\n'
+            f'  Start with "{FILE_START_STR} {SYNC_START_STR}"')
+
+    # Init serial port.
+    if port is None:
+        port = get_serial_port(product=product, serial_number=serial_number)
+
+    serial_instance = Serial(port=port, baudrate=baudrate, timeout=320)
+
+    # Use pyserial miniterm to monitor recieved data.
+    miniterm = Miniterm(
+        serial_instance,
+        echo=False,
+        eol='crlf',
+        filters=(),
+    )
+    # Use Ctrl-C as the exit character. (Miniterm default is Ctrl-])
+    miniterm.exit_character = chr(0x03)
+    miniterm.set_rx_encoding('utf-8')
+    miniterm.set_tx_encoding('utf-8')
+
+    # Start monitoring serial data.
+    miniterm.start()
+
+    # Wait a couple seconds to print early log messages from Gonk.
+    time.sleep(2)
+
+    # Write out the bitstream in batches.
+    print('Sending bitstream...')
+    written_bytes: int = 0
+    for byte_batch in batched(bitstream_bytes, 8):
+        result = serial_instance.write(byte_batch)
+        if result:
+            written_bytes += result
+        serial_instance.flush()
+    print(f'Done sending bitstream. Wrote {written_bytes}')
+
+    # Wait for ctrl-c, then shutdown miniterm.
+    try:
+        miniterm.join(True)
+    except KeyboardInterrupt:
+        pass
+    sys.stderr.write('\n--- exit ---\n')
+    miniterm.join()
+    miniterm.close()
+
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main(**vars(_parse_args())))