High-performance embedded C++ for ESP32 and friends — magnetic / optical encoder drivers.
LLM usage note: if this library is consumed from a coding AI workflow, explicitly point the agent to
API.mdfirst.API.mdis the LLM-facing contract (public API + examples + constraints) and avoids wasting time/tokens scanning source files and this human-oriented README.
Warning - Active Development: This library is under active architecture work to support multiple projects in parallel. Its structure is not finalized yet and may change without notice while this work is in progress. Updates are currently frequent (often daily). Target for structural freeze and stable
v1.0.0: June 2026.
Chip-neutral encoder interface plus drivers. Designed around two real deployments that look different on the board but identical in code:
- One bus, several encoders, one multiplexer (the original "Rachel" rig — two AS5600s sharing a TCA9548A).
- Multiple buses, no multiplexer (the new project — every encoder wired to its own I2C peripheral or to dedicated channels).
Both work through the same IEncoder API. The multiplexer is optional.
- C++ Compatibility
- Features
- Supported chips
- Dependencies
- Architecture
- Quick start — direct connect
- Quick start — behind a multiplexer
- Quick start — PWM-only (
As5600Pwm) - Quick start — I2C + PWM combined (
As5600I2cPwm) - Multiplexer is optional (per driver)
- Changing direction
- Logging
- Testing
- Acknowledgements
- License
- Arduino CLI symlink note (rarely relevant)
- Own source minimum:
C++17. - Effective minimum for consumers:
C++17. - Dependency impact: Declared internal dependencies
UngulaCoreandUngulaHalareC++17.
- Clean
IEncoderinterface — every method drives a single concrete behaviour. - Multiplexer is optional: pass a pointer or
nullptr, the driver does the right thing. - Degree-first API. Set the calibration once via
setCalibration(steps_per_degree);readAngle()/angle()return degrees from then on, no per-call divisor needed. RawreadPosition()/position()are still there for callers that want counts. - Wrap-around handled — the AS5600 returns 12-bit raw angles; the driver tracks 4095↔0 transitions and accumulates correctly.
- Per-instance EmblogX logging toggle. Off by default.
- Header-only
EncoderFakefor tests; locks the interface so a future signature change cannot silently break drivers.
| Chip | Bus | Resolution | Driver |
|---|---|---|---|
| AS5600 | I2C (I2cMaster) |
12 bit / 4096 steps | ungula::encoder::drivers::As5600I2c |
| AS5600 | PWM (IPwmInput) |
12 bit / 4096 steps | ungula::encoder::drivers::As5600Pwm |
| AS5600 | I2C + PWM (combined) | 12 bit / 4096 steps | ungula::encoder::drivers::As5600I2cPwm |
| AS5047P | SPI (SpiMaster) |
14 bit / 16384 steps | ungula::encoder::drivers::As5047pSpi |
| MA730 | SPI (SpiMaster) |
14 bit / 16384 steps | ungula::encoder::drivers::Ma730Spi |
| MT6835 | SPI (SpiMaster) |
21 bit / 2097152 steps | ungula::encoder::drivers::Mt6835Spi |
| MT6701 | ABI / quadrature (IDecoder) |
up to 16384 counts (decoder-dependent) | ungula::encoder::drivers::Mt6701Abi |
The IEncoder interface is transport-agnostic so new drivers (additional ABI chips, RS-485 absolute encoders, optical encoders) slot in without breaking host code.
UngulaCore—ungula::core::time::delay*for I2C pacing.UngulaHal—i2c::I2cMaster,gpiofor the optional DIR pin,multiplexer::IMultiplexerfor the optional channel select.EmblogX— runtime diagnostics. Logging is off by default.
- Core encoder API only:
#include <ungula/encoder.h>- AS5600 (I2C transport) direct-connect example:
#include <ungula/hal/i2c/i2c_master.h>
#include <ungula/encoder/drivers/as5600_i2c.h>- AS5600 (I2C transport) + TCA9548A multiplexer example:
#include <ungula/hal/i2c/i2c_master.h>
#include <ungula/hal/multiplexer/drivers/multiplexer_tca9548.h>
#include <ungula/encoder/drivers/as5600_i2c.h>ungula::encoder
├── IEncoder ← chip-neutral, transport-agnostic
│ ├── setCalibration() / readAngle() / position() / direction
│ └── enableLogging() / disableLogging()
└── drivers/
├── As5600I2c ← AS5600 over I2C (+ optional mux + DIR pin)
├── As5600Pwm ← AS5600 over PWM input only (no bus)
├── As5600I2cPwm ← AS5600 over I2C + PWM (fast read path)
├── As5047pSpi ← AMS AS5047P over SPI (14-bit, magnet diag)
├── Ma730Spi ← Monolithic Power MA730 over SPI (14-bit)
├── Mt6835Spi ← MagnTek MT6835 over SPI (21-bit, in-band CRC)
├── Mt6701Abi ← MT6701 over ABI quadrature (PCNT-backed)
├── EncoderFake ← header-only fake (transport-agnostic)
└── I2cEncoderFake ← header-only fake exercising the mux path
IEncoder knows nothing about I2C, multiplexers, or pin numbers.
Transport details (bus reference, mux pointer, channel, DIR pin) live
in the concrete driver's constructor. Channel-routing is internal to
the I2C-backed drivers — host code calls enc.readAngle() and the
multiplexer is selected automatically.
Drivers do not depend on Arduino. All time and pin operations go through
ungula::core::time and ungula::hal::gpio.
Encoder wired straight to the MCU's I2C peripheral. No multiplexer.
#include <ungula/hal/i2c/i2c_master.h>
#include <ungula/encoder/drivers/as5600_i2c.h>
namespace enc = ungula::encoder;
ungula::hal::i2c::I2cMaster bus(0);
enc::drivers::As5600I2c wheel("wheel", bus,
/*multiplexer=*/nullptr,
/*channel=*/0,
/*directionPin=*/4);
void setup() {
bus.begin(/*sda=*/21, /*scl=*/22, /*hz=*/400000);
if (!wheel.begin()) {
// wheel.statusToStr() / wheel.getLastErrorAsStr()
}
// Set the calibration once — the value is rig-specific (mechanical
// gearing, encoder mounting). 11.377 = AS5600's 4096 counts / 360°.
wheel.setCalibration(11.377f);
}
void loop() {
const float pos = wheel.readPosition(); // raw counts
const float deg = wheel.readAngle(); // degrees, uses stored calibration
// …
}Until setCalibration() lands a positive value, readAngle(),
angle(), and angleFromPosition() all return NaN — there's no honest
conversion without a known steps-per-degree.
The constructor took multiplexer = nullptr, so channel selection is a
zero-cost no-op on every transaction.
Two AS5600s sharing one TCA9548A on a single bus. The pattern from Ungula's Rachel photogrammetry project.
#include <ungula/hal/i2c/i2c_master.h>
#include <ungula/hal/multiplexer/drivers/multiplexer_tca9548.h>
#include <ungula/encoder/drivers/as5600_i2c.h>
namespace enc = ungula::encoder;
namespace mux = ungula::hal::multiplexer;
ungula::hal::i2c::I2cMaster i2c_bus(0);
mux::drivers::MultiplexerTCA9548 mux70(0x70, i2c_bus);
enc::drivers::As5600I2c horizontal("horizontal", i2c_bus, &mux70, /*channel=*/0);
enc::drivers::As5600I2c vertical ("vertical", i2c_bus, &mux70, /*channel=*/1);
void setup() {
i2c_bus.begin(21, 22, 400000);
mux70.begin();
horizontal.begin();
vertical.begin();
}
void loop() {
const float h = horizontal.readPosition();
const float v = vertical.readPosition();
// …
}The multiplexer caches the active channel. Reading vertical twice in a
row only writes the channel once — the second call is a cache hit and
goes straight to the AS5600.
The AS5600 emits a single-pin PWM where the duty cycle encodes the angle.
With this transport, you do not need a free I2C bus — only one input pin
plus the chip's PWM output. Magnet diagnostics and the watchdog are
not available in PWM mode (those live behind the I2C register file);
the driver reports hasMagnetSensing() and hasWatchDog() as false,
so callers that gate on those flags do the right thing automatically.
#include <ungula/encoder/drivers/as5600_pwm.h>
#include <ungula/hal/pwm_input/drivers/pwm_input.h>
namespace enc = ungula::encoder;
namespace pwm = ungula::hal::pwm_input;
pwm::drivers::PwmInput cap;
enc::drivers::As5600Pwm wheel("wheel", cap,
/*directionPin=*/ungula::encoder::ENCODER_NO_DIRECTION_PIN);
void setup() {
cap.begin(/*pin=*/34); // PWM output of the chip lands here
wheel.begin();
wheel.setCalibration(11.377f); // 4096 counts / 360°
}
void loop() {
const float deg = wheel.readAngle(); // NaN until first PWM frame arrives
if (!std::isnan(deg)) {
// use deg
}
}The driver flags a stalled signal as Error::NotConnected when the last
edge is older than the (configurable) stale threshold (default 50 ms,
~6 frames at the slowest 115 Hz mode). Tune it via
wheel.setStaleThresholdUs(...) — pick a window that comfortably
covers two frame periods on the host's PWMF setting.
The PWM ISR captures (highUs, periodUs) for every frame whether the
host polls or not. What the host does still need to do, in the polling
model, is feed the cumulative-position counter — wrap-around tracking
needs to see two consecutive samples that are less than half a
revolution apart. Skip the read for a full turn and the next decode
will pick the wrong direction.
For consumers that need true per-frame latency, enableIsrUpdates()
moves the wrap-around math into the same ISR that captures the sample.
After it is enabled, position() and readAngle() return the latest
cumulative count without any polling task running:
#include <ungula/encoder/drivers/as5600_pwm.h>
#include <ungula/hal/pwm_input/drivers/pwm_input.h>
namespace enc = ungula::encoder;
namespace pwm = ungula::hal::pwm_input;
pwm::drivers::PwmInput cap;
enc::drivers::As5600Pwm wheel("wheel", cap);
void setup() {
cap.begin(/*pin=*/34);
wheel.begin();
wheel.setCalibration(11.377f);
wheel.enableIsrUpdates(); // arm the per-period ISR callback
}
void loop() {
// No periodic readPosition() needed — the ISR keeps the cumulative
// count fresh. Read it whenever the application wants it.
const float deg = wheel.readAngle();
if (!std::isnan(deg)) {
// use deg
}
}What the ISR does and does not do:
- Does: decode raw angle from the latest sample, fold the 4095↔0 transition into the cumulative count, update the seed on the first sample. All in IRAM, no float ops on the position state, no logging, no I2C / SPI.
- Does not: call
setStatus()(would race with the host),magnetStatus()/setWatchDog()(those are I2C-bound and live onAs5600I2cPwm's polling side), or anything that allocates.
disableIsrUpdates() removes the callback; the next readPosition()
falls back to the polling decode path and picks up where the ISR left
off. Mixing the two modes is supported but only makes sense during a
controlled handoff — pick one for steady-state operation.
The AS5600 is happy to drive both transports at the same time: register
access (magnet status, watchdog, zero-snapshot) over I2C, and a
lower-latency angle stream on the PWM pin. As5600I2cPwm is exactly
that — it inherits As5600I2c for the diagnostic register path and
overrides the read path to use the PWM input. Capability flags reflect
both transports: hasMagnetSensing() and hasWatchDog() are true.
This is the right driver when:
- the read loop is hot and the I2C round-trip would dominate it, and
- you still want the magnet-quality / watchdog signals the chip exposes only over I2C.
#include <ungula/encoder/drivers/as5600_i2c_pwm.h>
#include <ungula/hal/i2c/i2c_master.h>
#include <ungula/hal/pwm_input/drivers/pwm_input.h>
namespace enc = ungula::encoder;
namespace pwm = ungula::hal::pwm_input;
ungula::hal::i2c::I2cMaster bus(0);
pwm::drivers::PwmInput cap;
enc::drivers::As5600I2cPwm wheel("wheel", bus, cap,
/*multiplexer=*/nullptr,
/*channel=*/0,
/*directionPin=*/4);
void setup() {
bus.begin(/*sda=*/21, /*scl=*/22, /*hz=*/400000);
cap.begin(/*pwmPin=*/34);
if (!wheel.begin()) {
// I2C side failed (chip missing / wiring fault). The PWM read
// path can still work in degraded mode if the chip is alive
// and the host accepts running without magnet diagnostics.
}
wheel.setCalibration(11.377f);
}
void loop() {
// Hot path — angle comes from the PWM pin, no I2C traffic.
const float deg = wheel.readAngle();
// Slow housekeeping — magnet check is still I2C.
if (wheel.magnetStatus() != enc::MagnetStatus::Ok) {
// log, alarm, recover, etc.
}
}The combined driver also accepts an optional IMultiplexer* and
channel for I2C-side wiring, just like As5600I2c (the PWM input is
its own pin and is unaffected).
IEncoder itself has no concept of a multiplexer — that's a transport
concern that lives inside the I2C-backed drivers. For As5600I2c,
multiplexer support is one constructor argument:
| Wiring | Construction |
|---|---|
| Direct to bus | As5600I2c enc("name", bus); |
Behind a IMultiplexer |
As5600I2c enc("name", bus, &mux, channel); |
In both cases host code calls enc.readAngle() / enc.readPosition()
identically; channel selection is handled internally. Future SPI / PWM
/ ABI drivers won't have a multiplexer parameter at all — the
multiplexer is an I2C-bus concept.
Three methods are provided for managing the direction of the readings logically:
setDirection(ungula::encoder::Direction direction)
and two convenient helpers:
setDirectionClockWise()
setDirectionCounterClockWise()
Direction calls work before begin(). The setting is captured
immediately and pushed to the DIR pin (when one is wired) the moment
the driver finishes initialising. The DIR pin number is fixed at
construction time — wiring is hardware, set once.
Changing the direction lets you, for example, always read positive values if the encoder will only ever turn one way.
horizontal.setDirection(ungula::encoder::Direction::ClockWise);
vertical .setDirection(ungula::encoder::Direction::CounterClockWise);
// or
horizontal.setDirectionClockWise();
vertical .setDirectionCounterClockWise();Off by default. enableLogging() routes diagnostics through EmblogX with
the module tag encoder. Enable per-instance, not globally — debugging a
flaky encoder doesn't have to flood the log with the others.
#include <ungula/encoder/drivers/as5600_i2c.h>
void enable_debug_for_one_encoder(ungula::encoder::drivers::As5600I2c& vertical) {
vertical.enableLogging(); // now everything from this instance shows up
}Errors flow through log_error_m, raw-angle traces through
log_debug_m. There is no global state — turning logging on for one
encoder leaves the rest quiet.
Host-side unit tests live in tests/:
test_encoder.cpp— fullIEncodercontract throughEncoderFake. Covers the logging toggle, capability defaults, direction-before-begincontract, status/error mapping, angle helpers, magnet status. Also usesI2cEncoderFaketo verify the multiplexer routing path. Drift detection: every pure-virtual onIEncoderis touched through anIEncoder*reference, so any signature change without a matching fake update breaks the build immediately.test_as5600_i2c.cpp— driver smoke test against the desktop I2C stub. Verifies the driver compiles, links, behaves asIEncoder, honours pre-begin()direction settings, and reports the right errors when the bus is unreachable.
cd tests
cmake -S . -B build
cmake --build build
./build/test_ungula_encoder
./build/test_ungula_encoder_as5600or just:
cd tests
chmod +x *sh
./1_build.sh
./2_run.shThanks to Claude and ChatGPT for helping on generating this documentation.
MIT License — see LICENSE file.
This library ships a flat forwarder header at src/ungula_encoder.h that
just #includes ungula/encoder.h. library.properties includes=
points at the forwarder. It only exists to work around an Arduino CLI
quirk when the library is consumed through a symlink. Host code keeps
including the real header (#include <ungula/encoder.h>).