Skip to content

Instantly share code, notes, and snippets.

@fxprime
Created May 4, 2026 07:37
Show Gist options
  • Select an option

  • Save fxprime/c4ff17d7832d0417e8dea6cd07379394 to your computer and use it in GitHub Desktop.

Select an option

Save fxprime/c4ff17d7832d0417e8dea6cd07379394 to your computer and use it in GitHub Desktop.
/*
* ESP32 + PCA9685 Servo Controller with WebSocket GUI
* =====================================================
* Controls servos 0-3 on PCA9685 via a browser-based dashboard.
*
* FOLDER STRUCTURE (both files must be in the same sketch folder):
* ESP32_PCA9685_ServoWS/
* ESP32_PCA9685_ServoWS.ino ← this file
* html_page.h ← HTML dashboard (included below)
*
* Libraries required (install via Arduino Library Manager):
* - Adafruit PWM Servo Driver Library (Adafruit)
* - ESPAsyncWebServer (me-no-dev)
* - AsyncTCP (me-no-dev)
* - ArduinoJson (Benoit Blanchon)
*
* Wiring:
* ESP32 SDA → PCA9685 SDA (GPIO 21 default)
* ESP32 SCL → PCA9685 SCL (GPIO 22 default)
* ESP32 3V3 → PCA9685 VCC
* ESP32 GND → PCA9685 GND
* External 5-6V PSU → PCA9685 V+ & GND (for servos)
*
* WebSocket protocol (JSON):
* Client → Server:
* {"cmd":"set", "ch":0, "us":1500}
* {"cmd":"config", "ch":0, "min":500, "max":2500, "deg":180}
* {"cmd":"center", "ch":0}
* Server → Client (broadcast on any change):
* {"servos":[{"ch":0,"us":1500,"min":500,"max":2500,"deg":180}, ...]}
*/
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <Wire.h>
#include <Adafruit_PWMServoDriver.h>
#include <ArduinoJson.h>
#include <EEPROM.h>
// HTML page lives in html_page.h to keep JS "function" keywords away from
// the Arduino IDE's C++ prototype scanner (which only processes .ino files).
#include "html_page.h"
// ─── WiFi Credentials ────────────────────────────────────────────────────────
const char* WIFI_SSID = "MDM_AIS_2.4G";
const char* WIFI_PASSWORD = "3213213213";
// ─── PCA9685 ─────────────────────────────────────────────────────────────────
#define PCA9685_ADDR 0x40 // Default I2C address (A0-A5 all LOW)
#define PCA9685_FREQ 50 // 50 Hz → 20 ms period (standard servo)
// Tune this if your servos are off by a few degrees (typical range: 25–27 MHz)
#define PCA9685_OSC_HZ 27000000
Adafruit_PWMServoDriver pca = Adafruit_PWMServoDriver(PCA9685_ADDR);
// ─── Servo Configuration ─────────────────────────────────────────────────────
#define NUM_SERVOS 4
struct ServoConfig {
uint16_t minUs; // Minimum pulse width in microseconds
uint16_t maxUs; // Maximum pulse width in microseconds
uint16_t currentUs; // Current pulse width in microseconds
uint16_t degrees; // Full travel range: 90, 180, 270, or 360
};
ServoConfig servos[NUM_SERVOS];
// Defaults applied when EEPROM has no saved config
const ServoConfig SERVO_DEFAULTS[NUM_SERVOS] = {
{500, 2500, 1500, 180},
{500, 2500, 1500, 180},
{500, 2500, 1500, 180},
{500, 2500, 1500, 180},
};
// ─── EEPROM ───────────────────────────────────────────────────────────────────
#define EEPROM_SIZE 128
#define EEPROM_MAGIC 0xCA // Change this byte to force-reset all saved configs
void saveConfig() {
EEPROM.write(0, EEPROM_MAGIC);
int addr = 1;
for (int i = 0; i < NUM_SERVOS; i++) {
EEPROM.put(addr, servos[i]);
addr += sizeof(ServoConfig);
}
EEPROM.commit();
Serial.println("[EEPROM] Config saved.");
}
void loadConfig() {
if (EEPROM.read(0) == EEPROM_MAGIC) {
int addr = 1;
for (int i = 0; i < NUM_SERVOS; i++) {
EEPROM.get(addr, servos[i]);
addr += sizeof(ServoConfig);
}
Serial.println("[EEPROM] Config loaded.");
} else {
Serial.println("[EEPROM] No saved config – using defaults.");
for (int i = 0; i < NUM_SERVOS; i++) {
servos[i] = SERVO_DEFAULTS[i];
}
saveConfig();
}
}
// ─── PCA9685 Helpers ─────────────────────────────────────────────────────────
// Convert microseconds → PCA9685 12-bit tick count
// At 50 Hz the period is 20 000 µs → 4096 ticks
uint16_t usToPwmTick(uint16_t us) {
return (uint16_t)(((uint32_t)us * 4096UL) / 20000UL);
}
void setServoUs(uint8_t ch, uint16_t us) {
us = constrain(us, servos[ch].minUs, servos[ch].maxUs);
servos[ch].currentUs = us;
pca.setPWM(ch, 0, usToPwmTick(us));
}
void centerServo(uint8_t ch) {
setServoUs(ch, (servos[ch].minUs + servos[ch].maxUs) / 2);
}
// ─── WebSocket / Web Server ───────────────────────────────────────────────────
AsyncWebServer httpServer(80);
AsyncWebSocket ws("/ws");
String buildStateJson() {
StaticJsonDocument<512> doc;
JsonArray arr = doc.createNestedArray("servos");
for (int i = 0; i < NUM_SERVOS; i++) {
JsonObject s = arr.createNestedObject();
s["ch"] = i;
s["us"] = servos[i].currentUs;
s["min"] = servos[i].minUs;
s["max"] = servos[i].maxUs;
s["deg"] = servos[i].degrees;
}
String out;
serializeJson(doc, out);
return out;
}
void broadcastState() {
ws.textAll(buildStateJson());
}
void handleWsMessage(uint8_t* data, size_t len) {
StaticJsonDocument<256> doc;
if (deserializeJson(doc, data, len) != DeserializationError::Ok) {
Serial.println("[WS] JSON parse error");
return;
}
const char* cmd = doc["cmd"] | "";
int ch = doc["ch"] | -1;
if (strcmp(cmd, "set") == 0) {
if (ch < 0 || ch >= NUM_SERVOS) return;
uint16_t us = doc["us"] | servos[ch].currentUs;
setServoUs(ch, us);
Serial.printf("[WS] set ch=%d us=%d\n", ch, servos[ch].currentUs);
broadcastState();
} else if (strcmp(cmd, "config") == 0) {
if (ch < 0 || ch >= NUM_SERVOS) return;
servos[ch].minUs = doc["min"] | servos[ch].minUs;
servos[ch].maxUs = doc["max"] | servos[ch].maxUs;
servos[ch].degrees = doc["deg"] | servos[ch].degrees;
setServoUs(ch, servos[ch].currentUs); // Re-clamp to new range
saveConfig();
Serial.printf("[WS] config ch=%d min=%d max=%d deg=%d\n",
ch, servos[ch].minUs, servos[ch].maxUs, servos[ch].degrees);
broadcastState();
} else if (strcmp(cmd, "center") == 0) {
if (ch < 0 || ch >= NUM_SERVOS) return;
centerServo(ch);
Serial.printf("[WS] center ch=%d us=%d\n", ch, servos[ch].currentUs);
broadcastState();
} else {
Serial.printf("[WS] Unknown cmd: %s\n", cmd);
}
}
void onWsEvent(AsyncWebSocket* server, AsyncWebSocketClient* client,
AwsEventType type, void* arg, uint8_t* data, size_t len) {
switch (type) {
case WS_EVT_CONNECT:
Serial.printf("[WS] Client #%u connected from %s\n",
client->id(), client->remoteIP().toString().c_str());
client->text(buildStateJson()); // Push current state to new client
break;
case WS_EVT_DISCONNECT:
Serial.printf("[WS] Client #%u disconnected\n", client->id());
break;
case WS_EVT_DATA: {
AwsFrameInfo* info = (AwsFrameInfo*)arg;
if (info->final && info->index == 0 && info->len == len &&
info->opcode == WS_TEXT) {
handleWsMessage(data, len);
}
break;
}
case WS_EVT_ERROR:
Serial.printf("[WS] Client #%u error(%u): %s\n",
client->id(), *((uint16_t*)arg), (char*)data);
break;
default:
break;
}
}
// ─── setup() ─────────────────────────────────────────────────────────────────
void setup() {
Serial.begin(115200);
delay(200);
Serial.println("\n=== ESP32 PCA9685 Servo Controller ===");
EEPROM.begin(EEPROM_SIZE);
loadConfig();
Wire.begin(); // SDA=GPIO21, SCL=GPIO22 (ESP32 default)
pca.begin();
pca.setOscillatorFrequency(PCA9685_OSC_HZ);
pca.setPWMFreq(PCA9685_FREQ);
delay(10);
for (int i = 0; i < NUM_SERVOS; i++) {
setServoUs(i, servos[i].currentUs);
Serial.printf(" CH%d: %d us (min=%d max=%d deg=%d)\n",
i, servos[i].currentUs,
servos[i].minUs, servos[i].maxUs, servos[i].degrees);
}
Serial.printf("Connecting to %s ", WIFI_SSID);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
delay(500); Serial.print(".");
}
Serial.printf("\nConnected! Open: http://%s\n", WiFi.localIP().toString().c_str());
ws.onEvent(onWsEvent);
httpServer.addHandler(&ws);
httpServer.on("/", HTTP_GET, [](AsyncWebServerRequest* req) {
req->send_P(200, "text/html", INDEX_HTML);
});
// Optional REST endpoint – returns JSON state
httpServer.on("/state", HTTP_GET, [](AsyncWebServerRequest* req) {
req->send(200, "application/json", buildStateJson());
});
httpServer.begin();
Serial.println("HTTP server started.");
}
// ─── loop() ──────────────────────────────────────────────────────────────────
void loop() {
ws.cleanupClients();
delay(10);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment