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 use rotate().
  • In moveArcDist() / moveArcTime() / motorsArc(), an angle with |angle| > 90 makes the inner wheel spin backwards — the path is still an arc, but behavior is less predictable.
  • moveTime() and moveArcTime() 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() and resetAngle() 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 (pixelsRainbow etc.) — block for the entire duration.

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).