Bu, konunun uzun ve teknik rehberi (ESP-IDF, MicroPython, rclcpp gibi alternatiflerle). Adım adım yol için Porti görevlerine dön.
Tek tekerlek, tek PID
ESP32 + enkoderli motor: dön, say, ölç, düzelt
Robotikteki en temel döngüyü elinle kuruyorsun: ölç, karşılaştır, düzelt. Masada tek bir motor, bir ESP32, bir sürücü. Sonunda motor hedef hızda duruyor, elinle frenleyince kendini topluyor, tarayıcında canlı grafiği akıyor. Buradaki her satır 4. haftada dengede duran robota taşınacak.
- Motor iki yönde dönüyor, deadband'i ölçülmüş
- Encoder tur başına sayımı (CPR) kendin ölçtün
- Kendi PID sınıfın: anti-windup, ölçümden türev, feedforward
- 3 farklı gain setinin step response grafiği ve metrik tablosu
- Tarayıcıda canlı telemetri + hedef hız kaydırıcısı
- Kaskad pozisyon kontrolü: "p 360" yaz, mil tam bir tur dönsün
Motoru döndür, deadband'i bul
ESP32 PWM ile TB6612 üzerinden motoru iki yönde döndürüyor. Motorun dönmeye başladığı en küçük duty'yi biliyorsun.
- Açık döngüde hedef hızı değiştir: hız hedefe tam oturmuyor. Sebebi deadband ve yük.
- Ölü bölgeyi 0.25'e çıkar: düşük hızlarda motor hiç dönmüyor.
- "Yük bindir"i aç: açık döngü yükü hiç fark etmiyor. Kapalı döngüye bu yüzden geçeceğiz.
; platformio.ini — ESP32 Arduino core 3.x (pioarduino)
[env:esp32dev]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/stable/platform-espressif32.zip
board = esp32dev
framework = arduino
monitor_speed = 115200
build_flags = -DCORE_DEBUG_LEVEL=0
lib_deps =
links2004/WebSockets@^2.6.1
bblanchon/ArduinoJson@^7.2.0- ESP32 motor çalışınca resetleniyorsa: motor ve mantık GND'si ortak mı, 12V adaptör yeterli mi, motor uçlarına 100 nF kondansatör lehimle.
- STBY pini LOW kalırsa sürücü hiç çalışmaz. Kodda HIGH yapmayı unutma.
- Arduino core 2.x kullanıyorsan ledcAttach yok: ledcSetup + ledcAttachPin kullanılır. platformio.ini'deki pioarduino platformu core 3.x getirir.
Daha derin
- Fren (IN1=IN2=HIGH) ile serbest dönüşü (IN1=IN2=LOW) karşılaştır: motor ne kadar sürede duruyor?
- PWM frekansını 1 kHz yap: motorun öttüğünü duyacaksın. 20 kHz insan kulağının üstünde.
- Multimetreyle motor akımını farklı duty'lerde ölç, deadband ile ilişkisini gör.
Encoder'ı say, CPR'ı kendin ölç
Encoder sayacı iki yönde doğru sayıyor. Çıkış milinin bir turunda kaç sayım geldiğini etiketten değil ölçerek biliyorsun.
- Hızı eksiye çek: A ile B'nin sırası değişiyor, sayaç geri sayıyor.
- "Tek kanal" seç ve mili salla: sayaç geri dönüşleri de ileri sayıyor. İki kanalın sebebi bu.
- x1, x2, x4 arasında geç: aynı tur için sayım 4 kat değişiyor.
// include/encoder.h — interrupt ile x4 quadrature sayaç
#pragma once
#include <Arduino.h>
#include "pins.h"
class Encoder {
public:
void begin() {
pinMode(PIN_ENC_A, INPUT_PULLUP);
pinMode(PIN_ENC_B, INPUT_PULLUP);
state_ = (digitalRead(PIN_ENC_A) << 1) | digitalRead(PIN_ENC_B);
self_= this;
attachInterrupt(digitalPinToInterrupt(PIN_ENC_A), isr, CHANGE);
attachInterrupt(digitalPinToInterrupt(PIN_ENC_B), isr, CHANGE);
}
// 32 bit okuma ESP32'de tek talimatta olur ama okuma-sıfırlama ikilisini
// atomik yapmak için kritik bölge kullanıyoruz.
int32_t count() {
portENTER_CRITICAL(&mux_);
int32_t c= count_;
portEXIT_CRITICAL(&mux_);
return c;
}
uint32_t errors() const { return errors_; }
private:
static void IRAM_ATTR isr() {
// (önceki A,B,şimdiki A,B) → -1, 0, +1. 2 değeri "geçersiz atlama" demek.
static const int8_t table[16]= {0, -1, 1, 2, 1, 0, 2, -1, -1, 2, 0, 1, 2, 1, -1, 0};
Encoder* e= self_;
uint8_t now= (gpio_get_level(static_cast<gpio_num_t>(PIN_ENC_A)) << 1) |
gpio_get_level(static_cast<gpio_num_t>(PIN_ENC_B));
int8_t d = table[(e->state_ << 2) | now];
portENTER_CRITICAL_ISR(&e->mux_);
if (d == 2) e->errors_++; // bir kenar kaçtı: çok hızlı ya da gürültülü
else e->count_ += d;
portEXIT_CRITICAL_ISR(&e->mux_);
e->state_ = now;
}
static inline Encoder* self_ = nullptr;
volatile int32_t count_ = 0;
volatile uint32_t errors_ = 0;
volatile uint8_t state_ = 0;
portMUX_TYPE mux_ = portMUX_INITIALIZER_UNLOCKED;
};
- Sayaç ileri dönüşte eksiye gidiyorsa A ile B'yi ters bağladın. Kabloları ya da koddaki pinleri değiştir.
- Tam hızda "hata" sayacı artıyorsa kenar kaçıyor: interrupt yerine PCNT donanım sayacına geç (ESP-IDF sekmesi).
- Interrupt içinde Serial.print yapma. ISR kısa olmalı ve IRAM'de durmalı (IRAM_ATTR).
Daha derin
- Arduino çerçevesinde kalıp PCNT kullan: ESP32 Arduino core 3.x, IDF'in pulse_cnt sürücüsünü doğrudan çağırmana izin verir.
- Mantık analizörü (ucuz 8 kanallı olanlar yeter) ile A/B sinyallerini gerçekten gör, simülasyondakiyle karşılaştır.
- Glitch filtresinin ns değerini değiştir, motor gürültüsünün sayıma etkisini ölç.
Sabit periyotta hız ölç
100 Hz'de koşan bir FreeRTOS task'i hızı RPM olarak ölçüyor, CSV olarak akıtıyor. Ölçüm gürültüsünün nereden geldiğini biliyorsun.
- Encoder çözünürlüğünü 80'e indir: ölçülen hız (mavi) basamaklı ve gürültülü. Gerçek hız (yeşil) pürüzsüz.
- Kontrol frekansını 1000 Hz'e çıkar: gürültü artar, çünkü 1 ms'de çok az sayım gelir.
- Ölçüm filtresini 0.7 yap: gürültü azalır ama ölçüm gecikir.
// src/main.cpp — adım 1.3: sabit periyotlu döngü, hız ölçümü, CSV
#include <Arduino.h>
#include "encoder.h"
#include "motor.h"
constexpr float CPR = 1400.0f; // 1.2'de ölçtüğün değer (çıkış milinde, x4)
constexpr int LOOP_HZ = 100;
constexpr float ALPHA = 0.3f; // hız için üstel filtre: 0 = filtre yok
Encoder enc;
Motor motor;
volatile float g_duty = 0.4f;
void controlTask(void*) {
TickType_t wake = xTaskGetTickCount();
const TickType_t period = pdMS_TO_TICKS(1000 / LOOP_HZ);
int32_t last = enc.count();
float rpmFilt = 0;
uint32_t t0 = micros();
for (;;) {
vTaskDelayUntil(&wake, period); // delay() değil: periyot kaymaz
int32_t now = enc.count();
float rpm = (now - last) / CPR * 60.0f * LOOP_HZ;
last = now;
rpmFilt += (1.0f - ALPHA) * (rpm - rpmFilt);
motor.set(g_duty);
// t_ms, duty, ham rpm, filtreli rpm
Serial.printf("%lu,%.3f,%.1f,%.1f\n", (micros() - t0) / 1000, g_duty, rpm, rpmFilt);
}
}
void setup() {
Serial.begin(921600); // 100 Hz CSV için 115200 sınırda kalır
enc.begin();
motor.begin();
// Kontrol döngüsü 1. çekirdekte, yüksek öncelikle. WiFi 0. çekirdekte kalsın.
xTaskCreatePinnedToCore(controlTask, "ctl", 4096, nullptr, 5, nullptr, 1);
}
void loop() {
// seri porttan "0.6" yaz: yeni duty
if (Serial.available()) g_duty = constrain(Serial.parseFloat(), -1.0f, 1.0f);
delay(10);
}
- delay() ile kurulan döngü her tur biraz kayar; hız hesabındaki Δt bozulur. vTaskDelayUntil periyodu sabit tutar.
- Serial.printf yavaştır: 115200 baud'da 100 satır/s sınırda. Baud'u artır ya da her 2 turda bir yaz.
Daha derin
- Düşük hızda Δsayım yerine iki kenar arasındaki süreyi ölçerek hız hesapla (period measurement). Hangi hızda hangisi daha iyi?
- ESP32'nin ikinci çekirdeğinde WiFi açıkken döngünün jitter'ını micros() ile ölç.
Kapalı döngü: kendi PID'in
Motor istediğin hızda duruyor, yük binince toparlanıyor. Kazançları değiştirdiğinde ne olacağını önceden tahmin edebiliyorsun.
- Yalnız Kp ile başla (Ki = 0): hedefin altında kalıcı hata var. Ki ekleyince kapanıyor.
- Ki'yi 30'a çıkar: aşma ve salınım başlıyor.
- Hedefi motorun sınırına yakın seç ve anti-windup'ı kapat: hedef düşünce büyük aşma geliyor.
- Feedforward'ı aç: aynı kazançlarla çok daha hızlı oturuyor.
- Gecikmeyi 40 ms yap: aynı kazançlar artık salınıyor. WiFi üzerinden kontrolün neden zor olduğunu gösterir.
// include/pid.h — gerçek dünyaya dayanıklı PID
#pragma once
#include <math.h>
struct PidConfig {
float kp = 0, ki = 0, kd = 0;
float outMin = -1, outMax = 1;
float dFilter = 0.2f; // türev için birinci derece filtre katsayısı (0..1)
};
class Pid {
public:
explicit Pid(PidConfig c = {}) : c_(c) {}
void config(const PidConfig& c) { c_ = c; }
const PidConfig& config() const { return c_; }
void reset(float measurement = 0) {
integ_ = 0;
dFilt_ = 0;
prevMeas_ = measurement;
first_ = true;
}
// ff: feedforward (modelden tahmin edilen çıkış). dt: saniye.
float update(float setpoint, float measurement, float dt, float ff = 0) {
if (dt <= 0) return last_;
const float e= setpoint - measurement;
// Türevi hatadan değil ölçümden al: setpoint sıçrayınca "derivative kick" olmaz.
float d= first_ ? 0 : -(measurement - prevMeas_) / dt;
first_= false;
prevMeas_= measurement;
dFilt_ = c_.dFilter * (d - dFilt_);
const float unsat= ff + c_.kp * e + c_.ki * integ_ + c_.kd * dFilt_;
const float out= fminf(c_.outMax, fmaxf(c_.outMin, unsat));
// Koşullu integral (anti-windup): çıkış doymuşken ve hata aynı yöne
// itiyorsa integrali büyütme. Yoksa motor durduğunda bile I şişer.
const bool pushingIntoLimit= (out = unsat) && ((e > 0) == (unsat > 0));
if (!pushingIntoLimit) integ_ += e * dt;
last_ = out;
return out;
}
private:
PidConfig c_;
float integ_ = 0, dFilt_ = 0, prevMeas_ = 0, last_ = 0;
bool first_ = true;
};
- Hedef 0 olunca motor titriyorsa: deadband çevresinde PID kararsız. Kodda olduğu gibi hedef 0 ve hız küçükse çıkışı sıfırla.
- Encoder yönü motor yönüyle ters ise geri besleme pozitif olur, motor tam hıza kaçar. İşareti kontrol et.
- Kd'yi ham hız ölçümüyle kullanma: gürültüyü büyütür. Bu kod türevi filtreli ve ölçümden alıyor.
Daha derin
- Ziegler–Nichols: yalnız P ile salınım başlayana kadar artır, Ku ve Tu'dan kazançları hesapla, simülasyonla karşılaştır.
- Motorun birinci derece modelini (K, τ) step yanıtından çıkar, sonra kazançları pole placement ile hesapla.
- MATLAB/Octave ya da python-control ile aynı modelin kapalı döngü yanıtını çiz, gerçekle üst üste koy.
Tarayıcıdan canlı telemetri
Telefonundan ESP32'nin sayfasını açıyorsun: hız grafiği 50 Hz akıyor, kaydırıcıyla hedef hızı ve kazançları değiştiriyorsun. Sayfa kapanınca motor 1 saniyede duruyor.
// src/main.cpp — adım 1.5: WiFi + WebSocket telemetri, tarayıcıdan kontrol
// Telefon/laptop aynı ağda: http://<esp32-ip>/ aç. Grafik 50 Hz akar.
#include <Arduino.h>
#include <ArduinoJson.h>
#include <WebServer.h>
#include <WebSocketsServer.h>
#include <WiFi.h>
#include "encoder.h"
#include "motor.h"
#include "pid.h"
#include "page.h" // aşağıdaki HTML, PROGMEM'de
const char* SSID = "WIFI_ADIN";
const char* PASS = "WIFI_SIFREN";
constexpr float CPR = 1400.0f, RPM_MAX = 300.0f, DEADBAND = 0.12f;
constexpr int LOOP_HZ = 100;
Encoder enc;
Motor motor;
Pid pid({.kp = 1.2f, .ki = 6.0f});
WebServer http(80);
WebSocketsServer ws(81);
struct Sample { uint32_t t; float target, rpm, u; };
QueueHandle_t samples; // kontrol çekirdeği → ağ çekirdeği
volatile float g_target = 0;
volatile uint32_t g_lastCmdMs = 0; // watchdog: komut gelmezse dur
void controlTask(void*) {
TickType_t wake = xTaskGetTickCount();
int32_t last = enc.count();
float rpm = 0;
uint8_t n = 0;
for (;;) {
vTaskDelayUntil(&wake, pdMS_TO_TICKS(1000 / LOOP_HZ));
int32_t now = enc.count();
rpm += 0.5f * ((now - last) / CPR * 60.0f * LOOP_HZ - rpm);
last = now;
float target = (millis() - g_lastCmdMs > 1000) ? 0 : g_target; // 1 s sessizlik → dur
float ff = fabsf(target) < 1 ? 0 : target / RPM_MAX + copysignf(DEADBAND, target);
float u= pid.update(target / RPM_MAX, rpm / RPM_MAX, 1.0f / LOOP_HZ, ff);
if (target= 0 && fabsf(rpm) < 2) { u= 0; pid.reset(rpm / RPM_MAX); }
motor.set(u);
if (++n % 2= 0) { // 50 Hz telemetri yeter
Sample s{millis(), target, rpm, u};
xQueueSend(samples, &s, 0); // kuyruk doluysa düşür, kontrolü bekletme
}
}
}
void onWs(uint8_t client, WStype_t type, uint8_t* payload, size_t len) {
if (type = WStype_TEXT) return;
JsonDocument doc;
if (deserializeJson(doc, payload, len)) return;
PidConfig c= pid.config();
if (doc["target"].is<float>()) g_target = constrain(doc["target"].as<float>(), -RPM_MAX, RPM_MAX);
if (doc["kp"].is<float>()) c.kp = doc["kp"];
if (doc["ki"].is<float>()) c.ki = doc["ki"];
if (doc["kd"].is<float>()) c.kd = doc["kd"];
pid.config(c);
g_lastCmdMs = millis();
}
void setup() {
Serial.begin(115200);
enc.begin();
motor.begin();
samples = xQueueCreate(64, sizeof(Sample));
WiFi.mode(WIFI_STA);
WiFi.begin(SSID, PASS);
while (WiFi.status() != WL_CONNECTED) delay(200);
Serial.printf("http://%s/\n", WiFi.localIP().toString().c_str());
http.on("/", [] { http.send_P(200, "text/html", PAGE); });
http.begin();
ws.begin();
ws.onEvent(onWs);
xTaskCreatePinnedToCore(controlTask, "ctl", 4096, nullptr, 5, nullptr, 1);
}
void loop() {
http.handleClient();
ws.loop();
Sample s;
char buf[96];
while (xQueueReceive(samples, &s, 0) == pdTRUE) {
int n = snprintf(buf, sizeof buf, "[%lu,%.1f,%.1f,%.3f]", (unsigned long)s.t, s.target, s.rpm, s.u);
ws.broadcastTXT(buf, n);
}
}
- HTTPS bir sayfadan ws:// adresine bağlanamazsın (mixed content). ESP32'nin kendi http sayfasını kullan ya da localhost'taki köprüye bağlan.
- WiFi bağlıyken kontrol döngüsü kayıyorsa döngüyü 1. çekirdeğe sabitle, ağ işini loop()'ta (0. çekirdek) tut.
- WiFi şifresini koda yazıp GitHub'a gönderme: secrets.h oluştur, .gitignore'a ekle.
Daha derin
- Telemetriyi ArduinoJson yerine ikili (binary) formatta gönder, bant genişliğini ölç.
- Aynı sayfayı Next.js ile kendi sitende yaz; Bun köprüsünden veri al.
- mDNS ekle (ESPmDNS): IP yerine http://motor.local/
Kaskad pozisyon kontrolü
"p 360" yazınca mil tam bir tur dönüp duruyor, aşmadan. Dışta pozisyon, içte hız döngüsü: dengeleyen robotta ve kolda aynı yapıyı kullanacaksın.
- Kontrol frekansını 500 Hz'e çıkar: iç döngü hızlandıkça dış döngü daha agresif ayarlanabilir.
- Kaskadın kuralı: iç döngü dış döngüden en az 5-10 kat hızlı olmalı.
// src/main.cpp — adım 1.6: kaskad pozisyon kontrolü (dış P → iç PI hız)
// Hedef açıyı derece olarak yolla: "p 360" milin tam bir tur dönmesi.
#include <Arduino.h>
#include "encoder.h"
#include "motor.h"
#include "pid.h"
constexpr float CPR = 1400.0f, RPM_MAX = 300.0f, DEADBAND = 0.12f;
constexpr int INNER_HZ = 500; // hız döngüsü hızlı
constexpr int OUTER_DIV = 5; // pozisyon döngüsü 5 kat yavaş: 100 Hz
Encoder enc;
Motor motor;
Pid speedPid({.kp = 1.0f, .ki = 8.0f});
float posKp = 4.0f; // (RPM) / (derece hata) ölçeğinde
float vMaxRpm = 120.0f; // dış döngü hız sınırı: yumuşak hareket
volatile float g_posDeg = 0;
void controlTask(void*) {
TickType_t wake = xTaskGetTickCount();
int32_t last = enc.count();
float rpm = 0, rpmCmd = 0;
uint32_t k = 0;
for (;;) {
vTaskDelayUntil(&wake, pdMS_TO_TICKS(1000 / INNER_HZ)); // 2 ms = 2 tick (1 kHz tick)
int32_t now = enc.count();
rpm += 0.4f * ((now - last) / CPR * 60.0f * INNER_HZ - rpm);
last = now;
if (k++ % OUTER_DIV == 0) {
float posDeg = now / CPR * 360.0f;
float err = g_posDeg - posDeg;
rpmCmd = constrain(posKp * err, -vMaxRpm, vMaxRpm);
if (fabsf(err) < 0.5f) rpmCmd= 0; // ölü bölge: titremeyi keser
}
float ff= fabsf(rpmCmd) < 1 ? 0 : rpmCmd / RPM_MAX + copysignf(DEADBAND, rpmCmd);
float u= speedPid.update(rpmCmd / RPM_MAX, rpm / RPM_MAX, 1.0f / INNER_HZ, ff);
if (rpmCmd= 0 && fabsf(rpm) < 2) { u= 0; speedPid.reset(rpm / RPM_MAX); }
motor.set(u);
}
}
void setup() {
Serial.begin(921600);
enc.begin();
motor.begin();
// FreeRTOS tick 1 kHz (ESP32 Arduino varsayılanı) — 500 Hz = 2 tick
xTaskCreatePinnedToCore(controlTask, "ctl", 4096, nullptr, 5, nullptr, 1);
}
void loop() {
if (Serial.available()) {
String l= Serial.readStringUntil('\n');
if (l.startsWith("p ")) g_posDeg= l.substring(2).toFloat();
if (l.startsWith("kp ")) posKp= l.substring(3).toFloat();
}
static uint32_t t= 0;
if (millis() - t > 10) {
t = millis();
Serial.printf("%lu,%.1f,%.1f\n", t, g_posDeg, enc.count() / CPR * 360.0f);
}
}
- İç döngü iyi ayarlanmadan dış döngüye geçme. Önce 1.4'teki hız yanıtı temiz olmalı.
Daha derin
- Trapezoid hız profili ekle: hedefe sabit ivmeyle hızlan, sabit hızla git, sabit ivmeyle yavaşla.
- Aynı yapıyı SO-101'in tek bir servosunda düşün: STS3215 içinde pozisyon döngüsü zaten var. Fark ne?
Kavramlar
Kaynaklar
- Understanding PID Control (MATLAB Tech Talks) · Kısa videolarla sezgi
- Improving the Beginner's PID · Anti-windup, derivative kick
- ESP-IDF PCNT dokümanı
- Differential drive robot using ROS2 and ESP32 (Hackster) · Encoder + motor PID örneği