Blog

  • Working With The Heltec V4 LoRA + GNSS

    Working With The Heltec V4 LoRA + GNSS

    The Heltec LoRA V4 is NOICE!!!!

    So while working on Project Aziz I upgraded by dev board from the Heltec V3 to the Heltec V4 with solar and GNSS. This simple hardware upgrade saved me countless hours of configuring and soldering. Of course in order to get familiar with the new microcontroller I wrote a simple C++ application to grab my GPS coordinates and display my speed and altitude.

    The application is very simple the Heltec V4 comes with a dedicated SH1.25-8Pin GNSS interface for the GNSS module and it spits out raw NMEA strings for parsing the GPS data (you can read more on NMEA here). Pin 38 is the RX pin for receiving data from the module, pin 34 powers it, pin 42 is the GNSS reset and pin 40 is for GNSS standby, lastly the baud rate is set at 9600. Knowing this let’s get started with the Platform.io C++ application.

    Platform.ini

    ; PlatformIO Project Configuration File
    ;
    ;   Build options: build flags, source filter
    ;   Upload options: custom upload port, speed and extra flags
    ;   Library options: dependencies, extra library storages
    ;   Advanced options: extra scripting
    ;
    ; Please visit documentation for the other options and examples
    ; https://docs.platformio.org/page/projectconf.html
    
    [env:heltec_wifi_lora_32_V3]
    platform = https://github.com/pioarduino/platform-espressif32/releases/download/stable/platform-espressif32.zip
    board = heltec_wifi_lora_32_V3
    framework = arduino
    lib_deps =
    	mikalhart/TinyGPSPlus@^1.1.0
    	adafruit/Adafruit SSD1306@^2.5.17
    	adafruit/Adafruit GFX Library@^1.12.6
    ; Route Serial to the USB-C port (native USB CDC). Without this flag, Serial
    ; prints go to UART0 pins 43/44 and the serial monitor shows nothing.
    build_flags = -DARDUINO_USB_CDC_ON_BOOT=1
    monitor_speed = 115200

    In the .ini file I created an environment for the heltec wifi lora 32 v3 because Platform.io vscode extension did not have the v4 board in the list however it is no issue for this application they are functionally the same for what I needed. Next I added the library dependencies:
    – TinyGPSPlus for parsing NMEA strings easily
    – Adafruit SSD1306 and Adafruit GFX Library to work with the onboard LCD

    With that out of the way let’s move on to the simple and elegant main.cpp file.

    Main.cpp

    #include <Wire.h>
    #include <Adafruit_GFX.h>
    #include <Adafruit_SSD1306.h>
    #include <TinyGPS++.h>
    
    // Onboard OLED Definitions for Heltec WiFi LoRa 32 V4
    #define SCREEN_WIDTH 128
    #define SCREEN_HEIGHT 64
    #define OLED_RESET    21 // OLED reset line is wired to GPIO 21 on this board
    #define SCREEN_ADDRESS 0x3C /// Standard I2C address for SSD1306
    #define VEXT_PIN      36 // Vext power rail switch (active LOW) — powers the OLED
    
    // Hardware Pins for GPS Module — Heltec V4 dedicated GNSS interface
    // (the SH1.25-8P socket; also broken out on header J3). Confirmed against
    // Meshtastic's heltec_v4 variant:
    //   GPIO 38 carries data toward the CPU (ESP32 receives here)
    //   GPIO 39 carries data toward the GPS
    //   GPIO 34 is the GNSS power enable, ACTIVE LOW — module is dead without it
    #define GPS_RX_PIN    38 // ESP32 receive line (data from GNSS module)
    #define GPS_TX_PIN    39 // ESP32 transmit line — unused, we listen only
    #define GNSS_EN_PIN   34 // GNSS power enable, drive LOW to power the module
    #define GNSS_RST_PIN  42 // GNSS reset — LOW for >100ms resets; hold HIGH to run
    #define GNSS_WAKE_PIN 40 // GNSS standby: HIGH = force awake, LOW = allow sleep
    #define GPS_BAUDRATE 9600 // Heltec L76K GNSS module factory default
    
    // Object Instances
    Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
    TinyGPSPlus gps;
    HardwareSerial gpsSerial(1); // Use HardwareSerial 1
    
    uint32_t gpsBaud = GPS_BAUDRATE;   // Actual baud found by detectGps()
    int8_t gpsRxPin = GPS_RX_PIN;      // Actual receive pin found by detectGps()
    unsigned long lastDiagDraw = 0;    // Last time the diagnostic screen was drawn
    
    // Listen (receive-only) on a candidate pin/baud and decide whether real NMEA
    // text is coming in: mostly printable characters with '$' sentence markers.
    // TX stays detached — we never send to the module, and keeping the second
    // GNSS line undriven avoids bus contention if the pin roles are swapped.
    bool probeGps(int8_t rxPin, uint32_t baud) {
      gpsSerial.end();
      gpsSerial.begin(baud, SERIAL_8N1, rxPin, -1);
      while (gpsSerial.available()) gpsSerial.read(); // drop stale garbage
      unsigned long start = millis();
      uint16_t total = 0, dollars = 0, printable = 0;
      while (millis() - start < 1300) {
        while (gpsSerial.available()) {
          char c = gpsSerial.read();
          total++;
          if (c == '$') dollars++;
          if ((c >= 32 && c <= 126) || c == '\r' || c == '\n') printable++;
        }
      }
      Serial.printf("Probe RX=%d @%lu: %u bytes, %u '$', %u printable\n",
                    rxPin, (unsigned long)baud, total, dollars, printable);
      return total >= 20 && dollars >= 2 && printable >= (uint16_t)((total * 9) / 10);
    }
    
    // The datasheet's GNSS_TX/GNSS_RX net names are direction-ambiguous, and
    // Heltec GNSS modules ship at different bauds (L76K: 9600, UC6580: 115200).
    // Try listening on both pins at every common baud until NMEA appears.
    bool detectGps() {
      static const uint32_t bauds[] = {9600, 115200, 38400, 57600, 19200, 4800};
      static const int8_t rxPins[] = {GPS_RX_PIN, GPS_TX_PIN};
      for (int8_t pin : rxPins) {
        for (uint32_t baud : bauds) {
          // Show progress on the splash screen's bottom line
          display.fillRect(0, 50, SCREEN_WIDTH, 14, SSD1306_BLACK);
          display.setCursor(10, 50);
          display.print("RX");
          display.print(pin);
          display.print(" @ ");
          display.print(baud);
          display.display();
          if (probeGps(pin, baud)) {
            gpsRxPin = pin;
            gpsBaud = baud;
            return true;
          }
        }
      }
      return false;
    }
    
    void updateDashboard() {
      display.clearDisplay();
      display.setTextSize(1);
      display.setTextColor(SSD1306_WHITE);
    
      // Row 1: Latitude & Longitude
      display.setCursor(0, 0);
      display.print("LAT: ");
      if (gps.location.isValid()) display.println(gps.location.lat(), 5);
      else display.println("SEARCHING");
    
      display.setCursor(0, 12);
      display.print("LON: ");
      if (gps.location.isValid()) display.println(gps.location.lng(), 5);
      else display.println("SEARCHING");
    
      // Draw a visual separator line
      display.drawFastHLine(0, 24, 128, SSD1306_WHITE);
    
      // Row 2: Speed (Formatted to look prominent)
      display.setCursor(0, 28);
      display.print("SPEED: ");
      if (gps.speed.isValid()) {
        display.print(gps.speed.mph(), 1); // Change to .kmph() if preferred
        display.println(" mph");
      } else {
        display.println("0.0");
      }
    
      // Row 3: Heading (Course)
      display.setCursor(0, 40);
      display.print("HEAD:  ");
      if (gps.course.isValid()) {
        display.print(gps.course.deg(), 1);
        display.print(" deg (");
        display.print(TinyGPSPlus::cardinal(gps.course.value()));
        display.println(")");
      } else {
        display.println("---");
      }
    
      // Row 4: Altitude & Satellites
      display.setCursor(0, 52);
      display.print("ALT:  ");
      if (gps.altitude.isValid()) {
        display.print(gps.altitude.feet(), 0); // Change to .meters() if preferred
        display.print(" ft");
      } else {
        display.print("---");
      }
      
      // Quick system health indicator in bottom right corner
      display.setCursor(95, 52);
      display.print("S:");
      display.print(gps.satellites.value());
    
      // Push buffer to the physical OLED panel
      display.display();
    }
    
    void setup() {
      Serial.begin(115200);
      
      // Power on the Vext rail first — it feeds both the OLED and the GNSS socket.
      // Switched by GPIO 36, active LOW.
      pinMode(VEXT_PIN, OUTPUT);
      digitalWrite(VEXT_PIN, LOW);
      delay(100); // Let the rail stabilize before talking to peripherals
    
      // Power the GNSS module — it has its own enable line in addition to Vext
      pinMode(GNSS_EN_PIN, OUTPUT);
      digitalWrite(GNSS_EN_PIN, LOW);
    
      // Release the GNSS module from reset and keep it awake
      pinMode(GNSS_RST_PIN, OUTPUT);
      digitalWrite(GNSS_RST_PIN, HIGH);
      pinMode(GNSS_WAKE_PIN, OUTPUT);
      digitalWrite(GNSS_WAKE_PIN, HIGH);
      delay(1000); // L76K warmup before it starts streaming NMEA
    
      // Initialize Onboard I2C for the OLED Screen
      // Heltec V4 onboard OLED I2C pins: SDA = 17, SCL = 18
      Wire.begin(17, 18);
      
      if(!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
        Serial.println(F("SSD1306 allocation failed"));
        for(;;); // Don't proceed, loop forever
      }
    
      // Initial Splash Screen
      display.clearDisplay();
      display.setTextSize(1);
      display.setTextColor(SSD1306_WHITE);
      display.setCursor(10, 20);
      display.println("GPS DASHBOARD");
      display.setCursor(10, 35);
      display.println("Detecting GPS...");
      display.display();
    
      // Find the module's receive pin and baud rate, then listen there
      if (!detectGps()) {
        // Nothing intelligible heard anywhere; fall back to defaults so the
        // loop() diagnostics can show what's (not) arriving
        gpsSerial.end();
        gpsSerial.begin(GPS_BAUDRATE, SERIAL_8N1, GPS_RX_PIN, -1);
      }
      Serial.printf("Using GPS RX=%d @ %lu\n", gpsRxPin, (unsigned long)gpsBaud);
    
      display.fillRect(0, 50, SCREEN_WIDTH, 14, SSD1306_BLACK);
      display.setCursor(10, 50);
      display.print("GPS RX");
      display.print(gpsRxPin);
      display.print(" @ ");
      display.print(gpsBaud);
      display.display();
    }
    
    void loop() {
      // Feed the GPS parser incoming characters from the hardware serial port
      while (gpsSerial.available() > 0) {
        char c = gpsSerial.read();
        Serial.write(c); // Echo raw NMEA to the USB serial monitor for debugging
        if (gps.encode(c)) {
          updateDashboard();
          lastDiagDraw = millis();
        }
      }
    
      // Until the first valid NMEA sentence parses, show what's actually
      // happening instead of a frozen splash screen
      if (gps.passedChecksum() == 0 && millis() - lastDiagDraw > 2000) {
        lastDiagDraw = millis();
        display.clearDisplay();
        display.setCursor(0, 0);
        if (gps.charsProcessed() < 10) {
          display.println("No GPS data!");
          display.println("Check GNSS module");
        } else {
          display.println("GPS data garbled");
          display.println("(baud mismatch?)");
        }
        display.print("rx/baud: ");
        display.print(gpsRxPin);
        display.print("/");
        display.println(gpsBaud);
        display.print("bytes: ");
        display.println((unsigned long)gps.charsProcessed());
        display.print("badck: ");
        display.println((unsigned long)gps.failedChecksum());
        display.display();
      }
    }

    1. Libraries and Include Files

    Every great firmware sketch starts with its dependencies. Here we include the libraries needed for communication, the screen, and GPS parsing.

    #include <Wire.h>
    #include <Adafruit_GFX.h>
    #include <Adafruit_SSD1306.h>
    #include <TinyGPS++.h>
    • Wire.h: The standard Arduino library used for I2C communication. This is how the ESP32 talks to the onboard OLED display.
    • Adafruit_GFX.h & Adafruit_SSD1306.h: These two libraries work together to control the OLED display. GFX handles the shapes and text rendering, while SSD1306 acts as the hardware driver for the specific screen.
    • TinyGPS++.h: An excellent, lightweight library that takes raw, messy NMEA data streams from the GPS module and converts them into easy-to-use variables like latitude, longitude, speed, and altitude.

    2. Hardware Definitions & Pin Mapping

    The Heltec V4 has a highly specific internal architecture. To turn on peripherals, we have to interact with its onboard power switches (Vext).

    // Onboard OLED Definitions for Heltec WiFi LoRa 32 V4
    #define SCREEN_WIDTH 128
    #define SCREEN_HEIGHT 64
    #define OLED_RESET    21 // OLED reset line is wired to GPIO 21 on this board
    #define SCREEN_ADDRESS 0x3C /// Standard I2C address for SSD1306
    #define VEXT_PIN      36 // Vext power rail switch (active LOW) — powers the OLED
    • SCREEN_WIDTH / SCREEN_HEIGHT: Defines the physical pixel boundaries of the display ($128 \times 64$).
    • OLED_RESET (GPIO 21): The Heltec board wires the screen’s reset pin to GPIO 21. We must toggle this to wake the screen up.
    • SCREEN_ADDRESS (0x3C): The standard factory I2C address for the SSD1306 OLED driver.
    • VEXT_PIN (GPIO 36): This is critical. The Heltec board features an external power rail switch (Vext). Driving GPIO 36 LOW closes the switch, supplying power to the OLED screen and the GNSS expansion socket.
    #define GPS_RX_PIN    38 // ESP32 receive line (data from GNSS module)
    #define GPS_TX_PIN    39 // ESP32 transmit line — unused, we listen only
    #define GNSS_EN_PIN   34 // GNSS power enable, drive LOW to power the module
    #define GNSS_RST_PIN  42 // GNSS reset — LOW for >100ms resets; hold HIGH to run
    #define GNSS_WAKE_PIN 40 // GNSS standby: HIGH = force awake, LOW = allow sleep
    #define GPS_BAUDRATE 9600 // Heltec L76K GNSS module factory default
    • GPS_RX_PIN / GPS_TX_PIN: Configured for the Heltec SH1.25-8P socket. The ESP32 listens for incoming GPS data on GPIO 38.
    • GNSS_EN_PIN (GPIO 34): In addition to the main Vext rail, the GNSS module has its own dedicated power enable pin. It must be driven LOW to turn the module on.
    • GNSS_RST_PIN & GNSS_WAKE_PIN: Controls the power-saving and operational state of the GPS chip. Keeping these HIGH keeps the chip awake and operational.
    • GPS_BAUDRATE: The baseline speed (9600 baud) that standard L76K GPS chips use.

    3. Global Instances & Variables

    Here, we spin up the software “objects” that we will interact with throughout the code.

    Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
    TinyGPSPlus gps;
    HardwareSerial gpsSerial(1); // Use HardwareSerial 1
    
    uint32_t gpsBaud = GPS_BAUDRATE;   // Actual baud found by detectGps()
    int8_t gpsRxPin = GPS_RX_PIN;      // Actual receive pin found by detectGps()
    unsigned long lastDiagDraw = 0;    // Last time the diagnostic screen was drawn
    • display(...): Initializes our screen object using our defined widths, heights, and I2C layout.
    • gps: Creates our TinyGPS++ instance to handle NMEA parsing.
    • gpsSerial(1): Tells the ESP32 to use its second internal hardware serial peripheral (UART 1) to listen to the GPS module, ensuring we don’t interfere with the main USB Serial Monitor (Serial).
    • Tracking variables: gpsBaud and gpsRxPin are mutable because our detection logic will overwrite them if it discovers the GPS is talking on a different pin or speed. lastDiagDraw handles non-blocking timers.

    4. The Autodetection Subsystem

    GPS modules from Heltec can ship with different baud rates (e.g., 9600 vs 115200) depending on the exact chip supplier. This smart function listens to a pin to see if valid GPS language is coming through.

    bool probeGps(int8_t rxPin, uint32_t baud) {
    gpsSerial.end();
    gpsSerial.begin(baud, SERIAL_8N1, rxPin, -1);
    while (gpsSerial.available()) gpsSerial.read(); // drop stale garbage
    unsigned long start = millis();
    uint16_t total = 0, dollars = 0, printable = 0;

    • gpsSerial.begin(...): Opens up the serial port on the tested pin and speed. Passing -1 for the TX pin keeps the ESP32 from transmitting, avoiding electrical conflicts.
    • while(...) gpsSerial.read(): Empties the buffer of any old data so we start fresh.
    • start = millis(): Captures the current timestamp to start a non-blocking 1.3-second test window.
    while (millis() - start < 1300) {
        while (gpsSerial.available()) {
          char c = gpsSerial.read();
          total++;
          if (c == '$') dollars++;
          if ((c >= 32 && c <= 126) || c == '\r' || c == '\n') printable++;
        }
      }
    • This loops for 1300 milliseconds, reading every character incoming from the GPS.
    • c == '$': GPS data strings always start with a dollar sign (e.g., $GPRMC). We count these.
    • printable++: Ensures the incoming data is readable ASCII text rather than random static or binary noise.
    Serial.printf("Probe RX=%d @%lu: %u bytes, %u '$', %u printable\n",
                    rxPin, (unsigned long)baud, total, dollars, printable);
      return total >= 20 && dollars >= 2 && printable >= (uint16_t)((total * 9) / 10);
    }
    • The Return Condition: It considers a pin/baud combo “successful” only if it received at least 20 bytes, saw at least 2 $ signs, and 90% of the data was valid, printable characters.
    bool detectGps() {
      static const uint32_t bauds[] = {9600, 115200, 38400, 57600, 19200, 4800};
      static const int8_t rxPins[] = {GPS_RX_PIN, GPS_TX_PIN};
      for (int8_t pin : rxPins) {
        for (uint32_t baud : bauds) {
          // Show progress on the splash screen's bottom line
          display.fillRect(0, 50, SCREEN_WIDTH, 14, SSD1306_BLACK);
          display.setCursor(10, 50);
          display.print("RX");
          display.print(pin);
          display.print(" @ ");
          display.print(baud);
          display.display();
          if (probeGps(pin, baud)) {
            gpsRxPin = pin;
            gpsBaud = baud;
            return true;
          }
        }
      }
      return false;
    }
    • This loops through an array of common baud rates and pin assignments. It prints what it’s trying to the OLED display in real-time. If probeGps returns true, it locks in those settings and immediately breaks out of the loop.

    5. UI Dashboard Display

    Once valid data is parsed, this function takes over to format it nicely on our $128 \times 64$ screen.

    void updateDashboard() {
    display.clearDisplay();
    display.setTextSize(1);
    display.setTextColor(SSD1306_WHITE);

    // Row 1: Latitude & Longitude
    display.setCursor(0, 0);
    display.print(“LAT: “);
    if (gps.location.isValid()) display.println(gps.location.lat(), 5);
    else display.println(“SEARCHING”);

    display.setCursor(0, 12);
    display.print(“LON: “);
    if (gps.location.isValid()) display.println(gps.location.lng(), 5);
    else display.println(“SEARCHING”);

    • gps.location.isValid(): This checks if the GPS actually has an active satellite lock. If it doesn’t, it safely displays "SEARCHING" instead of blank spaces or random coordinate glitches.
    // Draw a visual separator line
      display.drawFastHLine(0, 24, 128, SSD1306_WHITE);
    
      // Row 2: Speed (Formatted to look prominent)
      display.setCursor(0, 28);
      display.print("SPEED: ");
      if (gps.speed.isValid()) {
        display.print(gps.speed.mph(), 1); // Change to .kmph() if preferred
        display.println(" mph");
      } else {
        display.println("0.0");
      }
    • Draws a sharp horizontal line across the middle of the display to make it look like a professional dashboard layout, then prints the speed calculated in miles per hour.
    // Row 3: Heading (Course)
      display.setCursor(0, 40);
      display.print("HEAD:  ");
      if (gps.course.isValid()) {
        display.print(gps.course.deg(), 1);
        display.print(" deg (");
        display.print(TinyGPSPlus::cardinal(gps.course.value()));
        display.println(")");
      } else {
        display.println("---");
      }
    • TinyGPSPlus::cardinal(...): A handy built-in feature of TinyGPS that automatically converts degrees ($0^\circ – 360^\circ$) into human-readable compass headings like N, NW, or SE.
    // Row 4: Altitude & Satellites
      display.setCursor(0, 52);
      display.print("ALT:  ");
      if (gps.altitude.isValid()) {
        display.print(gps.altitude.feet(), 0); // Change to .meters() if preferred
        display.print(" ft");
      } else {
        display.print("---");
      }
      
      // Quick system health indicator in bottom right corner
      display.setCursor(95, 52);
      display.print("S:");
      display.print(gps.satellites.value());
    
      // Push buffer to the physical OLED panel
      display.display();
    }
    • Displays altitude in feet and shows an S: [number] count in the bottom right corner so the user knows exactly how many satellites are currently tracked.
    • display.display(): Up until this point, all drawings were handled in the ESP32’s memory buffer. This line pushes that complete image to the screen all at once, preventing screen flickering.

    6. The setup() Block

    The setup initializes all system pins and powers up the peripherals sequentially.

    void setup() {
      Serial.begin(115200);
      
      // Power on the Vext rail first — it feeds both the OLED and the GNSS socket.
      pinMode(VEXT_PIN, OUTPUT);
      digitalWrite(VEXT_PIN, LOW);
      delay(100); // Let the rail stabilize before talking to peripherals
    • Configures VEXT_PIN as an output and pulls it LOW to switch on the power rail. We give it a brief 100ms delay to ensure the voltage stabilizes.
    // Power the GNSS module — it has its own enable line in addition to Vext
      pinMode(GNSS_EN_PIN, OUTPUT);
      digitalWrite(GNSS_EN_PIN, LOW);
    
      // Release the GNSS module from reset and keep it awake
      pinMode(GNSS_RST_PIN, OUTPUT);
      digitalWrite(GNSS_RST_PIN, HIGH);
      pinMode(GNSS_WAKE_PIN, OUTPUT);
      digitalWrite(GNSS_WAKE_PIN, HIGH);
      delay(1000); // L76K warmup before it starts streaming NMEA
    • Fires up the specialized GNSS power configurations. Setting the reset and wake pins HIGH pulls the module out of hardware sleep. We wait 1 full second (delay(1000)) for the module to complete its boot sequence.
    // Initialize Onboard I2C for the OLED Screen
      Wire.begin(17, 18);
      
      if(!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
        Serial.println(F("SSD1306 allocation failed"));
        for(;;); // Don't proceed, loop forever
      }

    Wire.begin(17, 18): Initializes the I2C bus specifically mapping SDA to GPIO 17 and SCL to GPIO 18 (the Heltec V4 hardware standard layout). It then verifies if the screen initializes; if not, it hits an infinite safety loop to prevent executing broken code.

    // Initial Splash Screen
      display.clearDisplay();
      display.setTextSize(1);
      display.setTextColor(SSD1306_WHITE);
      display.setCursor(10, 20);
      display.println("GPS DASHBOARD");
      display.setCursor(10, 35);
      display.println("Detecting GPS...");
      display.display();
    • Prints a temporary splash screen on the OLED letting the user know the system is running its dynamic scanning routines.
    // Find the module's receive pin and baud rate, then listen there
      if (!detectGps()) {
        gpsSerial.end();
        gpsSerial.begin(GPS_BAUDRATE, SERIAL_8N1, GPS_RX_PIN, -1);
      }
      Serial.printf("Using GPS RX=%d @ %lu\n", gpsRxPin, (unsigned long)gpsBaud);
    
      display.fillRect(0, 50, SCREEN_WIDTH, 14, SSD1306_BLACK);
      display.setCursor(10, 50);
      display.print("GPS RX");
      display.print(gpsRxPin);
      display.print(" @ ");
      display.print(gpsBaud);
      display.display();
    }
    • Runs our custom scanning function detectGps(). If it fails to find anything, it safely falls back to standard hardware defaults. Finally, it paints a confirmation banner at the bottom of the screen showing what pin and baud rate were selected.

    7. The Main Operational Loop (loop())

    This runs constantly, chewing through serial data and handling real-time diagnostics if the hardware connection is unstable.

    void loop() {
      // Feed the GPS parser incoming characters from the hardware serial port
      while (gpsSerial.available() > 0) {
        char c = gpsSerial.read();
        Serial.write(c); // Echo raw NMEA to the USB serial monitor for debugging
        if (gps.encode(c)) {
          updateDashboard();
          lastDiagDraw = millis();
        }
      }
    • gpsSerial.available(): Checks if characters have arrived from the GPS module.
    • gps.encode(c): Feeds characters one-by-one into the TinyGPS string processor. When a complete sentence is successfully compiled and checked for errors, gps.encode() turns true, which instantly triggers our UI to refresh via updateDashboard().
    // Until the first valid NMEA sentence parses, show what's actually
      // happening instead of a frozen splash screen
      if (gps.passedChecksum() == 0 && millis() - lastDiagDraw > 2000) {
        lastDiagDraw = millis();
        display.clearDisplay();
        display.setCursor(0, 0);
        if (gps.charsProcessed() < 10) {
          display.println("No GPS data!");
          display.println("Check GNSS module");
        } else {
          display.println("GPS data garbled");
          display.println("(baud mismatch?)");
        }
        display.print("rx/baud: ");
        display.print(gpsRxPin);
        display.print("/");
        display.println(gpsBaud);
        display.print("bytes: ");
        display.println((unsigned long)gps.charsProcessed());
        display.print("badck: ");
        display.println((unsigned long)gps.failedChecksum());
        display.display();
      }
    }
    • The Troubleshooting Fallback: If the GPS module is active but hasn’t secured a single valid data packet yet (checked via gps.passedChecksum() == 0), this code checks if 2 seconds have passed and prints a live diagnostic screen.
    • gps.charsProcessed() < 10: If it isn’t processing any characters, it means the module is either physically disconnected or unpowered.
    • Otherwise, if characters are processing but no data checksums pass, it warns you that data is garbled (which points heavily toward an unexpected baud rate mismatch!). It outputs total parsed bytes and bad checksum calculations directly to the OLED screen so you can debug right in the field without a computer!

    Conclusion

    This was a fun and quick project (about an hour of my time) and I learned alot. Next I will be intregating GPS LoRA broadcasts over Reticulum with Project Aziz. If you enjoyed this tutorial please consider supporting this blog by purchasing a Heltec from my store!

  • Learn SWD Protocol: Key Features and Implementation

    Learn SWD Protocol: Key Features and Implementation

    What’s SWD?

    Serial Wire Debug (SWD) is a two-pin interface and protocol developed by ARM specifically for debugging and programming microcontrollers. It serves as a modern, space-saving alternative to traditional JTAG (Joint Test Action Group) interfaces, making it the standard debug method for ARM Cortex-M processors. Now why am I saying this to you and why does it matter? Well because I recently got into the world of vape hacking! My wife and I have a bunch of old vapes mostly RAZ brand and I have been taking out the batteries for a while to power my ESP32 based projects but I came across stories of people flashing the board that the vapes run on and the vast majority use SWD.

    I found this awesome tutorial from Praetorian where they hacked a nicotine vape I broke open one of my vapes (I have tons more I can work with without breaking later) and found my pins I need to connect to but there was ONNNNNNNE tiny problem. I did not have a SWD probe/debugger but I did have a sh!t ton of ESP32 😂 so I started coding one.

    SWD Protocol

    The Pins

    SWD drops the pin count significantly by utilizing a bi-directional data line. It requires only two mandatory pins for operation:

    • SWDIO (Serial Wire Data Input/Output): A bi-directional line used to transfer commands and data between the host debugger (like a J-Link, ST-Link, or CMSIS-DAP probe) and the target microcontroller.
    • SWCLK (Serial Wire Clock): A clock signal driven by the host debugger to synchronize data transfers.

    In practical implementations, you will also typically route:

    RESET: An optional pin to force a hardware reset on the target during connection or firmware flashing.

    GND: A common ground reference essential for signal integrity.

    VCC / VRef: To sense the target’s operating voltage so the debugger can match logic levels.

    SWO (Serial Wire Output): An optional third pin used for single-wire tracing. It enables real-time logging and profiling without stopping execution or consuming a standard UART peripheral.

    Knowing this I created a C++ Platformio program that consists of three files: main.cpp and swd.cpp and swd.h let’s dive into them one at a time.

    Main.cpp

    #include <Arduino.h>
    #include "swd.h"
    
    static SWD  swd;
    static bool connected = false;
    
    // ── helpers ───────────────────────────────────────────────────────────────────
    
    static bool parseU32(const char *s, uint32_t *out) {
        char *end;
        unsigned long v = strtoul(s, &end, 0);
        if (end == s) return false;
        *out = (uint32_t)v;
        return true;
    }
    
    // ── command handlers ──────────────────────────────────────────────────────────
    
    static void cmdConnect() {
        Serial.println("Connecting...");
        if (!swd.connect()) {
            Serial.println("FAIL — check SWDCLK/SWDIO wiring and target power");
            connected = false;
            return;
        }
        uint32_t id = 0;
        swd.dpRead(DP_IDCODE, &id);
        uint8_t  version  = (id >> 28) & 0xF;
        uint16_t partno   = (id >> 12) & 0xFFFF;
        uint16_t designer = (id >>  1) & 0x7FF;
        Serial.printf("IDCODE   : 0x%08X\n", id);
        Serial.printf("  Version : %u\n", version);
        Serial.printf("  Part    : 0x%03X\n", partno);
        Serial.printf("  Designer: 0x%03X%s\n", designer,
                      (designer == 0x23B) ? "  (ARM Ltd)" : "");
        Serial.println("Connected.");
        connected = true;
    }
    
    static void cmdIDCODE() {
        if (!connected) { Serial.println("Not connected"); return; }
        uint32_t id = 0;
        if (!swd.dpRead(DP_IDCODE, &id)) { Serial.println("FAIL"); return; }
        Serial.printf("IDCODE: 0x%08X\n", id);
    }
    
    static void cmdHalt() {
        if (!connected) { Serial.println("Not connected"); return; }
        if (!swd.halt())    { Serial.println("FAIL: halt"); return; }
        delay(10);
        if (swd.isHalted()) {
            uint32_t pc = 0;
            swd.readCoreReg(15, &pc);
            Serial.printf("Halted. PC = 0x%08X\n", pc);
        } else {
            Serial.println("Halt sent (target may not have stopped yet)");
        }
    }
    
    static void cmdResume() {
        if (!connected) { Serial.println("Not connected"); return; }
        if (!swd.resume()) { Serial.println("FAIL: resume"); return; }
        Serial.println("Running.");
    }
    
    static void cmdStep() {
        if (!connected) { Serial.println("Not connected"); return; }
        if (!swd.isHalted()) { Serial.println("Target must be halted first"); return; }
        if (!swd.step()) { Serial.println("FAIL: step"); return; }
        delay(2);
        uint32_t pc = 0;
        swd.readCoreReg(15, &pc);
        Serial.printf("Step. PC = 0x%08X\n", pc);
    }
    
    static void cmdReset() {
        if (!connected) { Serial.println("Not connected"); return; }
        swd.sysReset();
        delay(150);
        // Re-init DAP after system reset
        connected = swd.connect();
        Serial.println(connected ? "Reset OK. Reconnected." : "FAIL: reconnect after reset");
    }
    
    static void cmdRead(const char *args) {
        if (!connected) { Serial.println("Not connected"); return; }
        uint32_t addr;
        if (!parseU32(args, &addr)) { Serial.println("Usage: read <addr>"); return; }
        uint32_t val = 0;
        if (!swd.memRead32(addr, &val)) { Serial.println("FAIL: read error"); return; }
        Serial.printf("[0x%08X] = 0x%08X\n", addr, val);
    }
    
    static void cmdWrite(const char *args) {
        if (!connected) { Serial.println("Not connected"); return; }
        char a[20] = {}, b[20] = {};
        if (sscanf(args, "%19s %19s", a, b) != 2) {
            Serial.println("Usage: write <addr> <value>"); return;
        }
        uint32_t addr, val;
        if (!parseU32(a, &addr) || !parseU32(b, &val)) {
            Serial.println("Bad arguments"); return;
        }
        if (!swd.memWrite32(addr, val)) { Serial.println("FAIL: write error"); return; }
        Serial.printf("[0x%08X] <= 0x%08X  OK\n", addr, val);
    }
    
    static void cmdDump(const char *args) {
        if (!connected) { Serial.println("Not connected"); return; }
        char a[20] = {}, b[20] = {};
        uint32_t addr, count = 16;
        int n = sscanf(args, "%19s %19s", a, b);
        if (n < 1 || !parseU32(a, &addr)) {
            Serial.println("Usage: dump <addr> [words]"); return;
        }
        if (n >= 2) parseU32(b, &count);
        if (count > 256) count = 256;
    
        for (uint32_t i = 0; i < count; i++) {
            if (i % 4 == 0) Serial.printf("\n0x%08X:", addr + i * 4);
            uint32_t val = 0;
            if (!swd.memRead32(addr + i * 4, &val)) { Serial.println("  ERR"); return; }
            Serial.printf("  %08X", val);
        }
        Serial.println();
    }
    
    static const char *REG_NAMES[] = {
        "R0", "R1", "R2",  "R3",  "R4",  "R5",  "R6", "R7",
        "R8", "R9", "R10", "R11", "R12", "SP",  "LR", "PC", "xPSR"
    };
    
    static void cmdRegs() {
        if (!connected) { Serial.println("Not connected"); return; }
        if (!swd.isHalted()) { Serial.println("Target must be halted. Use 'halt' first."); return; }
        for (int i = 0; i <= 16; i++) {
            uint32_t val = 0;
            if (swd.readCoreReg(i, &val))
                Serial.printf("  %-5s : 0x%08X\n", REG_NAMES[i], val);
            else
                Serial.printf("  %-5s : ERROR\n", REG_NAMES[i]);
        }
    }
    
    static void cmdStatus() {
        if (!connected) { Serial.println("Not connected"); return; }
        uint32_t dhcsr = 0;
        if (!swd.memRead32(DHCSR, &dhcsr)) { Serial.println("FAIL"); return; }
        Serial.printf("DHCSR = 0x%08X — %s\n", dhcsr,
                      (dhcsr & DHCSR_S_HALT) ? "HALTED" : "RUNNING");
    }
    
    static void cmdClear() {
        swd.clearErrors();
        Serial.println("SWD error flags cleared.");
    }
    
    static void cmdResetPin(const char *args) {
        if (!*args) {
            uint8_t p = swd.getResetPin();
            if (p == SWD_NO_PIN) Serial.println("nRESET pin: not set");
            else                 Serial.printf("nRESET pin: GPIO%d\n", p);
            return;
        }
        uint32_t pin;
        if (!parseU32(args, &pin)) { Serial.println("Usage: resetpin <gpio>  (or 'none')"); return; }
        swd.setResetPin((uint8_t)pin);
        Serial.printf("nRESET → GPIO%u\n", pin);
    }
    
    static void cmdNReset() {
        if (swd.getResetPin() == SWD_NO_PIN) {
            Serial.println("No nRESET pin configured. Use: resetpin <gpio>");
            return;
        }
        Serial.println("Asserting nRESET...");
        swd.assertReset();
        delay(50);
        swd.releaseReset();
        delay(150);
        Serial.println("Released. Reconnecting...");
        connected = swd.connect();
        Serial.println(connected ? "Reconnected." : "FAIL: reconnect after reset");
    }
    
    static void cmdConnectHalt() {
        if (swd.getResetPin() == SWD_NO_PIN) {
            Serial.println("Requires nRESET pin. Use: resetpin <gpio>");
            return;
        }
        Serial.println("Connecting under reset...");
        if (!swd.connectHalt()) {
            Serial.println("FAIL — check wiring and target power");
            connected = false;
            return;
        }
        connected = true;
        uint32_t pc = 0;
        swd.readCoreReg(15, &pc);
        Serial.printf("Halted at reset vector. PC = 0x%08X\n", pc);
    }
    
    // GPIO pins exposed on the XIAO ESP32-S3 headers
    static const uint8_t SCAN_PINS[]  = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 43, 44 };
    static const uint8_t SCAN_NPIN    = sizeof(SCAN_PINS);
    
    static void cmdPinset(const char *args) {
        char a[8] = {}, b[8] = {};
        if (sscanf(args, "%7s %7s", a, b) != 2) {
            Serial.println("Usage: pinset <clk_gpio> <io_gpio>");
            Serial.printf("Current: SWDCLK=GPIO%d  SWDIO=GPIO%d\n",
                          swd.getCLKPin(), swd.getIOPin());
            return;
        }
        uint32_t clk, io;
        if (!parseU32(a, &clk) || !parseU32(b, &io)) {
            Serial.println("Bad pin numbers"); return;
        }
        swd.setPins((uint8_t)io, (uint8_t)clk);
        connected = false;
        Serial.printf("Pins set: SWDCLK=GPIO%u  SWDIO=GPIO%u\n", clk, io);
        Serial.println("Run 'connect' to try the new pins.");
    }
    
    // Try every CLK/IO pair from SCAN_PINS until we get a valid IDCODE.
    static void cmdScan() {
        Serial.println("Scanning all GPIO pairs for SWD target...");
        Serial.println("(this takes ~30 s — the vape must be powered)");
        connected = false;
    
        for (uint8_t ci = 0; ci < SCAN_NPIN; ci++) {
            for (uint8_t ii = 0; ii < SCAN_NPIN; ii++) {
                if (ci == ii) continue;
                uint8_t clk = SCAN_PINS[ci];
                uint8_t io  = SCAN_PINS[ii];
                Serial.printf("  CLK=GPIO%-2d  IO=GPIO%-2d ... ", clk, io);
    
                swd.setPins(io, clk);
                uint32_t id = swd.probe();
                if (id) {
                    Serial.printf("FOUND!  IDCODE=0x%08X\n", id);
                    Serial.printf("Run: pinset %d %d\n", clk, io);
                    Serial.println("Then: connect");
                    return;
                }
                Serial.println("--");
            }
        }
        Serial.println("No SWD target found on any pair.");
        Serial.println("Check: target powered? GND shared? correct USB-C pins?");
        // Restore defaults
        swd.setPins(SWD_DEFAULT_IO, SWD_DEFAULT_CLK);
    }
    
    static void printHelp() {
        Serial.println(
            "\nConnection:\n"
            "  connect              line reset → IDCODE → power up → MEM-AP\n"
            "  connecthalt          connect under nRESET, halt at first instruction\n"
            "  scan                 sweep all GPIO pairs to find a live SWD target\n"
            "  pinset <clk> <io>    reassign SWDCLK / SWDIO GPIOs at runtime\n"
            "  resetpin [gpio]      set nRESET GPIO (show current if no arg)\n"
            "\nExecution:\n"
            "  halt                 halt the CPU\n"
            "  resume               resume execution\n"
            "  step                 single-step one instruction (must be halted)\n"
            "  nreset               pulse nRESET low then reconnect\n"
            "  reset                software reset via AIRCR SYSRESETREQ\n"
            "  status               show DHCSR — running or halted\n"
            "\nInspection:\n"
            "  regs                 dump R0-R15, xPSR  (must be halted)\n"
            "  idcode               re-read raw IDCODE\n"
            "  read  <addr>         read one 32-bit word\n"
            "  write <addr> <val>   write one 32-bit word\n"
            "  dump  <addr> [n]     hex dump n words (default 16, max 256)\n"
            "  clear                clear SWD sticky error flags (ABORT)\n"
            "\nDefault pins (XIAO ESP32-S3):\n"
            "  SWDCLK → D8 / GPIO7\n"
            "  SWDIO  → D9 / GPIO8\n"
            "  nRESET → not set    (use 'resetpin <gpio>' to assign)\n"
        );
    }
    
    // ── command dispatcher ────────────────────────────────────────────────────────
    
    static void dispatch(char *line) {
        while (*line == ' ') line++;
        if (!*line) return;
    
        char *cmd = line;
        char *args = line;
        while (*args && *args != ' ') args++;
        if (*args) { *args++ = '\0'; }
        while (*args == ' ') args++;
    
        if      (!strcmp(cmd, "connect")) cmdConnect();
        else if (!strcmp(cmd, "idcode"))  cmdIDCODE();
        else if (!strcmp(cmd, "halt"))    cmdHalt();
        else if (!strcmp(cmd, "resume"))  cmdResume();
        else if (!strcmp(cmd, "step"))    cmdStep();
        else if (!strcmp(cmd, "reset"))   cmdReset();
        else if (!strcmp(cmd, "status"))  cmdStatus();
        else if (!strcmp(cmd, "regs"))    cmdRegs();
        else if (!strcmp(cmd, "read"))    cmdRead(args);
        else if (!strcmp(cmd, "write"))   cmdWrite(args);
        else if (!strcmp(cmd, "dump"))    cmdDump(args);
        else if (!strcmp(cmd, "clear"))        cmdClear();
        else if (!strcmp(cmd, "pinset"))       cmdPinset(args);
        else if (!strcmp(cmd, "scan"))         cmdScan();
        else if (!strcmp(cmd, "resetpin"))     cmdResetPin(args);
        else if (!strcmp(cmd, "nreset"))       cmdNReset();
        else if (!strcmp(cmd, "connecthalt"))  cmdConnectHalt();
        else if (!strcmp(cmd, "help"))         printHelp();
        else Serial.printf("Unknown: '%s'  (type 'help')\n", cmd);
    
        Serial.print("> ");
    }
    
    // ── Arduino entry points ──────────────────────────────────────────────────────
    
    static char   lineBuf[128];
    static uint8_t lineLen = 0;
    
    void setup() {
        Serial.begin(115200);
        // USB CDC: wait for the host to open the port (up to ~5 s), then greet.
        // Without this the banner gets sent before the terminal is ready.
        uint32_t t = millis();
        while (!Serial && (millis() - t < 5000)) delay(10);
        Serial.println("\n=== ESP32-S3 SWD Debugger ===");
        printHelp();
        Serial.print("\n> ");
    }
    
    void loop() {
        while (Serial.available()) {
            char c = (char)Serial.read();
            if (c == '\r') continue;
            if (c == '\n') {
                lineBuf[lineLen] = '\0';
                Serial.println();
                dispatch(lineBuf);
                lineLen = 0;
            } else if (c == '\b' || c == 127) {
                if (lineLen > 0) { lineLen--; Serial.print("\b \b"); }
            } else if (lineLen < sizeof(lineBuf) - 1) {
                lineBuf[lineLen++] = c;
                Serial.print(c);
            }
        }
    }

    1. The Terminal Engine and Dispatcher

    The firmware relies on standard Arduino entry points to drive a non-blocking character parser. Inside setup(), the code initializes the hardware serial interface at 115200 baud and loops until an active USB CDC connection is established, preventing the initial banner from dropping before a terminal viewer opens.

    The main loop() continuously monitors Serial.available(). It streams incoming characters into a raw character buffer (lineBuf), tracks length limits to prevent buffer overflows, and safely handles backspaces (\b or ASCII 127) by dropping the buffer index and echoing terminal erasure commands (\b \b).

    When a newline character (\n) is intercepted, the engine seals the string with a null terminator (\0) and forwards it to the dispatch() routing function. The dispatcher strips leading whitespace, tokenizes the raw text into distinct command and argument pointers by searching for space boundaries, and matches the command string via strcmp to its respective execution handler.

    2. Low-Level SWD Discovery and Connection

    Target acquisition is managed by cmdConnect() and cmdScan(). When the connect command is issued, swd.connect() triggers a low-level line reset sequence (driving SWDIO high for more than 50 clock cycles) to wake up the target’s Debug Port (DP). If successful, it reads the raw 32-bit DP_IDCODE from the target using swd.dpRead().

    The firmware then executes bitwise shifts and masking operations to extract and print the silicon’s metadata:

    • version: Isolated by shifting 28 bits right and applying a 0xF mask.
    • partno: Isolated by shifting 12 bits right and applying a 0xFFFF mask.
    • designer: Isolated by shifting 1 bit right and applying a 0x7FF mask (checking if it matches ARM Ltd’s JEDEC identity code 0x23B).

    If the exact pinout of the target board is unknown, cmdScan() implements a automated hardware prober. It runs nested loops across a predefined array of physical GPIO headers (SCAN_PINS), dynamically reassigning clock (SWDCLK) and data (SWDIO) lines via swd.setPins() before firing a low-overhead swd.probe() request. If a valid IDCODE drops out of the sweep, the firmware breaks execution and outputs the precise pin parameters required to establish a stable link.

    3. Execution Control and Memory Architecture

    Once connected evaluates to true, the firmware can directly mutate target execution and state through the processor’s Debug Halting Control and Status Register (DHCSR). Calling cmdHalt() updates the debug registers to pause the CPU core. Once halted, it calls swd.readCoreReg(15, &pc) to query core register 15—the Program Counter (PC)—revealing exactly where execution froze. Similarly, cmdStep() advances the target by a single assembly instruction before instantly re-reading the updated Program Counter.

    For direct physical memory access, commands like read, write, and dump wrap character inputs into numerical values using parseU32 (a thin wrapper around strtoul). These handlers route transactions directly through the target’s internal bus via the Memory Access Port (MEM-AP).

    • cmdRead() and cmdWrite() invoke swd.memRead32() and swd.memWrite32() respectively to read or write individual 32-bit words at explicit memory addresses.
    • cmdDump() loops through consecutive 32-bit words up to a hard cap of 256, formatting the data into a structured hex grid over the serial console to allow rapid analysis of RAM arrays, flash boundaries, or peripheral registers at runtime.

    swd.h

    #pragma once
    #include <Arduino.h>
    
    // ACK values
    #define SWD_ACK_OK     0x01
    #define SWD_ACK_WAIT   0x02
    #define SWD_ACK_FAULT  0x04
    #define SWD_ACK_ERROR  0xFF  // parity mismatch or comms failure
    
    // DP register addresses (word-aligned, bits [3:2] used in packet)
    #define DP_IDCODE    0x00  // R
    #define DP_ABORT     0x00  // W
    #define DP_CTRLSTAT  0x04
    #define DP_SELECT    0x08
    #define DP_RDBUFF    0x0C
    
    // CTRL/STAT bits
    #define CTRL_CSYSPWRUPACK  (1UL << 31)
    #define CTRL_CSYSPWRUPREQ  (1UL << 30)
    #define CTRL_CDBGPWRUPACK  (1UL << 29)
    #define CTRL_CDBGPWRUPREQ  (1UL << 28)
    #define CTRL_STICKYERR     (1UL << 5)
    #define CTRL_STICKYORUN    (1UL << 1)
    
    // MEM-AP register offsets (bank 0)
    #define AP_CSW  0x00
    #define AP_TAR  0x04
    #define AP_DRW  0x0C
    
    // CSW value: DbgSwEnable off (set by HW), privileged, word, single-increment
    #define AP_CSW_VALUE  0x23000052UL
    
    // Cortex-M debug register addresses
    #define DHCSR  0xE000EDF0UL  // Debug Halting Control & Status
    #define DCRSR  0xE000EDF4UL  // Debug Core Register Selector
    #define DCRDR  0xE000EDF8UL  // Debug Core Register Data
    #define DEMCR  0xE000EDFCUL  // Debug Exception & Monitor Control
    #define AIRCR  0xE000ED0CUL  // Application Interrupt & Reset Control
    
    // DHCSR bits
    #define DHCSR_DBGKEY     0xA05F0000UL
    #define DHCSR_C_DEBUGEN  (1UL << 0)
    #define DHCSR_C_HALT     (1UL << 1)
    #define DHCSR_C_STEP     (1UL << 2)
    #define DHCSR_C_MASKINTS (1UL << 3)
    #define DHCSR_S_REGRDY   (1UL << 16)
    #define DHCSR_S_HALT     (1UL << 17)
    
    // DEMCR bits
    #define DEMCR_TRCENA        (1UL << 24)
    #define DEMCR_VC_CORERESET  (1UL << 0)   // halt on reset vector
    
    // Sentinel meaning "no pin assigned"
    #define SWD_NO_PIN  0xFF
    
    // Default pins for XIAO ESP32-S3
    #define SWD_DEFAULT_CLK  7   // D8 / GPIO7
    #define SWD_DEFAULT_IO   8   // D9 / GPIO8
    
    class SWD {
    public:
        SWD(uint8_t swdio = SWD_DEFAULT_IO, uint8_t swdclk = SWD_DEFAULT_CLK);
    
        // Reconfigure SWD pins at runtime (releases old pins first)
        void setPins(uint8_t swdio, uint8_t swdclk);
        uint8_t getIOPin()  const { return _io;  }
        uint8_t getCLKPin() const { return _clk; }
    
        // Optional nRESET line (open-drain, active-low). Pass SWD_NO_PIN to disable.
        void setResetPin(uint8_t pin);
        uint8_t getResetPin() const { return _rst; }
        void assertReset();   // drive nRESET LOW
        void releaseReset();  // float nRESET (let pull-up take it HIGH)
    
        // Full connection sequence: line reset → IDCODE check → power up → MEM-AP
        bool connect();
    
        // Connect under reset: assert nRESET, init DAP, arm vector-catch, release.
        // Target halts at the very first instruction after reset. Requires nRESET pin.
        bool connectHalt();
    
        // Probe-only connect: returns IDCODE if SWD link is found, 0 on failure.
        // Does NOT power up or set up MEM-AP — use connect() for full init.
        uint32_t probe();
    
        // DP-level access
        bool dpRead(uint8_t addr, uint32_t *out);
        bool dpWrite(uint8_t addr, uint32_t val);
    
        // AP-level access (handles SELECT automatically)
        bool apRead(uint8_t ap, uint8_t reg, uint32_t *out);
        bool apWrite(uint8_t ap, uint8_t reg, uint32_t val);
    
        // Memory access via MEM-AP 0
        bool memRead32(uint32_t addr, uint32_t *out);
        bool memWrite32(uint32_t addr, uint32_t val);
        bool memRead(uint32_t addr, uint32_t *buf, size_t nWords);
    
        // Cortex-M debug operations (target must be connected + powered)
        bool halt();
        bool resume();
        bool step();
        bool sysReset();
        bool isHalted();
    
        // Core register access (target must be halted)
        bool readCoreReg(uint8_t reg, uint32_t *out);
        bool writeCoreReg(uint8_t reg, uint32_t val);
    
        // Clear sticky error flags in DP ABORT
        void clearErrors();
    
    private:
        uint8_t _io, _clk;
        uint8_t _rst;              // nRESET pin, SWD_NO_PIN if unused
        uint8_t _selAP, _selBank;  // cached SELECT state
    
        void ioOut();
        void ioIn();
        void writeBit(uint8_t b);
        uint8_t readBit();
        void clkPulse();
    
        void lineReset();
        void jtagToSWD();
        void idleCycles(uint8_t n);
    
        uint8_t buildReq(bool ap, bool rd, uint8_t reg);
        uint8_t xfer(bool ap, bool rd, uint8_t reg, uint32_t *data);
    
        bool selectAP(uint8_t ap, uint8_t bank);
        bool powerUp();
        bool setupMEMAP();
    };

    1. Protocol Tokens and Register Footprints

    The top half of the header uses standard preprocessor directives (#define) to build a rigid dictionary of the ARM architecture’s hardware requirements. The first block establishes the specific response bytes the target can return during a transaction turnaround, such as SWD_ACK_OK or SWD_ACK_WAIT.

    Following this are the hardcoded addresses for both the Debug Port (DP) and Access Port (AP) registers:

    • DP Registers: DP_IDCODE reads device identification, while DP_SELECT handles routing between different internal Access Ports and register banks.
    • AP Registers: Low-level memory bus operations route through the Memory Access Port using AP_CSW (Control Status Word), AP_TAR (Transfer Address Register), and AP_DRW (Data Read/Write). The specific bitmask AP_CSW_VALUE instructs the target to execute memory accesses as clean 32-bit words with automatic address incrementing turned on.
    • Cortex-M Debug Registers: The final macro block maps the memory locations for the Core Debug registers located at the top of the processor’s system space (the 0xE000EDF0 block). This includes DHCSR, which forces the CPU to halt or single-step, and DEMCR, which allows the host to trap the processor at its very first boot instruction using vector catching (DEMCR_VC_CORERESET).

    2. The Public High-Level API

    The public block of the SWD class exposes a clean interface for application-level code to interrogate a target device without needing to understand raw bit arrangements. The lifecycle begins with initialization, where the constructor assigns the default clock and data pins (configured here for the Seeed Studio XIAO ESP32-S3’s hardware map), while setPins() and setResetPin() manage physical wire reassignments at runtime. Physical reset control is exposed through open-drain abstractions (assertReset and releaseReset) to cleanly float or pull down the target’s physical nRESET line.

    Connection management is divided into three functional paradigms:

    • probe() acts as a fast, low-overhead link verifier that checks for an IDCODE without modifying system state.
    • connect() executes the full operational sequence required to power up the internal debug domains and latch onto the core.
    • connectHalt() implements a specialized sequence that pins the hardware reset line, arms a vector catch, and releases the reset to catch the processor before any firmware can execute.

    Once an active connection is established, developers are provided with high-level functions like halt(), resume(), step(), and register access blocks (readCoreReg/writeCoreReg). These are backed by automated memory methods like memRead32() and memWrite32(), which let you read and write data across the target’s entire internal memory map through a single call.

    3. The Private Bit-Banging Engine

    The private partition contains the raw, low-overhead state tracking and driver primitives needed to translate high-level commands into physical voltage fluctuations on the wire. The integer variables _selAP and _selBank act as a local software cache. By remembering which Access Port and register bank the host is currently talking to, the code avoids sending redundant DP_SELECT write packets over the wire, optimizing data throughput during massive memory dumps.

    The lower methods handle wire-level operations:

    • ioOut() and ioIn() rapidly reconfigure the microcontroller’s shared SWDIO data pin between input and output modes.
    • writeBit(), readBit(), and clkPulse() form the basic clock-synchronized IO foundation.
    • lineReset() and jtagToSWD() output the exact bitstream signatures (such as driving the data line high for 50+ cycles followed by a specific 16-bit switching key) required to reset the wire state machine and kick the target out of JTAG mode into SWD mode.
    • buildReq() and xfer() assemble the mandatory 8-bit request frame (comprising start bits, AP/DP selectors, read/write flags, address bits, and parity), transmit the command, handle the wire turnaround phase, and verify incoming target acknowledgements and data parity bits.

    swd.cpp

    #include "swd.h"
    
    // Half-period in µs → clock frequency ≈ 1/(2*HALF_US) Hz
    // 1 µs → ~500 kHz; safe for most Cortex-M targets
    #define HALF_US 1
    
    SWD::SWD(uint8_t swdio, uint8_t swdclk)
        : _io(swdio), _clk(swdclk), _rst(SWD_NO_PIN),
          _selAP(0xFF), _selBank(0xFF) {}
    
    void SWD::setPins(uint8_t swdio, uint8_t swdclk) {
        pinMode(_io,  INPUT);
        pinMode(_clk, INPUT);
        _io      = swdio;
        _clk     = swdclk;
        _selAP   = 0xFF;
        _selBank = 0xFF;
    }
    
    void SWD::setResetPin(uint8_t pin) {
        if (_rst != SWD_NO_PIN) pinMode(_rst, INPUT);  // release old pin
        _rst = pin;
        if (_rst != SWD_NO_PIN) pinMode(_rst, INPUT);  // start released (hi-Z)
    }
    
    // nRESET is open-drain active-low: drive LOW to assert, hi-Z to release.
    void SWD::assertReset() {
        if (_rst == SWD_NO_PIN) return;
        pinMode(_rst, OUTPUT);
        digitalWrite(_rst, LOW);
    }
    
    void SWD::releaseReset() {
        if (_rst == SWD_NO_PIN) return;
        pinMode(_rst, INPUT);  // let the target's pull-up take it HIGH
    }
    
    // ── pin helpers ──────────────────────────────────────────────────────────────
    
    void SWD::ioOut() { pinMode(_io, OUTPUT); }
    void SWD::ioIn()  { pinMode(_io, INPUT);  }
    
    // Clock starts LOW. Data is driven before clock rises; sampled at rising edge.
    void SWD::writeBit(uint8_t b) {
        digitalWrite(_io, b & 1);
        delayMicroseconds(HALF_US);
        digitalWrite(_clk, HIGH);
        delayMicroseconds(HALF_US);
        digitalWrite(_clk, LOW);
    }
    
    uint8_t SWD::readBit() {
        delayMicroseconds(HALF_US);
        digitalWrite(_clk, HIGH);
        uint8_t b = digitalRead(_io);  // sample at rising edge
        delayMicroseconds(HALF_US);
        digitalWrite(_clk, LOW);
        return b;
    }
    
    void SWD::clkPulse() {
        delayMicroseconds(HALF_US);
        digitalWrite(_clk, HIGH);
        delayMicroseconds(HALF_US);
        digitalWrite(_clk, LOW);
    }
    
    // ── line-level sequences ─────────────────────────────────────────────────────
    
    void SWD::lineReset() {
        ioOut();
        digitalWrite(_io, HIGH);
        for (int i = 0; i < 56; i++) clkPulse();
    }
    
    // Switch from JTAG to SWD: send 0xE79E as 16 bits, LSB first
    void SWD::jtagToSWD() {
        ioOut();
        uint16_t seq = 0xE79E;
        for (int i = 0; i < 16; i++) writeBit((seq >> i) & 1);
    }
    
    void SWD::idleCycles(uint8_t n) {
        ioOut();
        digitalWrite(_io, LOW);
        for (uint8_t i = 0; i < n; i++) clkPulse();
    }
    
    // ── SWD packet transfer ───────────────────────────────────────────────────────
    
    // Build the 8-bit request header (Start|APnDP|RnW|A[2]|A[3]|Parity|Stop|Park)
    uint8_t SWD::buildReq(bool ap, bool rd, uint8_t reg) {
        uint8_t A2  = (reg >> 2) & 1;
        uint8_t A3  = (reg >> 3) & 1;
        uint8_t par = (uint8_t)ap ^ (uint8_t)rd ^ A2 ^ A3;
        return (1        )       // Start
             | ((uint8_t)ap << 1) // APnDP
             | ((uint8_t)rd << 2) // RnW
             | (A2 << 3)          // A[2]
             | (A3 << 4)          // A[3]
             | (par << 5)         // Parity
             | (0  << 6)          // Stop
             | (1  << 7);         // Park
    }
    
    // Single SWD packet. Returns SWD_ACK_* constant.
    // For writes, *data must point to the value to write.
    // For reads, *data is filled with the received value on SWD_ACK_OK.
    uint8_t SWD::xfer(bool ap, bool rd, uint8_t reg, uint32_t *data) {
        // ── request phase (host drives) ──────────────────────────────────────────
        uint8_t req = buildReq(ap, rd, reg);
        ioOut();
        for (int i = 0; i < 8; i++) writeBit((req >> i) & 1);
    
        // ── turnaround: host releases SWDIO ─────────────────────────────────────
        ioIn();
        clkPulse();
    
        // ── ACK (3 bits, target drives) ─────────────────────────────────────────
        uint8_t ack = 0;
        for (int i = 0; i < 3; i++) ack |= (readBit() << i);
    
        if (rd) {
            // ── READ: target continues to drive data ─────────────────────────────
            if (ack == SWD_ACK_OK) {
                uint32_t val = 0;
                uint8_t  par = 0;
                for (int i = 0; i < 32; i++) {
                    uint8_t b = readBit();
                    val |= ((uint32_t)b << i);
                    par ^= b;
                }
                uint8_t rxPar = readBit();
    
                // turnaround: target releases, host takes over
                ioOut();
                clkPulse();
    
                if (data) *data = val;
                return (par == rxPar) ? SWD_ACK_OK : SWD_ACK_ERROR;
            } else {
                // WAIT or FAULT: turnaround then idle
                ioOut();
                clkPulse();
                idleCycles(8);
                return ack;
            }
        } else {
            // ── WRITE: turnaround, then host drives data ──────────────────────────
            ioOut();
            clkPulse();  // turnaround
    
            if (ack == SWD_ACK_OK && data) {
                uint8_t par = 0;
                for (int i = 0; i < 32; i++) {
                    uint8_t b = (*data >> i) & 1;
                    writeBit(b);
                    par ^= b;
                }
                writeBit(par);
            }
            idleCycles(2);  // capture / idle
            return ack;
        }
    }
    
    // ── DP access (with WAIT retry) ───────────────────────────────────────────────
    
    bool SWD::dpRead(uint8_t addr, uint32_t *out) {
        for (int r = 0; r < 8; r++) {
            uint8_t ack = xfer(false, true, addr, out);
            if (ack == SWD_ACK_OK)   return true;
            if (ack != SWD_ACK_WAIT) return false;
        }
        return false;
    }
    
    bool SWD::dpWrite(uint8_t addr, uint32_t val) {
        for (int r = 0; r < 8; r++) {
            uint8_t ack = xfer(false, false, addr, &val);
            if (ack == SWD_ACK_OK)   return true;
            if (ack != SWD_ACK_WAIT) return false;
        }
        return false;
    }
    
    // ── AP SELECT helper ──────────────────────────────────────────────────────────
    
    bool SWD::selectAP(uint8_t ap, uint8_t bank) {
        if (_selAP == ap && _selBank == bank) return true;
        uint32_t sel = ((uint32_t)ap << 24) | ((uint32_t)bank << 4);
        if (!dpWrite(DP_SELECT, sel)) return false;
        _selAP   = ap;
        _selBank = bank;
        return true;
    }
    
    // ── AP access ────────────────────────────────────────────────────────────────
    // AP reads are "posted": the xfer initiates the read and the result comes
    // back via DP RDBUFF (or the next AP read). We always use RDBUFF here.
    
    bool SWD::apRead(uint8_t ap, uint8_t reg, uint32_t *out) {
        uint8_t bank = (reg >> 4) & 0xF;
        if (!selectAP(ap, bank)) return false;
    
        uint32_t dummy = 0;
        for (int r = 0; r < 8; r++) {
            uint8_t ack = xfer(true, true, reg & 0x0F, &dummy);
            if (ack == SWD_ACK_OK)   break;
            if (ack != SWD_ACK_WAIT) return false;
        }
        return dpRead(DP_RDBUFF, out);
    }
    
    bool SWD::apWrite(uint8_t ap, uint8_t reg, uint32_t val) {
        uint8_t bank = (reg >> 4) & 0xF;
        if (!selectAP(ap, bank)) return false;
    
        for (int r = 0; r < 8; r++) {
            uint8_t ack = xfer(true, false, reg & 0x0F, &val);
            if (ack == SWD_ACK_OK)   return true;
            if (ack != SWD_ACK_WAIT) return false;
        }
        return false;
    }
    
    // ── high-level init ───────────────────────────────────────────────────────────
    
    bool SWD::powerUp() {
        if (!dpWrite(DP_CTRLSTAT, CTRL_CSYSPWRUPREQ | CTRL_CDBGPWRUPREQ)) return false;
        for (int i = 0; i < 100; i++) {
            uint32_t stat = 0;
            if (!dpRead(DP_CTRLSTAT, &stat)) return false;
            uint32_t ack_bits = CTRL_CSYSPWRUPACK | CTRL_CDBGPWRUPACK;
            if ((stat & ack_bits) == ack_bits) return true;
            delay(1);
        }
        return false;
    }
    
    bool SWD::setupMEMAP() {
        return apWrite(0, AP_CSW, AP_CSW_VALUE);
    }
    
    // probe(): line reset → JTAG-to-SWD → IDCODE read only.
    // Returns IDCODE on success, 0 on failure. Leaves pins floating afterward.
    uint32_t SWD::probe() {
        pinMode(_clk, OUTPUT);
        pinMode(_io,  OUTPUT);
        digitalWrite(_clk, LOW);
        digitalWrite(_io,  LOW);
        _selAP   = 0xFF;
        _selBank = 0xFF;
    
        lineReset();
        jtagToSWD();
        lineReset();
        idleCycles(2);
    
        uint32_t id = 0;
        if (dpRead(DP_IDCODE, &id) && id != 0 && id != 0xFFFFFFFF) return id;
    
        // Float pins so the next combination can be tried cleanly
        pinMode(_clk, INPUT);
        pinMode(_io,  INPUT);
        return 0;
    }
    
    bool SWD::connect() {
        pinMode(_clk, OUTPUT);
        pinMode(_io,  OUTPUT);
        digitalWrite(_clk, LOW);
        digitalWrite(_io,  LOW);
    
        _selAP   = 0xFF;
        _selBank = 0xFF;
    
        lineReset();
        jtagToSWD();
        lineReset();
        idleCycles(2);
    
        uint32_t id = 0;
        for (int r = 0; r < 3; r++) {
            if (dpRead(DP_IDCODE, &id) && id != 0 && id != 0xFFFFFFFF) goto got_id;
            lineReset();
            idleCycles(2);
        }
        return false;
    
    got_id:
        if (!powerUp())    return false;
        if (!setupMEMAP()) return false;
        return true;
    }
    
    // Assert nRESET, connect the DAP while held in reset, arm vector-catch on the
    // reset vector, then release. The CPU halts at its very first instruction.
    // Returns false if nRESET pin is not configured or connection fails.
    bool SWD::connectHalt() {
        if (_rst == SWD_NO_PIN) return false;
    
        assertReset();
        delay(10);
    
        if (!connect()) {
            releaseReset();
            return false;
        }
    
        // Arm vector-catch so the core halts at the reset vector
        if (!memWrite32(DEMCR, DEMCR_TRCENA | DEMCR_VC_CORERESET)) {
            releaseReset();
            return false;
        }
    
        // Pre-arm the halt flag so debug is enabled before reset exits
        memWrite32(DHCSR, DHCSR_DBGKEY | DHCSR_C_HALT | DHCSR_C_DEBUGEN);
    
        releaseReset();
    
        // Wait for the core to halt (it should be nearly instant)
        for (int i = 0; i < 200; i++) {
            if (isHalted()) return true;
            delay(2);
        }
        return false;
    }
    
    void SWD::clearErrors() {
        dpWrite(DP_ABORT, 0x1EUL);  // clear WDERR, STKERR, STKCMP, ORUNERR
        _selAP   = 0xFF;
        _selBank = 0xFF;
    }
    
    // ── memory access ─────────────────────────────────────────────────────────────
    
    bool SWD::memRead32(uint32_t addr, uint32_t *out) {
        if (!apWrite(0, AP_TAR, addr)) return false;
        return apRead(0, AP_DRW, out);
    }
    
    bool SWD::memWrite32(uint32_t addr, uint32_t val) {
        if (!apWrite(0, AP_TAR, addr)) return false;
        return apWrite(0, AP_DRW, val);
    }
    
    bool SWD::memRead(uint32_t addr, uint32_t *buf, size_t nWords) {
        for (size_t i = 0; i < nWords; i++) {
            if (!memRead32(addr + i * 4, &buf[i])) return false;
        }
        return true;
    }
    
    // ── Cortex-M debug operations ─────────────────────────────────────────────────
    
    bool SWD::halt() {
        return memWrite32(DHCSR, DHCSR_DBGKEY | DHCSR_C_HALT | DHCSR_C_DEBUGEN);
    }
    
    bool SWD::resume() {
        return memWrite32(DHCSR, DHCSR_DBGKEY | DHCSR_C_DEBUGEN);
    }
    
    bool SWD::step() {
        // Mask interrupts so the step doesn't fire an ISR
        return memWrite32(DHCSR, DHCSR_DBGKEY | DHCSR_C_STEP | DHCSR_C_MASKINTS | DHCSR_C_DEBUGEN);
    }
    
    bool SWD::isHalted() {
        uint32_t dhcsr = 0;
        return memRead32(DHCSR, &dhcsr) && (dhcsr & DHCSR_S_HALT);
    }
    
    bool SWD::sysReset() {
        // AIRCR: VECTKEY | SYSRESETREQ
        return memWrite32(AIRCR, 0x05FA0004UL);
    }
    
    bool SWD::readCoreReg(uint8_t reg, uint32_t *out) {
        if (!memWrite32(DCRSR, reg & 0x1F)) return false;
        uint32_t dhcsr = 0;
        for (int i = 0; i < 100; i++) {
            if (!memRead32(DHCSR, &dhcsr)) return false;
            if (dhcsr & DHCSR_S_REGRDY) return memRead32(DCRDR, out);
            delayMicroseconds(10);
        }
        return false;
    }
    
    bool SWD::writeCoreReg(uint8_t reg, uint32_t val) {
        if (!memWrite32(DCRDR, val))                            return false;
        if (!memWrite32(DCRSR, (1UL << 16) | (reg & 0x1F)))    return false;
        uint32_t dhcsr = 0;
        for (int i = 0; i < 100; i++) {
            if (!memRead32(DHCSR, &dhcsr)) return false;
            if (dhcsr & DHCSR_S_REGRDY)   return true;
            delayMicroseconds(10);
        }
        return false;
    }

    This implementation file translates the architectural framework of your header into concrete execution steps, managing everything from exact microsecond line timing up to core halting routines.

    Here is a breakdown of how the source code executes its low-level hardware manipulation.

    1. High-Performance Timing and Bit-Banging Primitives

    At the lowest level, the driver establishes wire communication using manual pin toggling synchronized by the HALF_US macro. Set to 1 microsecond, this creates a clean half-period delay, yielding a stable ~500 kHz clock cycle that is safe for long lines and most ARM Cortex-M targets. The fundamental I/O primitives—writeBit(), readBit(), and clkPulse()—implement standard SPI-like synchronous signaling on top of the shared SWDIO pin.

    Data is strictly driven onto the line before the clock rises, and sampled by the target on the rising edge of SWCLK. To transition between transmitting and receiving without causing a short circuit on the data line, the driver manipulates ioOut() and ioIn(). These call Arduino’s underlying pinMode() to seamlessly flip the SWDIO line between a low-impedance output and a high-impedance input.

    2. Protocol Frame Assembly and Packet Handling

    The transactional heart of the code lies within the xfer() method, which handles a complete, multi-phase SWD packet. It first invokes buildReq() to calculate parity over the target address and read/write flags, formatting an 8-bit command byte wrapped in explicit start, stop, and park bits.

    Once the host streams this byte out, it relinquishes control of the line during a precise one-cycle “turnaround” phase, shifts into input mode, and reads a 3-bit acknowledgement frame (ack) driven back by the target. If the transaction is a read and returns SWD_ACK_OK, the host clocks in 32 data bits followed by a final parity bit, tracking cumulative parity on the fly via an exclusive-OR accumulator (par ^= b).

    For writes, the sequence is inverted: the host clears the turnaround phase, assumes ownership of the data line, and streams out the payload along with its calculated parity before pushing trailing idle cycles to settle the target’s internal state machine. High-level wrappers dpRead() and dpWrite() wrap this machine in a loop that automatically retries transactions up to eight times if the target asserts a SWD_ACK_WAIT response, preventing transient bus collisions from breaking execution.

    3. State Caching and Access Port Topography

    To maximize throughput, the driver uses optimization shielding inside selectAP(). It maintains local copies of the current Access Port (_selAP) and register bank (_selBank) settings. If consecutive commands address the exact same register bank, the driver completely skips writing to the target’s DP_SELECT register, eliminating unnecessary overhead during consecutive transfers.

    This mechanism is critical for apRead() and apWrite(). Because ARM Access Port reads are inherently “posted”—meaning a request initiated on the bus does not return its data until the subsequent transaction—apRead() fires the primary AP request, handles potential wait states, and then executes a follow-up read on the Debug Port’s read buffer (DP_RDBUFF) to extract the staged data.

    4. Advanced Lifecycle and Core Debug Halting

    The complex lifecycles of target discovery and control are finalized in connect(), connectHalt(), and core register manipulators. The standard connect() sequence wakes the target by firing a 56-cycle continuous high signal (lineReset()), sending the mandatory 16-bit JTAG-to-SWD switching key (0xE79E), executing a second line reset, and asserting a power-up request to the internal system and debug power domains via DP_CTRLSTAT.

    The connectHalt() method goes an explicit step further to intercept execution on secured or crashing targets. It asserts a physical hardware reset by driving the target’s nRESET line low, hooks into the alive DAP, and writes to the Core Debug Exception and Monitor Control Register (DEMCR) to arm a vector catch (DEMCR_VC_CORERESET). It then primes the core’s halting state machine in DHCSR, releases the physical reset line, and monitors status flags until the processor halts directly at its first instruction vector before a single line of application firmware can run.

    Finally, readCoreReg() and writeCoreReg() manipulate internal CPU registers (R0-R15) by using a dual-register handshake: the host writes the target register index to the Selector Register (DCRSR), polls DHCSR until the data-ready flag (DHCSR_S_REGRDY) resolves, and then reads or writes the actual payload through the Data Register (DCRDR).

    This was fun!

    After building my swd software I loaded it on my Seeed Studio XIAO ESP32-S3 Sense and immediately got to work on my RAZ board. I was able to probe and dump the firmware successfully. All of my source code is publicly available on Github. Follow me as I go into the new firmware I will put on my vapes. Leave a comment to let me know what you want to see run on the boards!

  • ChatGPT Patron Course?

    Want to get deep-dive access into my coding practices, exclusive content and support the brand? Become a patron today!
    To view this content, you must be a member of Mastashake’s Patreon at $1 or more
    Already a qualifying Patreon member? Refresh to access this content.
  • My Trust Generator App Is Back!

    After a long hiatus, my trust generator app is back online. Starting a trust was one of the best financial things I ever did for myself and my family. Check out Trusts Generator and set up a trust for your family today. 

  • Today I Began Teaching My Kids How To Code

    Those of you who have been following my content creation journey since the beginning may remember my children being on my live streams.  They are finally learning how to code this summer by making video games! Follow us over the next couple of months as we make VR games using the Babylon.js framework.

    Patrons get behind-the-scenes access to the source code and special content as I teach these two mini geniuses. I continue to thank you all who have supported me, the channel, and the kid coding community.

    So what is Babylon.js? It is an amazing Javascript 3D video game framework that uses WebXR and WebGPU technologies to enable us to make web games for the Oculus Quest! Ethan and Moriah will be creating VR dinosaur and unicorn Oculus games this summer. I really hope that if you have kids or know of kids who want to code this summer please have them subscribe to my Youtube channel and check the channel Monday – Friday this summer and participate in the live stream! All you need is a code editor and a web browser!

    I created a repository on Github for the adventures you can follow along at https://github.com/mastashake08/dinosaur-unicorn-babylon-vr please feel free to fork and see the daily code updates!