Arduino library for Feetech bus servos (SCS/STS protocol) on ESP32 and compatible boards.
Supports ST3020 / ST3215 (STS, 360°, continuous rotation) and SC15 (SCS, 180°, continuous rotation) on a shared half-duplex UART bus.
- Half-duplex UART — two GPIOs, no direction pin needed (TX → 1kΩ → bus, RX direct to bus)
- Typed device classes — no accidental cross-model register access
- Endian-correct 16-bit register I/O (STS = little-endian, SCS = big-endian)
- Sign-bit encoding per family (STS bit 15, SCS bit 10)
- Broadcast protection (ID 0xFE is blocked)
- Checksum validation on every response
- Synchronized multi-servo moves via
moveTimeAsync+triggerAction - Three operating modes: Position, Velocity (continuous rotation), Step (multi-turn)
- EEPROM auto-unlock for protected settings (ID, mode, calibration offset)
setMode()reads current mode before writing — skips EEPROM write if already correct
ESP32 GPIO17 TX ---[1kΩ]---+--- SERVO BUS DATA
ESP32 GPIO18 RX ------------+
GND --------- SERVO GND
5–8.4 V --------- SERVO VCC
The 1 kΩ resistor sits between the TX pin and the shared bus node. The RX pin connects directly to the bus node (same junction, no resistor).
This is critical: if both RX and TX use the same pin or if RX is behind the resistor, the servo's response will be blocked by the ESP32 TX output driving the bus HIGH.
Initialise with:
bus.beginPins(1000000, 18, 17); // beginPins(baud, rxPin, txPin)#include <FeetechBus.h>
#include <FeetechST3020.h>
FeetechBus bus(Serial2);
FeetechST3020 st(bus, 1); // servo ID (factory default = 1; use BusScan to find yours)
void setup() {
bus.beginPins(1000000, 18, 17); // 1 MBaud, RX=GPIO18, TX=GPIO17
st.init();
st.moveTime(st.degToTicks(90), 500, 800); // go to 90°, 500 ms, speed 800
}
void loop() {}#include <FeetechBus.h>
#include <FeetechSC15.h>
FeetechBus bus(Serial2);
FeetechSC15 sc(bus, 1); // servo ID (factory default = 1; use BusScan to find yours)
void setup() {
bus.beginPins(1000000, 18, 17); // 1 MBaud, RX=GPIO18, TX=GPIO17
sc.init();
sc.moveTime(sc.degToTicks(150), 500, 600); // go to 150°, 500 ms, speed 600
}
void loop() {}| Class | Alias | Protocol | Encoder | Range | Speed range | degToTicks |
|---|---|---|---|---|---|---|
FeetechST3020 |
— | STS (little-endian, bit-15 sign) | 4096 ticks/rev | 0–360° | ±32767 | degToTicks(0–360) |
FeetechST3215 |
= FeetechST3020 |
STS (identical register map) | 4096 ticks/rev | 0–360° | ±32767 | degToTicks(0–360) |
FeetechSC15 |
— | SCS (big-endian, bit-10 sign) | 1024 ticks/180° | 0–180° | ±1023 | degToTicks(0–180) |
ST3020 and ST3215 share the same register map, protocol, and 12-bit encoder. The differences are purely physical (form factor, torque, voltage range).
| Method | Description |
|---|---|
bus.beginPins(baud, rx, tx) |
Half-duplex: TX → 1kΩ → bus, RX → bus direct |
| Method | Description |
|---|---|
ping() |
Returns true if the servo responds on the bus |
init(Profile) |
Apply Profile settings (return delay, torque, status level) |
| Method | Description |
|---|---|
moveTime(pos, time, speed) |
Move to position; time in ms (0 = no limit), speed 0 = no cap |
moveTimeAsync(pos, time, speed) |
Queue move (REGWRITE); does not start until triggerAction() |
triggerAction() |
Broadcast ACTION — starts all queued async moves simultaneously |
setTargetVelocity(v) |
Velocity mode: signed speed, negative = reverse, 0 = stop |
| Method | Class | Description |
|---|---|---|
degToTicks(deg) |
FeetechST3020 |
0–360° → 0–4095 ticks |
degToTicks(deg) |
FeetechSC15 |
0–180° → 0–1023 ticks; values outside range are clamped |
| Method | Description |
|---|---|
readPresentPosition(pos) |
Current position in ticks |
readCurrentSpeed(speed) |
Signed speed — negative means reverse direction |
readCurrentTemperature(degC) |
Temperature in °C |
readCurrentCurrent(amps) |
Current draw in Amperes (1 count = 6.5 mA) |
isMoving(moving) |
true while the servo is in motion |
| Method | Description |
|---|---|
setTorque(bool) |
Enable or disable holding torque |
setAcceleration(acc) |
Acceleration ramp (0 = no limit) |
setTorqueLimit(limit) |
Maximum torque (0–1000) |
These methods automatically unlock and relock EEPROM. Call them once at setup, not in
loop(). EEPROM is rated for ~100 000 write cycles.setMode()reads the current mode before writing and skips the EEPROM write if the mode is already correct.
| Method | Families | Description |
|---|---|---|
setMode(ServoMode) |
ST + SC | Operating mode: POSITION, VELOCITY, or STEP |
setId(newId) |
ST + SC | Change servo ID (1–253) |
setAngleLimits(minTicks, maxTicks) |
ST + SC | Clamp the allowed position range in EEPROM |
setPositionOffset(offset) |
ST only | Signed calibration offset — not available on SC15 (returns false) |
The servo will refuse to move outside the stored min/max range. Units are raw ticks.
Use degToTicks() to convert:
// ST3020: restrict to 45°–270° (0–360° = 0–4095 ticks)
st.setAngleLimits(st.degToTicks(45), st.degToTicks(270));
// SC15: restrict to 30°–150° (0–180° = 0–1023 ticks)
sc.setAngleLimits(sc.degToTicks(30), sc.degToTicks(150));Restore full range:
st.setAngleLimits(0, 4095); // ST3020
sc.setAngleLimits(0, 1023); // SC15The SCS protocol (SC15) has no calibration-offset register. Calling setPositionOffset() on an SC15 returns false immediately without touching the bus.
The STS (ST3020 / ST3215) offset is a signed 16-bit value stored in EEPROM at register 0x1F. It shifts the zero-point of the encoder without changing the physical range:
st.setPositionOffset(100); // shift zero-point by +100 ticks
st.setPositionOffset(0); // reset to factory zeroenum class ServoMode : uint8_t {
POSITION = 0, // Default: position control with moveTime()
VELOCITY = 1, // Continuous rotation with setTargetVelocity()
STEP = 3 // Multi-turn step mode (32-bit position counter)
};Both ST3020 and SC15 support all three modes. The only difference is the speed range.
// ST3020 — 360° range, 4096 ticks
st.moveTime(st.degToTicks(90), 300, 800); // 90°, 300 ms, speed 800
// SC15 — 180° range, 1024 ticks
sc.moveTime(sc.degToTicks(90), 300, 600); // 90°, 300 ms, speed 600setMode() writes EEPROM (once in setup). setTargetVelocity() writes RAM (safe in loop).
// ST3020 — speed range ±32767
st.setMode(ServoMode::VELOCITY); // setup() — once
st.setTargetVelocity( 800); // forward
st.setTargetVelocity(-800); // reverse
st.setTargetVelocity( 0); // stop
// SC15 — speed range ±1023
sc.setMode(ServoMode::VELOCITY); // setup() — once
sc.setTargetVelocity( 600); // forward
sc.setTargetVelocity(-600); // reverse
sc.setTargetVelocity( 0); // stopSwitch back to position mode:
st.setMode(ServoMode::POSITION);
st.moveTime(st.degToTicks(0), 500, 500);| Example | Servo | What it shows |
|---|---|---|
BusScan |
any | Scan all IDs 1–253 at multiple baud rates — find connected servos and their IDs |
BasicPositionControl |
ST3020 | Move through several angles, wait for completion via isMoving(), read position and speed |
SpeedMove |
ST3020 | Snap to position at full hardware speed — time=0, speed=0, acc=0 explained |
ContinuousRotation |
ST3020 | Velocity mode — forward / stop / reverse / stop with live speed readback |
CalibrateServos |
ST3020 + SC15 | Interactive: ST3020 current pos → tick 2048 (center); SC15 current pos → min or max limit |
SC15_PositionControl |
SC15 | Position control using degToTicks(), wait for completion, read back position |
SC15_ContinuousRotation |
SC15 | Velocity mode for SC15 — same API, speed range ±1023 |
StatusMonitor |
ST3020 | Live readout of position, speed, temperature, and current |
DualServo_SameUART |
ST3020 + SC15 | Both servo types on one bus — init, move, sequential position read |
SyncedMove |
ST3020 + SC15 | Two servos start simultaneously with moveTimeAsync + triggerAction |
RawDiag |
any | Raw hex dump of TX/RX bytes — diagnose wiring without any library abstraction |
Moves an ST3020 through 0° → 90° → 180° → 270° → 0°, blocking until each move finishes.
void moveTo(float deg, uint16_t timeMs, uint16_t speed) {
st.moveTime(st.degToTicks(deg), timeMs, speed);
delay(60); // let the servo start before polling isMoving()
bool moving = true;
while (moving) {
st.isMoving(moving);
delay(20);
}
uint16_t pos; int16_t spd;
st.readPresentPosition(pos);
st.readCurrentSpeed(spd);
Serial.print("pos="); Serial.print(pos);
Serial.print(" speed="); Serial.println(spd);
}
void loop() {
moveTo( 0, 900, 600);
moveTo( 90, 600, 900);
moveTo(180, 600, 900);
moveTo(270, 600, 900);
}Moves an SC15 through 0° → 90° → 180° → 90° → 0°, blocking until each move finishes.
SC15 range is 0–180°; degToTicks() clamps values outside this range.
FeetechSC15 sc(bus, 1);
void moveTo(float deg, uint16_t timeMs, uint16_t speed) {
sc.moveTime(sc.degToTicks(deg), timeMs, speed);
delay(60);
bool moving = true;
while (moving) { sc.isMoving(moving); delay(20); }
uint16_t pos; int16_t spd;
sc.readPresentPosition(pos);
sc.readCurrentSpeed(spd);
Serial.print("pos="); Serial.print(pos);
Serial.print(" speed="); Serial.println(spd);
}
void loop() {
moveTo( 0, 900, 600);
moveTo( 90, 700, 700);
moveTo(180, 900, 600);
}void setup() {
bus.beginPins(1000000, 18, 17);
st.init();
st.setMode(ServoMode::VELOCITY); // EEPROM — once at setup
}
void loop() {
st.setTargetVelocity( 800); delay(2000); // forward
st.setTargetVelocity( 0); delay(600); // stop
st.setTargetVelocity(-600); delay(2000); // reverse
st.setTargetVelocity( 0); delay(1000); // stop
}Same API as ST3020 — only the speed range differs (±1023 instead of ±32767).
void setup() {
bus.beginPins(1000000, 18, 17);
sc.init();
sc.setMode(ServoMode::VELOCITY); // EEPROM — once at setup
}
void loop() {
sc.setTargetVelocity( 600); delay(2000); // forward
sc.setTargetVelocity( 0); delay(600); // stop
sc.setTargetVelocity(-400); delay(2000); // reverse
sc.setTargetVelocity( 0); delay(1000); // stop
}Combined calibration utility — three functions, one sketch, both servos on the same bus. All writes go to EEPROM and survive power-off.
| Command | Servo | Function |
|---|---|---|
c |
ST3020 | Reads current position, writes setPositionOffset() so it reports as tick 2048 (180°, center) |
l |
SC15 | Reads current position, saves it as the lower angle limit via setAngleLimits() |
h |
SC15 | Reads current position, saves it as the upper angle limit via setAngleLimits() |
t |
both | Test: ST3020 → 2048; SC15 → min limit → max limit |
r |
both | Reset: ST3020 offset = 0; SC15 limits = 0–1023 (full range) |
ST3020 workflow:
1. Move the servo to the desired neutral / center position.
2. Send 'c' → encoder zero-point shifted so this position now reports as 2048 (180°).
SC15 workflow:
1. Move the servo to the desired minimum stop position.
2. Send 'l' → stored as lower limit in EEPROM.
3. Move the servo to the desired maximum stop position.
4. Send 'h' → stored as upper limit in EEPROM.
Why different methods for the two families? The STS protocol (ST3020) has a hardware position-offset register at 0x1F that shifts the reported encoder value —
setPositionOffset(2048 − currentTick). The SCS protocol (SC15) has no offset register, so center calibration is not possible. Instead, the SC15 supports angle limits (registers 0x09/0x0B) that mechanically clamp the allowed range —setAngleLimits(min, max).
Both servo types on one bus, started simultaneously.
// Queue on both servos — neither starts yet
st.moveTimeAsync(st.degToTicks(180), 900, 800);
sc.moveTimeAsync(sc.degToTicks(150), 900, 600);
// Fire: both start at exactly the same moment
st.triggerAction();Move to a position as fast as the hardware allows — no time constraint, no speed cap, no ramp.
// setup() — once:
st.setAcceleration(0); // 0 = no ramp; full torque from standstill
// Snap to any position at maximum hardware speed:
st.moveTime(st.degToTicks(180), 0, 0);
// ^ ^
// time=0 speed=0 → no limitsWARNING: With
acc=0andspeed=0the servo moves hard and fast. Make sure the mechanical end-stops and the load can handle the impact.
uint16_t pos; int16_t speed; uint8_t temp; float amps; bool moving;
st.readPresentPosition(pos);
st.readCurrentSpeed(speed);
st.readCurrentTemperature(temp);
st.readCurrentCurrent(amps); // 1 count = 6.5 mA
st.isMoving(moving);
Serial.print(pos); Serial.print('\t');
Serial.print(speed); Serial.print('\t');
Serial.print(temp); Serial.print('\t');
Serial.print(amps, 3); Serial.print('\t');
Serial.println(moving ? "yes" : "no");-
Unique IDs required — two servos with the same ID will both respond, corrupting the bus.
-
Read sequentially — never read two servos at the same time on a shared bus:
st.readPresentPosition(pos); delay(2); sc.readPresentPosition(pos);
-
Stagger return delays — set different
returnDelayUnitsper servo in the Profile so replies don't collide:FeetechST3020::Profile pSt; pSt.returnDelayUnits = 0; FeetechSC15::Profile pSc; pSc.returnDelayUnits = 4;
-
No broadcast writes — ID 0xFE is blocked by the library.
| Symptom | Likely cause |
|---|---|
ping() returns false |
Wrong ID, wrong baud, no common GND, no power, wrong wiring (see below) |
| RX sees nothing | RX pin is behind the 1kΩ instead of directly on the bus node |
| Checksum errors | Missing 1 kΩ resistor, cable too long, baud too high |
setMode() has no effect |
EEPROM was locked — library now unlocks automatically |
| Servo jitters at target position | setAcceleration() too low, increase or set 0 |
| Two servos reply to one read | Duplicate IDs on bus |
setTargetVelocity() ignored |
Servo still in POSITION mode — call setMode(VELOCITY) first |
| SC15 stops before target angle | Angle > 180° — SC15 servo mode range is 0–180°; degToTicks() clamps at 180° |
| SC15 stops before target angle | setAngleLimits() may have been called with a reduced range — check EEPROM limits |
setPositionOffset() returns false on SC15 |
Expected — the SCS register map has no offset register; use setAngleLimits() instead |
FeetechBus protocol framing, checksum, UART I/O
└── FeetechDevice register map, endian + sign encoding, full API
├── FeetechST3020 STS family, 4096 ticks/rev, degToTicks(0–360°)
│ FeetechST3215 (type alias — identical register map)
└── FeetechSC15 SCS family, 1024 ticks/180°, degToTicks(0–180°)
Bus knows only the wire protocol. Device knows registers. Subclass knows the model.
class FeetechXYZ : public FeetechDevice {
public:
FeetechXYZ(FeetechBus& bus, uint8_t id) : FeetechDevice(bus, id) {
// Override only what differs from the STS defaults:
_reg.bigEndian = false;
_reg.signBit15 = true;
_reg.ADDR_PRESENT_POSITION_L = 0x38;
}
};- ESP32 (recommended)
- ESP32-S3, ESP32-C3
- AVR (use
beginPins()with hardware serial that supports pin remapping)
MIT