Bus servo
Up to 16 smart serial servos on one shared UART — position, speed, torque control and rich feedback, Feetech ST and SC series.
Requires the Feetech/Waveshare SCServo library and <PardaloteBusServo.h> in the sketch. Supports Feetech ST / SMS series (0–4095 counts, e.g. the STS3215 used in the LeRobot SO-100/SO-101 arms) and SC / SCS series (0–1023 counts).
Unlike PWM servos, every bus servo shares one UART and is addressed by a hardware servo ID (1–253), and positions are raw encoder counts, not degrees. Wiring is via a Waveshare Serial Bus Servo Driver board (or equivalent) on a hardware serial port: UNO R4 WiFi → Serial1 (D0/D1), ESP32 → Serial1/Serial2.
Give the servos their own power supply (6–7.4 V typical) — not the board's 5 V rail. Examples assume:
arduino.add('shoulder', new BusServo());
configureBus()
Optional bus configuration — defaults to Serial1 at 1 Mbps, brought up lazily on first attach(). Call once from any instance; it affects all bus servos.
| Option | Type | Description |
|---|---|---|
serial |
number | Hardware serial port number (default 1). |
baud |
number | Bus speed (default 1000000). |
rxPin, txPin |
number | ESP32 only: custom UART pins. |
arduino.shoulder.configureBus({ serial: 1, baud: 1000000 });
arduino.shoulder.configureBus({ rxPin: 18, txPin: 19 }); // ESP32 custom pins
A headless sketch has the same control: PardaloteBusServo.configureBus(rxPin, txPin, serialIndex = 1, baud = 0) — call it in setup() before attach(). The rx/tx pins are ESP32-only (UNO R4 is fixed to Serial1 = D0/D1 and ignores them).
attach()
Binds this instance to a servo ID on the bus. Call inside on('ready').
| Parameter | Type | Description |
|---|---|---|
id |
number | Hardware servo ID, 1–253. Servos ship as ID 1. |
series |
string | Optional. 'ST' (0–4095, default) or 'SC' (0–1023). |
arduino.shoulder.attach(1);
arduino.wrist.attach(3, 'SC');
detach()
Releases torque and unbinds the instance.
write()
Moves to a position in raw counts, with optional per-move speed and acceleration.
| Parameter | Type | Description |
|---|---|---|
counts |
number | Target position: ST 0–4095, SC 0–1023. |
options |
object | Optional. { speed, acc } for this move. |
arduino.shoulder.write(2048); // centre
arduino.shoulder.write(3000, { speed: 3000, acc: 50 });
writeDegrees()
Degree convenience wrapper — accurate for ST (4096/360°), nominal for SC.
center()
Moves to the middle of the range (resolution / 2, i.e. 2048 for ST).
setMoveDefaults()
Sets the default speed and acceleration used by plain write() and group write() / writeTimed().
writeTimed()
Reaches a position in about a set time — the speed is picked from the move distance. What lets bus servos arrive together in a group.
| Parameter | Type | Description |
|---|---|---|
counts |
number | Target position in counts. |
duration |
number | Approximate arrival time in ms. |
gesture()
Plays an authored segment schedule — an ordered list of moves the board runs back-to-back on its own clock. Values are in counts. A bus servo takes a (position, speed) target and runs its own move, so the board renders each segment's easing curve with a streaming interpolator: it samples the curve on a fixed clock and streams look-ahead setpoints (batched across a group into one phase-locked write), so the shape you author — overshoot included — is the shape the servo runs. Fires done when the last segment lands. Timing follows the shared gesture timing rules: segments start on schedule, and the last key is landed or given up on 2 s after its expected arrival. Coordinate several lanes with a group.
Each segment carries a duration and a displacement expressed either relatively (by) or absolutely (to):
| Field | Type | Description |
|---|---|---|
dur |
number | Segment duration in ms — sizes the segment's speed. |
by |
number | Relative displacement in counts — the default frame. |
to |
number | Absolute target in counts — use in place of by. |
curve |
string | Easing shape for the segment — linear (default), easeIn, easeOut, easeInOut, back. Rendered on the board by the streaming interpolator. A segment shorter than ~600 ms plays linear regardless (too few stream ticks to render an easing shape cleanly; the difference is imperceptible at that duration). |
Relative by default (the board reads the live start position, then chains from each target). Absolute targets are clamped to the series range / setLimits(). Up to 12 segments. The board paces the stream to the servo's real position: if a segment's implied speed exceeds what the servo can deliver, the schedule holds until the hardware catches up rather than running ahead of it, so the timeline stays honest instead of drifting.
arduino.shoulder.gesture([
{ by: 600, dur: 400 },
{ by: -600, dur: 600 },
{ by: 80, dur: 200 },
]);
await arduino.shoulder.gesture([ /* … */ ]).whenDone();
A direct write(), runSpeed(), or setMode() cancels a running gesture. To coordinate several actuators at once, see group.gesture().
whenDone()
Promise for the most recent move — resolves true when the board reports the servo settled (its own Moving flag — real arrival, not a timer), false on the safety timeout (default max(duration × 2, 10000) ms; pass { timeout } or a bare number to override, 0 to wait forever). The same method exists on servos, steppers, and groups.
await arduino.shoulder.writeTimed(3000, 1500).whenDone();
await arduino.shoulder.writeTimed(1000, 1500).whenDone();
stop()
Halts the servo — holds the last-read position.
setMode() / runSpeed()
Wheel mode for continuous rotation — the same verb as the stepper's runSpeed().
| Parameter | Type | Description |
|---|---|---|
mode |
string | 'wheel' or 'position'. |
speed |
number | Rotation speed; sign sets direction, 0 stops. |
arduino.wheel.setMode('wheel');
arduino.wheel.runSpeed(2000);
arduino.wheel.runSpeed(0);
arduino.wheel.setMode('position');
enableTorque() / disableTorque()
Hold position, or go limp. Disabling torque lets you move a joint by hand while read() streams the position — the basis of the teach-a-trajectory workflow in projects like LeRobot.
read()
Starts polling feedback. ONE bus transaction per poll tick — however many browsers are connected — returns position, velocity, load, voltage, temperature, and (ST) current. The board runs the poll (per-browser interval and threshold) and only transmits changes of threshold+ counts (gated on position).
| Parameter | Type | Description |
|---|---|---|
interval |
number | Optional. Poll interval in ms. Pass END to stop. |
threshold |
number | Optional. Minimum position change worth transmitting, in counts (0 = default: 2 — the encoder noise floor). Also settable via setReadInterval(ms) / setReadThreshold(counts). |
setLimits() / clearLimits()
Soft position limits, enforced on the Arduino — every commanded position (browser write, sketch write, or group SyncWrite) is clamped to the range before it reaches the servo. Software-only by design: the servo's own EEPROM limit registers are never written. Same shape as setLimits on the stepper and PWM servo.
| Parameter | Type | Description |
|---|---|---|
min, max |
number | Allowed position range in counts. |
readFirmwareLimits()
The servo's own angle limits, stored in its EEPROM — separate from the software setLimits() above. The board reads them once when the servo attaches and caches them, so the cached value is usually already available as the firmwareLimits property (see below); it also replays on reconnect. Call readFirmwareLimits() only to force a fresh read — e.g. after changing the limits on the servo itself with the vendor tool.
Read-only here: Pardalote never writes these registers. By Feetech convention, min and max both 0 means the limits are disabled (multi-turn / continuous), reported as enabled: false.
Returns { min, max, enabled } (counts), or null if the servo didn't answer (unpowered or absent). enabled is false when the servo reports min === 0 && max === 0.
await arduino.shoulder.readFirmwareLimits();
const fw = arduino.shoulder.firmwareLimits; // cheap cached read, no bus traffic
if (fw && fw.enabled) console.log(`firmware limits: ${fw.min}–${fw.max}`);
setHome() / home()
setHome(position) declares the home position — no-arg means "the current position is home", which pairs naturally with torque-off hand-posing (pose the joint, read(), setHome()). home() moves there; home(duration) moves there smoothly. Default home is the centre of range (2048 ST / 512 SC). Same pair as the stepper and PWM servo.
arduino.shoulder.disableTorque();
// ...move the joint by hand...
arduino.shoulder.read();
arduino.shoulder.setHome().enableTorque();
await arduino.shoulder.home(1000).whenDone();
calibrate()
Declares the servo's current physical position as centre. Move the joint to its zero pose by hand (torque off) first.
scan() / ping() / setId()
Bus utilities. Servos ship as ID 1 — to renumber, put a single servo on the bus and call setId(), one servo at a time.
| Function | Returns |
|---|---|
scan(from, to) |
Array of responding IDs in the range, e.g. [1, 2, 3]. |
ping(id) |
true / false. |
setId(newId) |
Renumbers the currently addressed servo. One servo on the bus only — with several servos sharing an ID, they'd all take the new one. |
Properties and state
| Property | Description |
|---|---|
arduino.shoulder.position |
Raw counts — real feedback, needs polling. |
arduino.shoulder.target |
Last commanded goal — set immediately by write(). |
arduino.shoulder.hasTarget |
true while target is a live goal; false when the servo has no active target (freed, in wheel mode, or never commanded). Check this before trusting target. |
arduino.shoulder.positionDegrees |
Position converted to degrees. |
arduino.shoulder.velocity |
From feedback. |
arduino.shoulder.load |
From feedback. |
arduino.shoulder.voltage |
Volts. |
arduino.shoulder.temperature |
°C. |
arduino.shoulder.current |
Raw units (ST only). |
arduino.shoulder.firmwareLimits |
The servo's own EEPROM angle limits: { min, max, enabled } or null (not yet read / no answer). Cached from the attach-time read — see readFirmwareLimits(). |
arduino.shoulder.present |
Is the servo answering? true = found, false = no response (check wiring / ID / baud), null = not yet attached. Seeded by the attach-time ping (the browser equivalent of the serial monitor's [found] / [NO RESPONSE] line), then kept live from read() feedback — a servo powered up or unplugged mid-session updates it. See the 'presence' event. |
arduino.shoulder.isGesturing |
true while the board is playing a segment schedule on this servo (any origin). |
target vs position: write(n) sets target immediately (where you told it to go); position is real encoder feedback and only tracks toward target while you're polling. Unlike the stepper, bus-servo target is browser-side only — the servo has no board-replayed goal, so it isn't restored after a board reset.
hasTarget guards target: a position write() or gesture() makes it true; disabling torque clears it (a freed servo isn't holding a goal — you're hand-posing it), as does entering wheel mode. target keeps its last number for reference, but hasTarget tells you whether that number is still meaningful. Re-enabling torque does not set a new target — the servo holds where it is, and hasTarget stays false until you command a position again.
Events
| Event | Payload | Fires when |
|---|---|---|
'change' |
{ position, velocity, load, voltage, temperature } |
A polled reading changed by at least the threshold. |
'write' |
{ position } |
A move is issued (including a gesture start — position is the predicted rest). |
'done' |
{ position, arrived } |
A move or timed move settles (the servo's Moving flag cleared), or a gesture lands. arrived is false only for a gesture that gave up. |
'fwlimits' |
{ min, max, enabled } or null |
The servo's firmware angle limits arrived — at attach, on reconnect, or after readFirmwareLimits(). |
'presence' |
{ servoId, present } |
The servo's answering state changed. Fires on the attach-time ping, on reconnect, and whenever read() feedback shows the servo appearing or disappearing (present: false = no answer — wrong ID, wiring, baud, or unpowered). Needs read() polling active to track mid-session changes. |
'gesturestart' / 'gestureend' |
{} |
The board starts / ends playing a schedule on this servo — any origin (this browser, another, or the sketch). End = the last segment settles or a superseding write. |
Shorthand: onChange(fn), onWrite(fn), onDone(fn), onPresence(fn), onGestureStart(fn), onGestureEnd(fn). The isGesturing property tracks whether a schedule is playing (any origin).
Returns { logicalId, servoId, series, attached, present, mode, torque, resolution, target, hasTarget, position, velocity, load, voltage, temperature, current, limits, firmwareLimits, home, interval }.
See also: Groups · Bus servo example · Troubleshooting