Mark McBride

ESP32 as a Controller for UART Consoles & Remote Power-On/Off Events

I like full remote control of my servers. While ssh(1) and various OS utilities cover most use cases, I occassionally want lower level access. Maybe I want to install an OS remotely, or maybe I just simply want to tweak a BIOS setting and monitor all boot messages when the system comes online. Whatever the use case, I need serial console access and the ability to press the power button on the server hardware to do these things.

I previously wrote about how to accomplish rebooting and monitoring an ODROID H4 Ultra with a Raspberry Pi 5. This does what I want and the approach is quite simple, but it does have one minor drawback: it requires an RPi5. If you don’t already have an RPi5, it’s an expensive solution. If you do, you might not want to tie it up controlling the consoles and power of machines.

This latter was the case for me. While I do have an RPi5, I like to use it for experimentation, which means it’s constantly offline, in a broken state, or running a freshly installed OS. So while the RPi5 is capable, I’d rather delegate the role of UART and optocoupler relay controller to another device that does nothing but act as a remotely accessible controller.

Requirements to Beat the RPi5 Solution

While looking at alternatives, I had six requirements in mind to convince myself that I had truly found a better alternative:

The good news is, it’s totally possible to meet them all. Let’s walk through how.

Waveshare ESP32-S3-ETH with PoE

The ESP32 family of microcontrollers is at the core of the solution. ESP32 devices are low-cost, low-powered, programmable microcontrollers that excel at quite a few things.

For $28 on Amazon, I was able to get a Waveshare ESP32-ETH w/ PoE device.

The Waveshare ESP32-S3-ETH w/ PoE

Right away, this device met 5 of the 6 requirements defined above and contributes favorably to the 6th (total cost). While the exact wattage depends on what is enabled, I found with no special configuration the device was running slightly over 1.1W. Once I configured it to do only what I need, that dropped to an operating range between 0.69-0.84W.

The Code

Disclaimer: This was my first ever ESP32 project. As such, I referenced a lot of online sources. While I like to think I’m reasonably competent at writing code, it’s possible I’ve keyed off of an old approach or a non-best practice. Certainly double-check anything you may copy/paste from here if you’re going to use it for anything important.

This code does a few key things:

ESP32-S3 Triple UART to TCP Bridge + Dual Relays + Port Knocking
#include <SPI.h>
#include <ETH.h>
#include <Network.h>
#include <HardwareSerial.h>
#include <WiFi.h>
#include <esp_wifi.h>
#include <esp_bt.h>
#include <esp_pm.h>

// ============ POWER SAVING OPTIONS ============
#define ENABLE_DFS            true    // Dynamic Frequency Scaling
#define CPU_FREQ_MAX_MHZ      240     // Maximum CPU frequency
#define CPU_FREQ_MIN_MHZ      80      // Minimum CPU frequency (idle)
#define APB_FREQ_MAX_MHZ      80      // Maximum APB frequency  
#define ETH_SPEED_10MBPS      true    // 10Mbps for power savings
#define IDLE_BEFORE_DFS_MS    2000    // Time before scaling down CPU
// ==============================================

// ============ SESSION MANAGEMENT OPTIONS ============
#define LOGOUT_ON_DISCONNECT     true  // Send logout sequence when TCP disconnects
#define LOGIN_TRIGGER_ON_CONNECT true  // Send newline on first-ever connection only
#define SESSION_TIMEOUT_MS       0     // 0 = disabled, or e.g. 300000 for 5 min timeout
// ====================================================

// ============ DEBUG OUTPUT CAPTURE ============
#define DEBUG_BUFFER_SIZE 2048

class DebugCapture : public Print {
private:
  uint8_t buffer[DEBUG_BUFFER_SIZE];
  volatile size_t head = 0;
  volatile size_t tail = 0;

public:
  // Write to both USB Serial AND our capture buffer
  size_t write(uint8_t c) override {
    Serial.write(c);  // Still send to USB for local debugging

    // Convert LF to CRLF for proper terminal display
    if (c == '\n') {
      // Insert CR before LF
      size_t nextHead = (head + 1) % DEBUG_BUFFER_SIZE;
      if (nextHead != tail) {
        buffer[head] = '\r';
        head = nextHead;
      }
    }

    size_t nextHead = (head + 1) % DEBUG_BUFFER_SIZE;
    if (nextHead != tail) {  // Don't overflow
      buffer[head] = c;
      head = nextHead;
    }
    return 1;
  }


  size_t write(const uint8_t *buf, size_t size) override {
    for (size_t i = 0; i < size; i++) {
      write(buf[i]);
    }
    return size;
  }

  // Check if data available for TCP bridge to read
  int available() {
    return (head >= tail) ? (head - tail) : (DEBUG_BUFFER_SIZE - tail + head);
  }

  // Read one byte (for TCP bridge)
  int read() {
    if (head == tail) return -1;
    uint8_t c = buffer[tail];
    tail = (tail + 1) % DEBUG_BUFFER_SIZE;
    return c;
  }

  // Read multiple bytes (for TCP bridge)
  size_t readBytes(uint8_t *buf, size_t length) {
    size_t count = 0;
    while (count < length && available()) {
      buf[count++] = read();
    }
    return count;
  }
};

DebugCapture debugCapture;
#define Debug debugCapture
// ==============================================

// ============ W5500 SPI ETHERNET CONFIGURATION ============
#define ETH_TYPE        ETH_PHY_W5500
#define ETH_ADDR        1
#define ETH_CS_PIN      14
#define ETH_INT_PIN     10
#define ETH_RST_PIN     9
#define ETH_SPI_SCK     13
#define ETH_SPI_MISO    12
#define ETH_SPI_MOSI    11
// ==========================================================

// ============ STATIC IP CONFIGURATION ============
IPAddress staticIP(10, 0, 1, 50);
IPAddress gateway(10, 0, 1, 1);
IPAddress subnet(255, 255, 255, 0);
IPAddress dns1(9, 9, 9, 9);
IPAddress dns2(1, 1, 1, 1);
// =================================================

#define BAUD_RATE 115200
#define BUFFER_SIZE 1024

// UART1 and UART2 pins
#define UART1_TX 16
#define UART1_RX 18
#define UART2_TX 41
#define UART2_RX 42

// Relay config
#define RELAY_1 15
#define RELAY_2 38
#define RELAY_ACTIVE HIGH
#define RELAY_DEFAULT_MS 6000

// Port knocking config
#define KNOCK_SECRET "let_me_in"
#define UNLOCK_DURATION_MS 30000

struct UartBridge {
  Stream* serial;             // For reading FROM device (NULL for debug bridge)
  Print* output;              // For writing TO device
  bool isDebugBridge;         // True for bridge[0] (debug capture)
  NetworkServer* server;
  NetworkClient client;
  bool wasConnected;          // Track previous connection state
  unsigned long lastActivity; // For optional session timeout
};

struct Relay {
  uint8_t pin;
  NetworkServer* server;
  unsigned long offTime;
  bool active;
};

NetworkServer* server0;
NetworkServer* server1;
NetworkServer* server2;
NetworkServer* relayServer1;
NetworkServer* relayServer2;
NetworkServer* knockServer;

UartBridge bridges[3];
Relay relays[2];

uint8_t buf[BUFFER_SIZE];

// Port knocking state
IPAddress unlockedIP;
unsigned long unlockExpiry = 0;

// Ethernet connection state
static bool eth_connected = false;

// Power management state
unsigned long lastActivityTime = 0;
esp_pm_lock_handle_t cpu_freq_lock = NULL;
bool high_freq_mode = false;

// Rollover-safe time comparison
bool timeElapsed(unsigned long startTime, unsigned long interval) {
  return (unsigned long)(millis() - startTime) >= interval;
}

bool isUnlocked(IPAddress ip) {
  return (ip == unlockedIP && !timeElapsed(unlockExpiry - UNLOCK_DURATION_MS, UNLOCK_DURATION_MS));
}

// Send logout sequence to UART (for sh/bash/zsh)
void sendLogoutSequence(Print* output) {
  #if LOGOUT_ON_DISCONNECT
    output->print("\x03");       // Ctrl+C - cancel any pending input
    delay(50);
    output->print("\x04");       // Ctrl+D - EOF, logs out shell
    Debug.println("Logout sequence sent to UART");
  #endif
}

// Configure Dynamic Frequency Scaling for power savings
void configureDFS() {
  #if ENABLE_DFS
    // Configure power management for DFS
    esp_pm_config_t pm_config = {
        .max_freq_mhz = CPU_FREQ_MAX_MHZ,
        .min_freq_mhz = CPU_FREQ_MIN_MHZ,
        .light_sleep_enable = false  // CRITICAL: Disable light sleep
    };

    esp_err_t err = esp_pm_configure(&pm_config);
    if (err == ESP_OK) {
      Debug.printf("DFS configured: %d-%d MHz (light sleep DISABLED)\n", 
                   CPU_FREQ_MIN_MHZ, CPU_FREQ_MAX_MHZ);
    } else {
      Debug.printf("DFS configuration failed: %s\n", esp_err_to_name(err));
    }

    // Create CPU frequency lock for high-performance mode
    err = esp_pm_lock_create(ESP_PM_CPU_FREQ_MAX, 0, "ethernet_active", &cpu_freq_lock);
    if (err != ESP_OK) {
      Debug.printf("Failed to create CPU freq lock: %s\n", esp_err_to_name(err));
      cpu_freq_lock = NULL;
    }
  #else
    // Set fixed frequency without DFS
    setCpuFrequencyMhz(CPU_FREQ_MAX_MHZ);
    Debug.printf("Fixed CPU frequency: %d MHz\n", CPU_FREQ_MAX_MHZ);
  #endif
}

// Boost CPU frequency during network activity
void boostCPUFrequency() {
  #if ENABLE_DFS
    if (cpu_freq_lock && !high_freq_mode) {
      esp_err_t err = esp_pm_lock_acquire(cpu_freq_lock);
      if (err == ESP_OK) {
        high_freq_mode = true;
        Debug.println("CPU boosted to max frequency");
      }
    }
  #endif
}

// Allow CPU frequency scaling during idle
void allowCPUScaling() {
  #if ENABLE_DFS
    if (cpu_freq_lock && high_freq_mode) {
      esp_err_t err = esp_pm_lock_release(cpu_freq_lock);
      if (err == ESP_OK) {
        high_freq_mode = false;
        Debug.println("CPU frequency scaling enabled");
      }
    }
  #endif
}

void disableRadiosAndSavePower() {
  // Disable WiFi completely
  WiFi.disconnect(true);
  WiFi.mode(WIFI_OFF);
  esp_wifi_stop();
  esp_wifi_deinit();

  // Disable Bluetooth completely
  btStop();
  esp_bt_controller_disable();
  esp_bt_controller_deinit();

  Debug.println("WiFi and Bluetooth disabled for power savings");
}

void onNetworkEvent(arduino_event_id_t event) {
  switch (event) {
    case ARDUINO_EVENT_ETH_START:
      Debug.println("ETH Started");
      ETH.setHostname("esp32-bridge");
      break;
    case ARDUINO_EVENT_ETH_CONNECTED:
      Debug.println("ETH Connected");
      if (!ETH.config(staticIP, gateway, subnet, dns1, dns2)) {
        Debug.println("Static IP configuration failed");
      } else {
        Debug.println("Static IP configured");
      }
      break;
    case ARDUINO_EVENT_ETH_GOT_IP:
      Debug.print("ETH IP: ");
      Debug.println(ETH.localIP());
      Debug.print("Speed: ");
      Debug.print(ETH.linkSpeed());
      Debug.println(" Mbps");
      eth_connected = true;
      break;
    case ARDUINO_EVENT_ETH_DISCONNECTED:
      Debug.println("ETH Disconnected");
      eth_connected = false;
      break;
    case ARDUINO_EVENT_ETH_STOP:
      Debug.println("ETH Stopped");
      eth_connected = false;
      break;
    default:
      break;
  }
}

void setup() {
  Serial.begin(BAUD_RATE);
  delay(100);

  Debug.println("\n=== ESP32-S3 UART Bridge + Relay Controller (DFS Power Saving) ===");

  // Configure power management FIRST
  configureDFS();

  // Disable unused radios for power savings
  disableRadiosAndSavePower();

  Serial1.begin(BAUD_RATE, SERIAL_8N1, UART1_RX, UART1_TX);
  Serial2.begin(BAUD_RATE, SERIAL_8N1, UART2_RX, UART2_TX);

  Network.onEvent(onNetworkEvent);

  // Initialize SPI
  SPI.begin(ETH_SPI_SCK, ETH_SPI_MISO, ETH_SPI_MOSI, ETH_CS_PIN);

  #if ETH_SPEED_10MBPS
    ETH.setAutoNegotiation(false);
    ETH.setLinkSpeed(10);
    Debug.println("Configured for 10 Mbps mode");
  #endif

  // Initialize Ethernet
  ETH.begin(ETH_TYPE, ETH_ADDR, ETH_CS_PIN, ETH_INT_PIN, ETH_RST_PIN, SPI);

  Debug.println("Waiting for Ethernet...");
  while (!eth_connected) {
    delay(500);
  }

  // Create servers
  server0 = new NetworkServer(8880);
  server1 = new NetworkServer(8881);
  server2 = new NetworkServer(8882);
  relayServer1 = new NetworkServer(8883);
  relayServer2 = new NetworkServer(8884);
  knockServer = new NetworkServer(9999);

  // Bridge 0: Debug capture (ESP32 console output)
  bridges[0].serial = nullptr;          // No Stream to read from (we use debugCapture)
  bridges[0].output = &Serial;          // Commands from TCP go to Serial input
  bridges[0].isDebugBridge = true;
  bridges[0].server = server0;
  bridges[0].wasConnected = false;
  bridges[0].lastActivity = 0;

  // Bridge 1: UART1 (external device)
  bridges[1].serial = &Serial1;
  bridges[1].output = &Serial1;
  bridges[1].isDebugBridge = false;
  bridges[1].server = server1;
  bridges[1].wasConnected = false;
  bridges[1].lastActivity = 0;

  // Bridge 2: UART2 (external device)
  bridges[2].serial = &Serial2;
  bridges[2].output = &Serial2;
  bridges[2].isDebugBridge = false;
  bridges[2].server = server2;
  bridges[2].wasConnected = false;
  bridges[2].lastActivity = 0;

  // Initialize relays
  relays[0] = {RELAY_1, relayServer1, 0, false};
  relays[1] = {RELAY_2, relayServer2, 0, false};

  for (auto& r : relays) {
    pinMode(r.pin, OUTPUT);
    digitalWrite(r.pin, !RELAY_ACTIVE);
    r.server->begin();
  }

  for (auto& b : bridges) {
    b.server->begin();
    b.server->setNoDelay(true);
  }

  knockServer->begin();
  lastActivityTime = millis();

  Debug.println("=== Ready ===");
  Debug.printf("Ports: Knock=9999, Debug=8880, UART1=8881, UART2=8882, Relay1-2=8883-8884\n");
  Debug.printf("Power: DFS %s, CPU %d-%d MHz\n", 
               ENABLE_DFS ? "enabled" : "disabled", CPU_FREQ_MIN_MHZ, CPU_FREQ_MAX_MHZ);
  Debug.printf("Session: Logout on disconnect=%s, Login trigger on first connect=%s\n",
               LOGOUT_ON_DISCONNECT ? "yes" : "no", LOGIN_TRIGGER_ON_CONNECT ? "yes" : "no");
}

bool checkForActivity() {
  if (knockServer->hasClient()) return true;

  for (auto& b : bridges) {
    if (b.server->hasClient()) return true;
    if (b.client && b.client.connected() && b.client.available()) return true;
    if (b.isDebugBridge) {
      if (debugCapture.available()) return true;
    } else {
      if (b.serial->available()) return true;
    }
  }

  for (auto& r : relays) {
    if (r.server->hasClient()) return true;
    if (r.active) return true;
  }

  return false;
}

bool hasActiveConnections() {
  for (auto& b : bridges) {
    if (b.client && b.client.connected()) return true;
  }
  for (auto& r : relays) {
    if (r.active) return true;
  }
  return false;
}

void handleConnections() {
  // Handle port knocking
  if (knockServer->hasClient()) {
    NetworkClient k = knockServer->accept();
    unsigned long timeout = millis() + 100;
    while (k.available() == 0 && !timeElapsed(timeout - 100, 100)) {
      delay(1);
    }

    if (k.available() > 0) {
      String knock = k.readStringUntil('\n');
      knock.trim();
      if (knock == KNOCK_SECRET) {
        unlockedIP = k.remoteIP();
        unlockExpiry = millis() + UNLOCK_DURATION_MS;
        k.printf("Unlocked for %d seconds\n", UNLOCK_DURATION_MS / 1000);
        Debug.printf("Port knock accepted from %s\n", k.remoteIP().toString().c_str());
      } else {
        k.println("Invalid knock");
        Debug.printf("Invalid port knock from %s\n", k.remoteIP().toString().c_str());
      }
    } else {
      k.println("Send knock secret");
    }
    k.stop();
  }

  // Handle UART bridges with session management
  for (int i = 0; i < 3; i++) {
    UartBridge& b = bridges[i];

    // Check current connection state
    bool isConnected = b.client && b.client.connected();

    // === SESSION TIMEOUT CHECK ===
    #if SESSION_TIMEOUT_MS > 0
      if (isConnected && b.lastActivity > 0 && timeElapsed(b.lastActivity, SESSION_TIMEOUT_MS)) {
        Debug.printf("Session timeout on bridge %d - forcing disconnect\n", i);
        if (!b.isDebugBridge) {
          sendLogoutSequence(b.output);
        }
        b.client.stop();
        isConnected = false;
      }
    #endif

    // === DETECT DISCONNECT - Send logout sequence ===
    if (b.wasConnected && !isConnected) {
      Debug.printf("TCP client disconnected from bridge %d\n", i);
      if (!b.isDebugBridge) {
        // Only send logout for real UART bridges, not debug
        sendLogoutSequence(b.output);
      }
      if (b.client) {
        b.client.stop();
      }
    }

    // === HANDLE NEW CONNECTIONS ===
    if (b.server->hasClient()) {
      NetworkClient newClient = b.server->accept();
      if (!isUnlocked(newClient.remoteIP())) {
        newClient.println("Locked - knock first on port 9999");
        newClient.stop();
      } else {
        // Close existing client if any (with logout)
        if (b.client && b.client.connected()) {
          Debug.printf("Replacing existing connection on bridge %d\n", i);
          if (!b.isDebugBridge) {
            sendLogoutSequence(b.output);
          }
          delay(100);
          b.client.stop();
        }

        // Accept new client
        b.client = newClient;
        b.client.setNoDelay(true);
        b.lastActivity = millis();
        isConnected = true;

        // === FLUSH STALE UART DATA ===
        if (!b.isDebugBridge && b.serial != nullptr) {
          int flushed = 0;
          while (b.serial->available()) {
            b.serial->read();
            flushed++;
          }
          if (flushed > 0) {
            Debug.printf("Flushed %d stale bytes from UART buffer\n", flushed);
          }
          // Trigger fresh login prompt after flush
          delay(50);
          b.output->print("\r");
        }

        Debug.printf("New TCP client connected to bridge %d\n", i);
      }
    }

    // === BRIDGE DATA ===
    if (isConnected) {
      // TCP -> UART/Serial
      int avail = b.client.available();
      if (avail > 0) {
        int len = b.client.read(buf, min(avail, BUFFER_SIZE));
        b.output->write(buf, len);
        b.lastActivity = millis();
      }

      // UART/Debug -> TCP
      if (b.isDebugBridge) {
        // Read from debug capture buffer
        avail = debugCapture.available();
        if (avail > 0) {
          int len = debugCapture.readBytes(buf, min(avail, BUFFER_SIZE));
          b.client.write(buf, len);
          b.lastActivity = millis();
        }
      } else {
        // Normal UART bridge
        avail = b.serial->available();
        if (avail > 0) {
          int len = b.serial->readBytes(buf, min(avail, BUFFER_SIZE));
          b.client.write(buf, len);
          b.lastActivity = millis();
        }
      }
    }

    // Update state for next iteration
    b.wasConnected = isConnected;
  }

  // Handle relay triggers
  for (int i = 0; i < 2; i++) {
    Relay& r = relays[i];
    if (r.server->hasClient()) {
      NetworkClient client = r.server->accept();
      if (!isUnlocked(client.remoteIP())) {
        client.println("Locked - knock first on port 9999");
        client.stop();
        continue;
      }

      unsigned long timeout = millis() + 100;
      while (client.available() == 0 && !timeElapsed(timeout - 100, 100)) {
        delay(1);
      }

      unsigned long duration = RELAY_DEFAULT_MS;
      if (client.available() > 0) {
        String input = client.readStringUntil('\n');
        input.trim();
        int seconds = input.toInt();
        if (seconds > 0) {
          duration = seconds * 1000UL;
        }
      }

      client.printf("Relay %d activated for %lu seconds\n", i + 1, duration / 1000);
      Debug.printf("Relay %d activated for %lu seconds\n", i + 1, duration / 1000);
      client.stop();

      digitalWrite(r.pin, RELAY_ACTIVE);
      r.offTime = millis() + duration;
      r.active = true;
    }

    if (r.active && timeElapsed(r.offTime - (r.offTime - millis()), r.offTime - millis())) {
      digitalWrite(r.pin, !RELAY_ACTIVE);
      r.active = false;
      Debug.printf("Relay %d deactivated\n", i + 1);
    }
  }
}

void loop() {
  bool activity = checkForActivity();
  bool activeConnections = hasActiveConnections();

  if (activity) {
    handleConnections();
    lastActivityTime = millis();

    // Boost CPU frequency during network activity
    boostCPUFrequency();
  }

  // Also check for disconnects even without "activity"
  // This ensures we detect when a client disconnects gracefully
  for (auto& b : bridges) {
    bool isConnected = b.client && b.client.connected();
    if (b.wasConnected && !isConnected) {
      handleConnections();  // Process the disconnect
      break;
    }
  }

  // Manage CPU frequency based on activity
  if (activeConnections) {
    // Keep high frequency while connections are active
    boostCPUFrequency();
  } else if (timeElapsed(lastActivityTime, IDLE_BEFORE_DFS_MS)) {
    // Allow frequency scaling when idle
    allowCPUScaling();
  }

  if (!activity) {
    delay(1); // Prevent tight loop
  }

  // Status report every 5 minutes
  static unsigned long lastStatusReport = 0;
  if (timeElapsed(lastStatusReport, 300000)) {
    lastStatusReport = millis();
    Debug.printf("Status: CPU mode=%s, Ethernet=%s, Active connections=%s\n",
                 high_freq_mode ? "high" : "scaled", 
                 eth_connected ? "connected" : "disconnected",
                 activeConnections ? "yes" : "no");
  }
}

All that’s left to do with the code is compile it and flash it to the ESP32 device. Luckily, the very popular Arduino IDE does the heavy lifting. Just plug in the ESP32 with a USB cable, push a button, and about 30 seconds later the device is ready to use.

Wiring and Relay Components

With a programmed ESP32, we just need to wire it to our relays and the external devices we want to control. The additional items needed to do this are:

So that’s $35 on top of the $28 for the ESP32 device. $63 altogether, or about 40% of the cost of a RPi. You could probably cost optimize a bit more, but those are the prices I paid for my Amazon purchases.

There are many relays to choose from, so I thought I’d note a few things to look for. Most importantly is the inclusion of an optocoupler. This electrically isolates the input voltage and signal from the ESP32 from the switching portion that opens/closes the circuit wired to the external device’s power button. Also consider the voltage. Common options are 3.3V and 5V. Lastly, note whether the relay is activated by a high or low signal (some you can configure). I picked a relay that requires a “high” signal to activate it (i.e., close the circuit).

Optocoupler 3V/3.3V Relay

There’s not much to worry about when choosing wire other than size and core. For size, I went with 24 AWG, but 22 AWG will work as well. You’ll find solid core and stranded core options. Solid core will allow you to strip away a bit of the casing and wire directly to a breadboard.

24 AWG Solid Core Wire

For breadboards, just avoid the super cheap options as you may find the build quality quite poor. I like the kind depicted as you can easily connect them together and create a surface that is sized to your needs.

Breadboards

Lastly, for jumper wires I recommend grabbing a set that includes all three adapter combinations as shown in the photo. This will ensure you can connect to both male and female headers.

Jumper wires

Putting It All Together

In the code above, there are pin definitions that determine how to wire things.

Pin Definitions
// UART1 and UART2 pins
#define UART1_TX 16
#define UART1_RX 18
#define UART2_TX 41
#define UART2_RX 42

// Relay pins
#define RELAY_1 15
#define RELAY_2 38

Those pin numbers correlate to the GPIO pin numbers on the board. You can choose whichever pins you like, but one thing to note with any device like this is how the pins behave during a power reset. Some pins activate, or “go high,” when power is reset. If you’re using a high signal to trigger your relay (which is what I do), this would result in a relay activation if the ESP32 device restarts. I chose pins that avoid this. I also chose these pins such that UART1 and RELAY1 wire into one side of the board and UART2 and RELAY2 into the other.

Pin diagram for the ESP-S3-ETH.

While iterating on the code and prototyping the hardware solution, I used jumper wires as they are very easy to move around.

All wiring with jumper wires.

However, jumperwires are also quite easy to bump and knock out of a breadboard, so I eventually replaced all the on-board wiring with solid core 24 AWG wire. It looks cleaner and is more stable. Logically, the setup in the photo above and below are identical.

Onboard wiring with 24 AWG solid core wires, external wiring with jumper wires.

Lastly, we need to connect the UART wires and Relay wires to another device. Here’s an example of the wiring into an ODROID H4 Ultra’s GPIO header.

ESP32 UART1 <==> ODROID UART0: blue, purple, gray wires
ESP32 RELAY1 <==> ODROID PWR_BTN#: white, black wires

Using the ESP32 Controller

Port Knocking and UART Monitoring

Using the solution is quite simple. First, we port knock. This opens ports 8880-8884 if the correct simple authentication is provided. In this example, the passphrase is “let_me_in”. Aside from some basic security, this also helps avoid accidental execution. For example, while repeatedly pressing the up key to go through my shell’s history I don’t want to accidentally pick the wrong thing and trigger a server reboot. You could remove the port knocking altogether if desired, but I like it.

Port Knock
echo "let_me_in" | nc 10.0.1.50 9999
Unlocked for 30 seconds

With that, we have 30 sections to take the next step.

Connect to UART Console
socat -,rawer,escape=0x1d TCP:10.0.1.50:8881
FreeBSD/amd64 (o25) (ttyu0) login:

And we’re in! socat(1) is like nc(1) on steroids. I use it for the UART connections as it handles a few things better, namely you can pass through ctrl-c, and when you type your password it will be obfuscated on screen. That said, nc works too and is often installed by default in most OSes. Port 8881 correlates to UART1. We’ve connected the ESP32’s three GPIO pins for UART1 directly to an ODROID H4 Ultra’s GPIO UART pins. And on this ODROID I’m running FreeBSD as we can see in the console login message.

Triggering Power Events

We have also setup our ESP32 to let us press buttons. I’ve wired RELAY2 to my RPi’s J2 header, which is available to wire up a physical button or a relay like we’re using. The way this works is the ESP32 will trigger a “high” signal to the relay for however many seconds we decide. While high, the relay will close the circuit wire to J2 on the RPi. For as long as we’re in this “high” relay state, it’s like we’re holding down the computer’s physical power button. With most systems, a less-than-5 second press will turn on a computer that’s off, or tell a computer that’s powered on to gracefully shutdown immediately. Most systems will also do a hard reset if the button is held for 5 seconds or more.

I’ll open two terminal windows with the goal of triggering a 1-second button press on a running system in one, and monitoring the UART console in the other. (I’ve omitted the port knocking steps to keep it simpler, but you’d need to do that too.)

Step 1: Monitor the UART Console
socat -,rawer,escape=0x1d TCP:10.0.1.50:8882
Welcome to Alpine Linux 3.23 Kernel 6.12.62-0-rpi on aarch64 (/dev/ttyAMA10)

In another terminal, I’ll trigger a 1-second power button press.

Step 2: Trigger a Button Press
echo "1" | nc 10.0.1.50 8884

And with that command issued, looking back at the other terminal shows the system shutting down.

Step 3: Watch the system shutdown on the UART Console
Welcome to Alpine Linux 3.23
Kernel 6.12.62-0-rpi on aarch64 (/dev/ttyAMA10)

r24 login:  
* Stopping local ... [ ok ]
* Stopping sshd ... [ ok ]

[ ... I omitted various OS shutdown messages for brevity ... ]

The system is going down NOW!
Sent SIGTERM to all processes
Sent SIGKILL to all processes
Requesting system poweroff
[15161.890851] reboot: Power down

RPi: BOOTSYS release VERSION:4c845bd3 DATE: 2024/02/16 TIME: 15:28:41
BOOTMODE: 0x06 partition 63 build-ts BUILD_TIMESTAMP=1708097321 serial c55939a1 boardrev d04170 stc 1067583
AON_RESET: 00000003 PM_RSTS 00000575
RP1_BOOT chip ID: 0x20001927
Halt: power_off: 0

If you look at those last messages, they’re from the RPi itself, not the operating system. This is one of the advantage of monitoring a hardware console, i.e., you get all the messages and not just those from the OS.

Wrapping Low-Level TCP Commands with More Semantic Scripts

It’s a bit dangerous to execute commands like nc/socat as we have been. For example, if you mix up ports 8882 and 8883 you’ll find yourself rebooting a machine instead of connecting to a console. To avoid such mistakes, I call these types of commands via wrapper scripts. So for example, when I want to connect to my FreeBSD server’s console, I do the following, where “srv” is an alias for a executable wrapper script, “o25” is the name of the FreeBSD system, and “console” is an action defined in my script to execute the commands we were executing previously.

Simple Wrapper Script to Avoid Errors
srv o25 console
Ctrl-] to disconnect FreeBSD/amd64 (o25) (ttyu0) login:

This is less error prone and more intuitive as it’s noun-verb oriented. Also, I can insert little things like the reminder for how to disconnect from the console. So once you’ve got the ESP32 doing what you want, I highly recommend doing something similar so that you don’t accidentally reboot the wrong system.

As a side note, I also like wrappers because you can put a common interface on different methods. For example, I also have a server running that has it’s own BMC and I can access it’s console via Serial-Over-LAN (SOL). I tuck away all the ipmitool(1) commands in the same wrapper, which allows me to do this:

Extending Wrapper Script to SOL
srv c24 sol
~. to disconnect [SOL Session operational. Use ~? for help] Welcome to Alpine Linux 3.23 Kernel 6.18.2-0-lts on x86_64 (/dev/ttyS1) c24 login:

Like this, I just remember my wrapper commands and let the script sort out which commands to call, the necessary arguments to pass, and the per-host details like IP addresses, ports, etc.

To the Web!

What’s really cool about this solution is that you can feed it to other things. For example, you might use a project like ttyd expose the terminal over the web. I’ve done exactly this. I have a web portal setup with Authelia for authentication and nginx as a reverse proxy. It calls back to ttyd, which in turn talks to the ESP32. The result is access to my console on the web!

Using ttyd to expose the ESP32-provided console on the web.

A Note on Security

Hopefully it goes without saying, but don’t put the ESP32 or ttyd directly on the Internet. Port knocking is not sufficient security. I suggest something like Authelia if you’re exposing things on the web, or using a VPN or similar for direct access to the LAN device.

Conclusion

With a little effort, you can make your devices’ hardware consoles remotely accessible and also ensure that you can power them off and on, even if they’re stuck and require a hard reboot. Total time to do this project was 7 days. In that time I ordered parts, learned Arduino IDE, wrote working code, configured things to my liking, and got the wiring to a respectable state. Not bad given I knew almost nothing about ESP32 going into it. I would definitely recommend this project to anyone wanting to accomplish similar goals.

Revisiting the requirements I had:

Now that I know the basics of ESP32, I’m sure I’ll be dreaming up all sorts of extensions to my homelab.