Library Limitations
This page documents the boundaries of the UNI library: things worth knowing in advance so the robot's behavior never comes as a surprise. Most of these are deliberate trade-offs rather than bugs, and each one comes with a recommended way to work with it.
Movement and Commands
Only one movement command runs at a time
There is no command queue: a new movement command silently cancels the current one. The code
moveDistAsync(50, 500); rotateAsync(50, 90); will only perform the rotation —
the distance move is aborted right after it starts.
How to work with it: wait for completion between async commands using
waitMove(), or use the blocking versions (moveDist,
rotate) which wait on their own.
Minimum movement power — minPower
Power below minPower (default 15) in movement commands is automatically raised
to it: below that level the motors cannot overcome static friction. You cannot drive slower
than ~15% with moveDist/rotate and similar commands.
How to work with it: the direct motor commands motors() and
motorsSync() do not apply this limit — use them for very slow motion. The
threshold is configurable via TuningConfig::minPower.
Speed is a percentage, not mm/s
The power parameter is a percentage of maximum, not a physical speed. Actual
speed depends on battery charge: the robot drives faster on a fresh battery than on a
drained one. Closed-loop control compensates by position (stopping accuracy is
preserved), but the time a command takes will vary.
How to work with it: never base your logic on movement timing — verify
results via odometry (getDistance(), getAngle()).
The safety timeout ends movements silently
If the robot has not reached its target within ~3× the estimated time (pushed against a wall, wheels blocked), the command finishes on its own — with no error code. A blocking call simply returns as if the move had completed.
How to work with it: where getting stuck is likely, use
moveDistAsync() + waitMove(timeout): a false return
means the robot did not arrive in time. Then compare getDistance() against the
expected distance.
Arc limitations
moveArcRadius()requires a radius of at least half the track width (~53 mm). For turning in place userotate().- In
moveArcDist()/moveArcTime()/motorsArc(), ananglewith |angle| > 90 makes the inner wheel spin backwards — the path is still an arc, but behavior is less predictable. moveTime()andmoveArcTime()stop on a timer without a deceleration profile — the stop is sharper than distance-based commands.
Accuracy and Odometry
Physical accuracy equals calibration accuracy
Turns are physically accurate to the extent the track width (trackLengthMM) is
accurate; distances depend on wheel diameter (wheelDiameterMM) and ticks per
revolution (ticksPerRev).
Calibration rule: if odometry reads exactly the target
(getAngle() = 90 after rotate(50, 90)) but the robot physically
turned more or less — calibrate the geometry in UniConfig, not the PID
gains.
Odometry accumulates drift
Position is computed from wheel encoders only: wheel slip, floor bumps and collisions are
invisible to odometry. There is no gyroscope or external correction.
moveTo() and setPosition() work well over routes of a few meters,
but the error grows with mileage and aggressive maneuvers.
How to work with it: on long routes, periodically "re-ground" the pose
against an external reference (wall, line) and call setPosition() with known
coordinates.
Completion tolerances and no reverse correction
Movement commands finish when entering a tolerance band by odometry:
rotateTolDeg (default 1°) for turns and moveTolMM (1 mm) for
distances. If the robot overshoots the target it is fixed by the brake — it never backs up
to correct (this removes end-of-move jitter).
How to work with it: need higher precision — reduce the tolerance and lower
moveAccel/rotateAccel in TuningConfig (gentler
braking means less overshoot).
moveTo: lateral drift is proportional to the turn tolerance
moveTo() first turns toward the target (within rotateTolDeg), then
drives straight holding the heading — but it does not steer onto the point along
the way. A 1° heading error produces ~5 mm of lateral drift per 300 mm of travel.
How to work with it: reduce rotateTolDeg, or split a long leg
into two moveTo() calls — the intermediate point recalculates the heading.
Coordinate system and resets
- The X axis points forward from the robot's starting pose, Y points sideways, and a positive angle is a turn to the right.
resetDistance()andresetAngle()reset only the trip counters for distance and angle — they do not touch the X/Y pose.- To anchor odometry to the field (or reset everything at once), use
setPosition(x, y, angle).
Behavior and Blocking
Blocking calls
Some functions halt program execution:
stop(HARD)— blocks ~50 ms (braking time);waitButton()— waits forever for a press;ultraSonic()— up to ~70 ms when no echo returns (3 attempts);- all light ring effects (
pixelsRainbowetc.) — block for the entireduration.
How to work with it: in a loop that watches sensors while driving, avoid
long effects; for lighting during movement use the basic
pixel()/pixelsAll() calls.
Lazy initialization
Calling begin() is optional — the first command initializes the robot
automatically. The consequence: a sketch that never calls any robot method will
not bring up the display or odometry.
How to work with it: if the robot should "just stand there showing the
screen" — call robot.begin() explicitly in setup().
The API is designed for setup() and loop()
The library itself uses the second ESP32 core for odometry and control loops, and API calls are synchronized assuming a single user core. Calling robot methods from your own FreeRTOS tasks is unsupported and may cause race conditions.
Peripherals
Display
displayPrint()strings — 15 characters maximum, longer ones are truncated.- The screen refreshes ~10 times per second: values changing faster are partially skipped — this is normal.
Servos
- At most 12 servos at once (16 ESP32 PWM channels minus 4 for the motors).
- A PWM channel is bound to a port on first use and is not reused for another port
(though
servoDetach()does not consume channels). - Pulses are fixed: 0.5–2.5 ms at 50 Hz. Servos with non-standard calibration cannot be adjusted.
- Horn speed is not controlled — the servo moves to the angle at full speed. Smooth motion is achieved with a series of small angle steps and delays.
Ultrasonic sensor
The working range of ultraSonic() is 20–4000 mm. A return value of
0 means "no echo": the target is too close, too far, or does not reflect
sound.
Important: always check for zero separately, otherwise the condition
dist < threshold falsely triggers as "obstacle right here":
int dist = module.ultraSonic(P6, P7);
if (dist > 0 && dist < 150) {
robot.stop(HARD); // the obstacle really is close
}
No analog input on P5 and P6
When an analog read is attempted on P5/P6, the library prints a warning to Serial.
How to work with it: for the line sensor and any analog sensors, use
ADC-capable ports — P1, P2, P3, P4 (ADC2) or P7
(ADC1). Digital sensors (digitalSensor()) work on any port, including
P5/P6.
Wi-Fi note: P1–P4 use ADC2, which is unavailable while Wi-Fi is active. The library itself does not use Wi-Fi, so P1–P4 are safe; but if you add Wi-Fi to your sketch, the only independent analog input is P7 (ADC1).