Debug, test, configure and calibrate STS3215 Feetech servo actuators for the soarm100 arm via the stservo-sdk. Use when: setting up new servos, diagnosing connection issues, reading or writing servo registers, homing or zero-calibrating the arm, adding features to the Streamlit dashboard, building position recorders or sync-movement testers, or developing low-level utilities for higher-level control modules.
The stservo-sdk package (stservo-sdk/src/stservo_sdk/) is the sole low-level interface between Python and the STS3215 Feetech servos used on the soarm100 arm. It communicates over a half-duplex RS-485/TTL serial bus using a proprietary packet protocol.
All development tooling lives in stservo-sdk/examples/:
homing_calibrate.py — CLI + interactive TUI for scan, ID assignment, limits, tuning, EEPROMhoming_dashboard.py — Streamlit web dashboard wrapping the same operations| Class | File | Role |
|---|---|---|
PortHandler | port_handler.py | Opens serial port, reads/writes raw bytes, timeout tracking |
ProtocolPacketHandler |
protocol_packet_handler.py |
Builds/parses STServo packets; byte helpers (sts_lobyte, sts_hibyte, sts_tohost, sts_toscs) |
sts | sts.py | High-level STS-series API (read/write position, speed, voltage, etc.) |
scscl | scscl.py | High-level SCSCL-series API (alternate family) |
GroupSyncRead | group_sync_read.py | Bulk-read same register from multiple IDs in one packet |
GroupSyncWrite | group_sync_write.py | Bulk-write same register block to multiple IDs in one packet |
| Register | Address | Description |
|---|---|---|
STS_ID | 5 | Servo ID (EEPROM) |
STS_BAUD_RATE | 6 | Baud-rate code (EEPROM) |
STS_MIN_ANGLE_LIMIT_L/H | 9/10 | Min travel limit (EEPROM) |
STS_MAX_ANGLE_LIMIT_L/H | 11/12 | Max travel limit (EEPROM) |
STS_CW_DEAD / STS_CCW_DEAD | 26/27 | Dead-band width |
STS_OFS_L/H | 31/32 | Position correction offset |
STS_MODE | 33 | 0=servo, 1=wheel (continuous) |
STS_TORQUE_ENABLE | 40 | 0=off, 1=on |
STS_ACC | 41 | Acceleration (SRAM) |
STS_GOAL_POSITION_L/H | 42/43 | Target position |
STS_GOAL_SPEED_L/H | 46/47 | Target speed |
STS_LOCK | 55 | EEPROM lock (1=locked) |
STS_PRESENT_POSITION_L/H | 56/57 | Current position (signed 15-bit) |
STS_PRESENT_SPEED_L/H | 58/59 | Current speed (signed 15-bit) |
STS_PRESENT_LOAD_L/H | 60/61 | Load (×0.1 %) |
STS_PRESENT_VOLTAGE | 62 | Voltage (×0.1 V) |
STS_PRESENT_TEMPERATURE | 63 | Temperature (°C) |
STS_STATUS | 65 | Error flags byte |
STS_MOVING | 66 | 1 if actively moving |
STS_PRESENT_CURRENT_L | 69 | Current (×6.5 mA) |
Error bits in STATUS: ERRBIT_VOLTAGE=1, ERRBIT_ANGLE=2, ERRBIT_OVERHEAT=4, ERRBIT_OVERELE=8, ERRBIT_OVERLOAD=32
sts High-Level Methods# --- Connection ---
PortHandler(port).openPort()
PortHandler(port).setBaudRate(1_000_000)
# --- Discovery ---
servo.ping(servo_id) # → (model, result, error)
# --- Position / Motion ---
servo.WritePosEx(id, pos, speed, acc) # write directly
servo.RegWritePosEx(id, pos, speed, acc) # queue for RegAction
servo.SyncWritePosEx(id, pos, speed, acc) # add to sync group, then:
servo.groupSyncWrite.txPacket() # fire all at once
servo.RegAction() # broadcast INST_ACTION
servo.WheelMode(id) # set continuous rotation
servo.WriteSpec(id, speed, acc) # wheel-mode speed
# --- Telemetry reads ---
servo.ReadPos(id) # → (pos, result, error)
servo.ReadSpeed(id) # → (speed, result, error)
servo.ReadPosSpeed(id) # → (pos, speed, result, error)
servo.ReadMoving(id) # → (moving, result, error)
servo.IsMoving(id) # → (bool, result, error)
servo.ReadLoad(id) # → (load_pct, result, error)
servo.ReadVoltage(id) # → (volts, result, error)
servo.ReadCurrent(id) # → (mA, result, error)
servo.ReadTemperature(id) # → (degC, result, error)
servo.ReadAccelaration(id) # → (acc, result, error)
servo.ReadMode(id) # → (mode, result, error)
servo.ReadCorrection(id) # → (offset, result, error)
servo.ReadStatus(id) # → (status_byte, result, error)
servo.GetBaudrate() # → int (from PortHandler)
# --- EEPROM management ---
servo.LockEprom(id)
servo.unLockEprom(id)
# --- Direct register access (inherited from ProtocolPacketHandler) ---
servo.read1ByteTxRx(id, address)
servo.read2ByteTxRx(id, address)
servo.write1ByteTxRx(id, address, value)
servo.write2ByteTxRx(id, address, value)
servo.writeTxRx(id, address, length, data_list)
All reads return PacketResult(data, result, error) or (value, result, error). Always check result == COMM_SUCCESS (0).
from stservo_sdk import PortHandler, sts
from stservo_sdk.stservo_def import COMM_SUCCESS
ph = PortHandler("/dev/tty.usbserial-XXXX") # macOS; Linux: /dev/ttyUSB0
assert ph.openPort(), "Failed to open port"
assert ph.setBaudRate(1_000_000), "Failed to set baud"
servo = sts(ph)
Find ports with: python -c "from serial.tools import list_ports; [print(p.device, p.description) for p in list_ports.comports()]"
from homing_calibrate import discover_servos
found = discover_servos("/dev/tty.usbserial-XXXX", 1_000_000, range(1, 11))
# Returns {id: model_number}
Or via CLI: python homing_calibrate.py --device /dev/tty.usbserial-XXXX --scan-range 1-10
from homing_calibrate import read_servo_diagnostics
diag = read_servo_diagnostics("/dev/tty.usbserial-XXXX", 1_000_000, [1,2,3,4,5,6])
for sid, data in diag.items():
print(f"Servo {sid}: pos={data['Position']} V={data['Voltage']} T={data['Temperature']}")
soarm100 joint IDs: 1–6
Zero pose: a specific physical pose (e.g. arm hanging straight down / fully folded) — NOT the midpoint 2048. Position 2048 is the electrical midpoint; the physical zero pose must be manually established.
Goal: Write STS_OFS_L offset on each joint so that the desired physical zero pose reads as the agreed reference value used by higher-level modules.
Step 1 — Unlock EEPROM on all joints (IDs 1-6)
--unlock flag or: servo.unLockEprom(id)
Step 2 — Disable torque so joints can be moved freely
servo.write1ByteTxRx(id, STS_TORQUE_ENABLE, 0) # repeat for 1-6
Step 3 — Manually move arm to physical zero pose
Step 4 — Record raw positions at zero pose
pos, _, _ = servo.ReadPos(id) # note these for each of IDs 1-6
Step 5 — Decide the target reference value for "zero" (e.g. 2048)
Compute offset = target_zero - pos
Encode: raw_offset = (abs(offset) | 0x0800) if offset < 0 else abs(offset)
servo.write2ByteTxRx(id, STS_OFS_L, raw_offset)
Step 6 — Set angle limits per joint (define physical travel boundaries)
Step 7 — Re-enable torque; lock EEPROM
servo.LockEprom(id)
Via existing CLI tools:
python homing_calibrate.py \
--device /dev/tty.usbserial-XXXX \
--scan-range 1-6 \
--angle-limit 1:512:3584 --angle-limit 2:512:3584 \
--set-acc 1:50 --set-speed 1:300 \
--torque 1:on --unlock --lock
1. Connect single servo (no others on bus) → scan-range should be 1-254
2. Assign proper ID: --assign-id 1:5
3. Set angle limits: --angle-limit 5:512:3584
4. Set default acc/speed: --set-acc 5:50 --set-speed 5:300
5. Lock EEPROM: --lock
cd stservo-sdk
python -m streamlit run examples/homing_dashboard.py
These features are planned additions to homing_dashboard.py to support higher-level application development.
Purpose: Debug control loops; verify that position commands are followed.
st.line_chart or Plotly time-seriesstreaming: bool, poll_interval_ms, history_secondsst.empty() + time.sleep or st_autorefresh componentPurpose: Step-by-step guided homing for soarm100 (IDs 1–6) into a specific physical zero pose.
STS_OFS_L offsets → confirm → write offsets → set limits → re-lockPurpose: Capture demonstration trajectories to bootstrap learning-based controllers.
ReadPosSpeed for IDs 1–6 at fixed Hztimestamp, id, position, speedSyncWritePosEx + groupSyncWrite.txPacket()Purpose: Send direct position or velocity commands to one or all joints from the UI.
groupSyncWrite.txPacket() for IDs 1–6 at oncePurpose: Validate synchronized multi-joint commands before integrating with the motion planner.
Purpose: Catch thermal, overload, or voltage errors during long runs.
ReadStatus, ReadTemperature, ReadCurrent for IDs 1–6st.warning / st.error badges when error bits are setPurpose: Reproducible robot setup across sessions and hardware.
configs/configs/ folder in the main projectPurpose: Fine-tune zero offsets without re-running full homing.
ReadCorrection value, slider to adjust by ±200 countsPurpose: Verify that angle limits are physically correct.
When a feature in fullstack_manip/ (e.g., core/robot.py, control/) needs servo-level access:
PortHandler and sts directly from stservo_sdkGroupSyncRead / GroupSyncWrite for high-frequency joint state loops (read 4 bytes position+speed in one packet per servo)read4ByteTxRx / SyncWritePosEx + txPacket inside a LoopRateLimiter from fullstack_manip/utils/loop_rate_limiters.py| Code | Rate |
|---|---|
| 0 | 1,000,000 (default) |
| 1 | 500,000 |
| 2 | 250,000 |
| 3 | 128,000 |
| 4 | 115,200 |
| 5 | 76,800 |
| 6 | 57,600 |
| 7 | 38,400 |
| Code | Constant | Meaning |
|---|---|---|
| 0 | COMM_SUCCESS | OK |
| -1 | COMM_PORT_BUSY | Port locked by another call |
| -2 | COMM_TX_FAIL | Send failed |
| -3 | COMM_RX_FAIL | No response received |
| -4 | COMM_TX_ERROR | Malformed packet |
| -6 | COMM_RX_TIMEOUT | Response timed out |
| -7 | COMM_RX_CORRUPT | Bad checksum |