Created
May 4, 2026 07:37
-
-
Save fxprime/c4ff17d7832d0417e8dea6cd07379394 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * 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