A portable, dependency-free C11 core for Feetech STS series smart servos over half-duplex UART. The project includes an STM32F103 hardware port using DMA-driven half-duplex UART with IDLE-line reception. The core is covered by 198 host unit tests in CI. The current AF open-drain port has completed 304,227 live transactions with zero retries or hard communication failures on the tested single-servo bench, separately from 390,385 historical transactions.
Quick links: API documentation | Hardware validation | Design decisions | Porting guide | Engineering postmortem
- Protocol and service core: implemented and covered by 198 passing host unit tests using Unity and CTest, gated in CI alongside a Cppcheck static-analysis pass.
- STM32F103 port: validated across four historical on-target campaigns totalling 390,385 transactions with zero retries and zero hard failures. Full per-campaign figures and methodology are in docs/hardware-validation.md.
- Port simplification: PA2 now stays in AF open-drain mode, removing per-packet GPIO switching and the unused scope marker.
- Latest bench report: two complete AF_OD campaigns passed 400 runs and 12,800 individual tests, recording 304,227 transactions with zero retries or hard communication failures. The tested configuration uses 1.5 kOhm from PA2/DATA to 3.3 V at 1 Mbaud, 8N1; removing the resistor caused communication failure. Direction-separated captures measured approximately 195 ns command rise time and 5 ns reply rise time. See hardware validation.
- In progress: Phase 1 electrical evidence closure (settled-low voltage and receiver-threshold margin), Phase 2 receive-state hardening, and Phase 3 cleanup under #10. Full-duplex adapter re-validation remains in #9. Current receive-timing limitations are documented in the porting guide.
An intermittent test failure was traced to state persisting between test runs rather than the initially suspected bus EMI. The investigation and supporting campaign logs are documented as an engineering postmortem in issue #8. The full hardware bring-up was merged in PR #11.
- Noise-resilient parsing: a sliding-window parser resynchronises after malformed data, false headers, and partial packet fragments
- Zero heap allocation: all API buffers are caller-provided, making memory ownership explicit and keeping the core usable on targets without a heap
- HAL-agnostic core: three injected function pointers decouple servo logic from the MCU UART implementation
- Instrumented bus: per-transaction counters for transactions, retries, retry-saves, and hard failures support regression testing, hardware validation, and fault analysis
- Granular error reporting: separate result codes distinguish validation, protocol, transport, timeout, and device faults
- Portable: written in C11 with fixed-width types; no platform-specific dependencies in the core
- Broad command coverage: position, speed, acceleration, PWM, step, torque, telemetry, EEPROM, and ID control
- 198 host unit tests (Unity/CTest), plus an on-target hardware integration and stress suite
Three layers, each independently testable:
Protocol layer (sts_protocol): stateless packet framing, checksum, and response parsing. It holds no knowledge of hardware or servo state, and each parse call has no shared parser state. Synchronisation of shared bus access remains the responsibility of the service or application layer.
Service layer (sts_servo, sts_servo_cmd): HAL-agnostic servo management built on the protocol layer. Handles bus wiring, servo handles, and all transactions through a single command engine (sts_execute_command), which also carries the transaction, retry, and failure counters. Platform UART is injected as function pointers via sts_bus_t, so the core has no MCU dependency. The command set (sts_servo_cmd) provides motion control, telemetry reads, and configuration built on the register-access primitives.
Port layer (Ports/sts_ports_stm32.c): the STM32F103 implementation of the injected transmit, receive, and flush contract: DMA transfers with IDLE-line variable-length reception, direct-wired half-duplex turnaround, and bounded error recovery. Implementing the transport callbacks ports the core to another MCU, with this file as a worked reference; see the porting guide.
The STM32F103 port is exercised by an on-target integration suite (Hardware_Tests/) over SEGGER RTT, covering protocol validation, position, speed and acceleration control, the torque state machine, and moving-status semantics. An instrumented stress runner drives repeated campaigns and reports pass, skip and fail counts, a per-test failure histogram, quiescence-gate activation, and bus-counter deltas.
| Campaign | Runs | Transactions | Retries | Hard failures |
|---|---|---|---|---|
| Baseline | 100 | 67,801 | 0 | 0 |
| Intermediate | 30 | 19,269 | 0 | 0 |
| Final | 200 | 152,980 | 0 | 0 |
| Flush removal | 200 | 150,335 | 0 | 0 |
| Total | 530 | 390,385 | 0 | 0 |
These historical figures cover the earlier GPIO-switching port, with the timed turnaround flush removed for the fourth campaign. The current AF_OD results are kept separate because they describe a different electrical configuration:
| AF_OD campaign | Runs passed | Individual tests passed | Transactions | Retries | Hard failures |
|---|---|---|---|---|---|
| Rise-time capture | 200/200 | 6,400/6,400 | 152,140 | 0 | 0 |
| Fall-time capture | 200/200 | 6,400/6,400 | 152,087 | 0 | 0 |
| Total | 400/400 | 12,800/12,800 | 304,227 | 0 | 0 |
The Phase 1 report records a passed functional non-regression gate. Electrical evidence closure and subsequent software phases remain pending.
These figures characterise one bench configuration: a single MCU, servo, cable, and environment. They are direct observations, not a general reliability claim for the design. Methodology, per-campaign conditions, and the oscilloscope work that retracted an earlier transient hypothesis are documented in docs/hardware-validation.md.
Why are hardware errors on ping treated as online?
STS_ERR_HARDWARE means the servo responded, so communication succeeded. The fault lies in the servo's internal state, such as overtemperature or overload, rather than the bus. Marking the servo offline in this case would be incorrect. Hardware error semantics belong in a higher application layer that has the context to make recovery decisions.
Why a centralised command engine?
All service layer transactions route through a single sts_execute_command function. This keeps TX framing, RX receive, and response parsing in one place, gives retry policy and the transaction counters a single home, and provides one point of change for future work such as mutex protection or asynchronous IO.
Why caller-provided buffers? Dynamic allocation can introduce variable latency, fragmentation, and runtime allocation failures. Caller-provided API buffers make memory ownership and capacity explicit while keeping the core usable on targets without a heap.
The full set, including the operating-mode and direction-encoding semantics, is in docs/design-decisions.md.
The example below shows the injection contract with a minimal blocking transport. The production STM32 port in Ports/sts_ports_stm32.c implements the same contract over DMA with IDLE-line half-duplex reception. Full API documentation is published at grish98.github.io/Feetech_Stm32.
#include "sts_servo.h"
#include "sts_servo_cmd.h"
/* 1. Implement your platform transport functions */
sts_result_t my_uart_tx(sts_bus_t *bus, const uint8_t *data, uint16_t len) {
UART_HandleTypeDef *huart = (UART_HandleTypeDef *)bus->port_handle;
return (HAL_UART_Transmit(huart, data, len, 10) == HAL_OK)
? STS_OK : STS_ERR_TX_FAIL;
}
/* Distinguish a silent bus from a transport fault; both are retryable, but only
the caller can tell them apart afterwards. */
sts_result_t my_uart_rx(sts_bus_t *bus, uint8_t *data, uint16_t len, uint32_t timeout_ms) {
UART_HandleTypeDef *huart = (UART_HandleTypeDef *)bus->port_handle;
switch (HAL_UART_Receive(huart, data, len, timeout_ms)) {
case HAL_OK: return STS_OK;
case HAL_TIMEOUT: return STS_ERR_TIMEOUT;
default: return STS_ERR_RX_FAIL;
}
}
/* 2. Initialise the bus and servo handles */
sts_bus_t bus;
sts_servo_t servo;
STS_Bus_Init(&bus, &huart2, my_uart_tx, my_uart_rx);
bus.flush_rx = NULL; /* optional: supply one to drain stale RX between retries */
bus.max_retries = 2U; /* 0 = single attempt */
STS_Servo_Init(&servo, &bus, 0x01);
/* 3. Ping to confirm the servo is online */
if (STS_servo_ping(&servo) == STS_OK) {
/* servo.is_online == STS_ONLINE */
}
/* 4. Drive the servo. Every call returns a result code; a short chain keeps the
first failure rather than overwriting it. */
sts_result_t res = STS_SetOperatingMode(&servo, STS_MODE_POSITION);
if (res == STS_OK) { res = STS_SetTorqueEnable(&servo, 1); }
if (res == STS_OK) { res = STS_SetTargetAcceleration(&servo, 50); }
if (res == STS_OK) { res = STS_SetTargetPosition(&servo, 2048); }
if (res == STS_ERR_TIMEOUT) {
/* Nothing answered: check wiring, servo ID, and baud rate. */
} else if (res == STS_ERR_HARDWARE) {
/* The servo replied but reports a fault, such as overload or overtemperature.
Communication is intact, so this is a servo-state problem, not a bus problem. */
}
/* 5. Read telemetry */
uint16_t pos = 0U;
uint8_t temp = 0U;
STS_GetPresentPosition(&servo, &pos);
STS_GetPresentTemperature(&servo, &temp);
/* 6. Read the bus counters at any time */
uint32_t failures = bus.hard_failures;GitHub tags are the source of project versions; the badge above follows the latest tag automatically. On a push to main, successful host tests and static analysis allow CI to create the next tag: v0.1 for the first merge with this workflow, then v0.2, v0.3, and so on. Feature branches and pull-request checks do not create tags. Re-running CI for an already tagged commit keeps its existing version; superseded main commits are skipped. Documentation deployment runs separately and does not gate tagging.
Tags remain attached to their original commits. There are no manually maintained per-file version numbers, and dependency/tool versions are independent of the project version.
The library uses CMake with a dual-target build system. Host tests run on the development machine with a native compiler, so no hardware is required. The ARM firmware target is selected automatically when an arm-none-eabi toolchain is configured.
- CMake 3.22+
- A C11 compiler (GCC or Clang)
- CTest (included with CMake)
cmake -B build_native
cmake --build build_native --config Debug
ctest --test-dir build_native -C Debug --output-on-failureAll 198 tests should pass across two suites: 40 in the protocol layer and 158 in the service and command layers. The --config and -C flags are required by multi-config generators such as Visual Studio and are ignored by single-config generators such as Ninja and Unix Makefiles.
Requires Doxygen. CI publishes the same output to GitHub Pages on every push to main.
cmake --build build_native --target docsGenerated HTML is written to build_native/html/index.html.
The Feetech STS protocol is a binary half-duplex UART protocol. Every packet follows this structure:
| Byte(s) | Field | Description |
|---|---|---|
| 0–1 | Header | Always 0xFF 0xFF |
| 2 | ID | Servo ID (0–253, 254 = broadcast) |
| 3 | Length | Number of remaining bytes (excl. header + ID) |
| 4 | Instruction | Command or status byte |
| 5..N | Parameters | Optional payload (0–253 bytes) |
| N+1 | Checksum | ~(ID + Length + Instruction + Params) & 0xFF |
Responses are variable length, which is why the STM32 port frames them with UART IDLE-line detection rather than a fixed byte count. The servo supports four operating modes (position, speed, PWM, and step) selected through STS_SetOperatingMode; their semantics are described in docs/design-decisions.md.
- Protocol layer: packet framing, checksum, noise-resilient parsing
- Service layer: HAL-agnostic bus abstraction, command engine, register access primitives, ping, and command coverage for position, speed, acceleration, PWM, step, torque, telemetry, EEPROM, and ID
- STM32F103 port: DMA half-duplex with IDLE-line reception, hardware-validated
- Transient-hypothesis measurement: oscilloscope capture found no turnaround transient, so the defensive RX flush loop was retracted and removed (#10)
- Port simplification: PA2 stays in AF open-drain, removing per-packet GPIO switching (#10)
- Phase 1 functional non-regression: two 200-run AF_OD campaigns, using 1.5 kOhm to 3.3 V at 1 Mbaud
- Phase 1 evidence closure: link the tested revision and complete campaign evidence; document settled-low voltage and receiver-threshold margin (#10)
- Phase 2 receive-state hardening and its campaign gate (#10)
- Phase 3 bounded error recovery, TX-completion review, documentation/compliance cleanup, and final campaign gate (#10)
- Sync Write and Bulk Read support
- Portable on-target test suite: route
Hardware_Tests/timing through theSTS_Delay_msandSTS_GetTick_msport hooks so the integration and stress suites can validate a new MCU port unmodified - Full-duplex bus-adapter path re-validation (#9)
The core (protocol and service layers) has no platform-specific dependencies and builds on any target with a C11 toolchain. Fixed-width integer types are used throughout for cross-architecture correctness. Porting to another MCU means supplying three transport callbacks, transmit, receive, and the optional flush_rx; no other platform code is required to drive servos. The porting guide documents the full contract, and the STM32F103 port in Lib/STS_Servo/Ports/ is a worked reference implementation.
Copyright (c) 2026 Grisham Balloo. All rights reserved.