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:
- be cheaper than an RPi5 w/ PoE (less than $150)
- be more power efficient than an RPi (less than 5W)
- allow monitoring of at least 2 UART consoles
- allow controlling at least 2 relays
- be powered by PoE (Power Over Ethernet)
- not be dependent on any other device to operate
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.
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:
- Enables a TCP bridge to control UARTs and Power Events
- Listens on port 9999 for a “port knock” with very basic authentication
- After a knock, ports 8880-8882 (UART bridges) and 8883-8884 (relay bridges) are opened
- Powers off WiFi, Bluetooth, and throttles down the CPU
- Ability to monitor the console of the ESP32 itself on UART0 (8880)
- External device 1 connects to UART 1 (8881) and Relay 1 (8883)
- External device 2 connects to UART 2 (8882) and Relay 2 (8884)
- Cleans up buffers on connect, triggers logout of consoles on disconnect
#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:
- 3.3V Relays - $2.50 x 2 = $5
- Jumper Wires, Box of 120 = $7
- Breadboard, Box of 6 Panels = $7
- 24 AWG Solid Core Wire, 6 rolls of 25ft = $16 (This is optional, i.e., you could just use jumper wires, but this gives a better final result)
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).
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.
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.
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.
Putting It All Together
In the code above, there are pin definitions that determine how to wire things.
// 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.
While iterating on the code and prototyping the hardware solution, I used jumper wires as they are very easy to move around.
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.
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 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.
echo "let_me_in" | nc 10.0.1.50 9999Unlocked for 30 seconds
With that, we have 30 sections to take the next step.
socat -,rawer,escape=0x1d TCP:10.0.1.50:8881FreeBSD/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.)
socat -,rawer,escape=0x1d TCP:10.0.1.50:8882Welcome 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.
echo "1" | nc 10.0.1.50 8884
And with that command issued, looking back at the other terminal shows the system shutting down.
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.
srv o25 consoleCtrl-] 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:
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!
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:
- be cheaper than RPi5 - $40-60 vs. $150
- be more power efficient than RPi5 - <1W vs. 5W
- control at least 2 UART consoles - yes, we did 3
- control at least 2 relays - yes
- powered by PoE - yes
- not be dependent on any other device to operate - yes
Now that I know the basics of ESP32, I’m sure I’ll be dreaming up all sorts of extensions to my homelab.