Repository navigation
diffdrive
The differential-drive wheel kernel — the control law, what it demands of a caller, and how it is gated.
Status: design record for code being lifted, not invented. The control law already exists and is already gated; this document says what it is, what it demands of a caller, and what changes when it moves into this repo.
Source of truth for the extraction: src/archive/diffdrive/ (1657 lines of
kernel, 1223 of gate). Provenance is in that directory's own README.md —
extracted 2026-08-18 from src/firm/control/differential_drive.{h,cpp}, the
law itself byte-identical, namespace and includes changed.
One class, DiffDrive::DifferentialDrive, owning two wheels end to end:
- the staged duty write schedule and split-phase encoder sampling,
- the
(velocity, twist)control law — feedforward + Stage A wheel correction- fast PID + Stage C bias adaptation,
- stall, deficit and wedge latches,
- a lease watchdog on every motion command,
- an estop latch.
Its whole dependency list is <cmath>, <cstdint>, <algorithm>. It has its
own namespace and no inheritance relationship with any firmware header. That
independence is the reason it can move here at all, and it is worth defending:
the package gate compiles it with an include path of exactly its own directory,
so a firmware include creeping in fails immediately.
The kernel's commands and outputs are in native encoder counts — counts,
counts/s. There are no millimetres in it, no track width, no wheel radius, no
chassis geometry of any kind.
This is not an oversight to be corrected. It is the boundary that keeps the kernel portable: geometry is a property of a particular robot, and a wheel control law is not. Every mm ↔ counts conversion belongs above this class, in the adapter (see protocol §4).
A caller implements four small interfaces, declared at the top of the header. They are the package's own types, on purpose — the parent firmware connects them to its HAL with one-line forwarding adapters; a MicroPython C module or a MakeCode/PXT package implements them directly.
| port | methods | job |
|---|---|---|
Motor |
13 | one wheel: stage duty, tick() to execute + collect, counts out, emergency stop, wedge reporting |
Clock |
1 |
nowMicros() — monotonic [us]
|
Sleeper |
2 |
sleepMillis() settle/pace + yield() cooperative hand-off |
FiberLauncher |
1 | start the kernel loop on its own thread — optional |
FiberLauncher is the one you can decline. If you would rather drive the
kernel from your own loop, call step() yourself and implement launch() to
fail loudly. For a first test harness that is the simpler choice: it makes the
whole thing single-threaded and deterministic.
Two of its 13 methods carry semantics that are easy to get wrong and expensive to debug:
-
sampleTime()must stamp on collect SUCCESS only. A failed collect holds the previous stamp. The kernel derives itsi2cFaultCountpurely from that stamp failing to advance —requestSample()/tick()returnvoid, so stamp non-advance is the only observable "that collect did not land". A port that stamps unconditionally silently reports a healthy bus. -
rebaseline()is a software re-anchor and must issue no bus traffic. Encoders are never device-reset; position is accumulated and re-origined in software, and each rebaseline bumps that wheel'spositionEpochso a host can tell "the robot moved backwards" from "the origin moved".
Only a small part of the class is reachable from the wire, and for testing the kernel it is smaller still:
Status drive(float velocity, float twist, uint32_t lease); // [counts/s] [counts/s] [ms]
Status driveDuty(float dutyLeft, float dutyRight, uint32_t lease); // [%] [%] [ms]
void neutral(); // commanded stop, through the full stop path
void estop(); // latch: zero NOW; holds until estopClear()
void estopClear();
Output output() const; // seq-consistent snapshottwist is the measured/commanded half-differential, CCW-positive. So for
left/right wheel speeds:
velocity = (left + right) / 2
twist = (right - left) / 2
Every motion command carries a lease — a duration in [ms] from now,
clamped to kLeaseMax. When it expires the kernel stops. A dead caller cannot
mean a runaway.
This lines up exactly with the wire's WHEELS_V <left> <right> <duration>
(renamed from WHEELS, 2026-08-22 — docs/design/motion-api.md §9.2), where
duration is a required [ms] field with a 5000 ceiling for precisely
the same reason. duration becomes lease with no reinterpretation — the
one place where the wire and the kernel already agree on a concept without
anyone having designed it that way.
That agreement is why WHEELS_V is the right verb to build the first
end-to-end test around, rather than a body-frame verb like MOVE_X.
neutral() writes a neutral command to the mailbox; controlStep() zeroes
duty demand for it immediately, no ramp (stageStop() is a bare
stageDuty(0, 0)). estop() sets a latch that forces that same immediate-zero
path from the next cycle onward, regardless of the mailbox's state, and
additionally refuses every new motion command until estopClear().
Both are immediate at the duty level. The distinction is persistence and
guarantee, not speed of stopping: neutral()'s zero is an ordinary mailbox
write the very next drive() call overrides; estop()'s zero is
latched — effective even if the command handshake is wedged — and it blocks
new motion rather than merely commanding a stop.
A robot with a motion planner on top of this kernel can measure a much
bigger contrast between its own planned-stop verb and its own estop verb — a
planned stop that queues behind an active move can let the robot travel the
rest of that move before it even begins decelerating, while an estop
interrupts immediately. That is a property of the queue, not of this
kernel's neutral() vs estop(), and this kernel has no queue: don't cite a
queued-stop measurement here, because it does not describe what this class
does. See protocol §5.1.
Any halt path — a test harness's Ctrl-C included — calls estop(), because it
is the one that latches and refuses new commands, not because it decelerates
differently.
output() returns a seq-consistent snapshot with everything a t telemetry
frame needs, already measured:
-
timing —
now [ms],nowFine [us],cycleCount,cyclePeriodMeasured [us],cycleBusy [us],cycleOverrunCount -
per-wheel measurement —
positionLeft/Right [counts],velocityLeft/Right [counts/s],sampleTimeLeft/Right [us],positionEpochLeft/Right -
derived —
velocity,twist(both[counts/s]),appliedDutyLeft/Right [%] -
learned state —
lambda,biasLeft/Right [counts/s] -
health —
ready,estopped,leaseExpired,stallHalted, and per-wheelsat/stall/wedge/wedgeSuspect/deficit/connected -
sticky diagnostics —
leaseExpiryCount,i2cFaultCount
cycleBusy is worth calling out: it is the number to measure before an ASCII
telemetry-formatting cost can be trusted (the wire's own snprintf-per-column
concern). The kernel already publishes it, so the measurement is a
subtraction, not a new instrument.
sampleTime* are [us] stamps from the same base as nowFine. Age is
(int32_t)(nowFine - sampleTime) — deliberately signed, deliberately wrapping.
Right is deterministically about one settle window younger than left, because
sampling is sequential split-phase. A telemetry consumer that treats the two
wheels as simultaneous will see a phantom twist at high speed.
Nothing about the law. Specifically:
-
The files move as-is from
src/archive/diffdrive/intosrc/diffdrive/— same namespace, same includes, same behaviour. -
The fidelity gate comes with them.
golden_ref_drive.{h,cpp}+fidelity_harness.cpphold the kernel duty-for-duty against the frozen pre-kernel law it descends from (feedforward exact; closed loop equal at steady state). That gate is the reason we can move this code and still claim it is the same code, so it is not optional cargo. - The standalone-build check comes with them — compile with an include path of exactly the package directory.
Decision (stakeholder, 2026-08-20): radio-robot-lib is the authoritative
source for the control law. The prior locations are deprecated.
radio-robot-elite currently holds two copies — the original at
src/firm/control/differential_drive.{h,cpp} and the extracted package at
src/firm/diffdrive/. Both are now downstream of this repo, and both are
slated for removal once a consumer cuts over.
What that settles, and what it does not:
- Fixes land here first. A change made in elite and not here is a regression waiting to be re-applied, not a fix.
- The divergence window is still real until elite actually cuts over. Three copies exist today and the fidelity gate is what keeps them honest — but only when it is run. Shortening that window is worth doing early; it is not this library's blocker, but it is elite's.
-
Provenance stays documented, not assumed. The gate holds this code
duty-for-duty against
golden_ref_drive, which is the only reason we can claim this is the same law that drives the robot rather than a plausible descendant of it.
DifferentialDrive::Config plus the fluent setters are this library's
configuration, and they are all of it — gains, limits, latch thresholds, cycle
period. There is no config store, no field table, and no wire schema here; per
the 2026-08-20 decision, configuration storage is out of scope for both
libraries in this repo. See protocol §6.
Note what is not in Config: any millimetre, any track width, any wheel
radius. Geometry belongs to the caller (§1.1).