File RotaryEncoder.cpp
File List > Firmware > Libraries > PanelGroup > Inputs > RotaryEncoder > RotaryEncoder.cpp
Go to the documentation of this file
#ifdef ARDUINO_ARCH_STM32
#include "RotaryEncoder.h"
#include <CANProtocol.h> // sendBatched, canIdEvtRel, canIdEvtDir, ControlPacket
#include <STM32Board.h>
namespace OpenSkyhawk {
RotaryEncoder::RotaryEncoder(uint16_t controlId, PinRef pinA, PinRef pinB,
EncoderStepsPerDetent stepsPerDetent, EncoderMode mode, int16_t step)
: RotaryEncoder(controlId, pinA, pinB, stepsPerDetent, mode, step,
/*momentumFilter*/ false, /*fastStep*/ 0) {}
RotaryEncoder::RotaryEncoder(uint16_t controlId, PinRef pinA, PinRef pinB,
EncoderStepsPerDetent stepsPerDetent, EncoderMode mode, int16_t step,
bool momentumFilter, int16_t fastStep)
: _controlId(controlId),
_pinA(pinA),
_pinB(pinB),
_mode(mode),
_step(step),
_stepsPerDetent((uint8_t)stepsPerDetent),
_lastState(0),
_delta(0),
_pendingDetents(0),
_pendingFast(0),
_filter(momentumFilter),
_fastStep(mode == EncoderMode::Rel ? fastStep : 0), // DIR carries ±1 only — no speed
_momentum(0),
_lastDetentMs(0),
_hasLastDetent(false),
_sampled(false),
_initialized(false) {}
void RotaryEncoder::configure() {
_pinA.configureAsInput();
_pinB.configureAsInput();
}
uint8_t RotaryEncoder::readState() {
return (uint8_t)(((_pinA.read() ? 1u : 0u) << 1) | (_pinB.read() ? 1u : 0u));
}
void RotaryEncoder::emit(int16_t value) {
// REL → one frame carrying a coalesced magnitude (a burst reads as one bigger twist, same DCS
// result, fewer frames); DIR → ±1 per frame (DCS steps one position per INC/DEC, so a burst
// must stay one frame per detent — the caller loops). drainPending() computes the value.
const uint32_t frame = (_mode == EncoderMode::Rel) ? canIdEvtRel(NODE_ID) : canIdEvtDir(NODE_ID);
CANProtocol::sendBatched(frame, ControlPacket{_controlId, (uint16_t)value});
#ifdef ROTARYENCODER_TEST
_emitCount++;
_lastValue = value;
_lastFrame = frame;
#endif
if (STM32Board::isDebug()) {
auto& d = STM32Board::diagSerial();
d.print(F("[ENC] 0x")); d.print(_controlId, HEX);
d.print(_mode == EncoderMode::Rel ? F(" REL ") : F(" DIR "));
d.println(value); // signed: + = CW, - = CCW
}
}
void RotaryEncoder::decode(uint8_t state) {
// Quadrature transition table — ported verbatim from DcsBios RotaryEncoder (Encoders.h).
// Runs wherever the sampling happens — sampleTick() (possibly ISR context) once a
// sampler ticks, poll() otherwise; never both. Completed detents go to the pending
// counters; emission always happens loop-side in drainPending().
int8_t dir = 0;
switch (_lastState) {
case 0: if (state == 2) dir = -1; if (state == 1) dir = +1; break;
case 1: if (state == 0) dir = -1; if (state == 3) dir = +1; break;
case 2: if (state == 3) dir = -1; if (state == 0) dir = +1; break;
case 3: if (state == 1) dir = -1; if (state == 2) dir = +1; break;
}
_lastState = state;
if (_filter) {
// Momentum filter — DcsBios RotaryAcceleratedEncoder: a transition against non-zero
// momentum is a bounce on a noisy encoder; drop it and only decay the momentum.
const int8_t maxM = (int8_t)(MAX_MOMENTUM * (int8_t)_stepsPerDetent);
if (dir > 0) {
if (_momentum >= 0) { if (_momentum < maxM) _momentum++; }
else { dir = 0; _momentum++; }
} else if (dir < 0) {
if (_momentum <= 0) { if (_momentum > -maxM) _momentum--; }
else { dir = 0; _momentum--; }
} else if ((uint32_t)(millis() - _lastDetentMs) > STOPPED_THRESHOLD_MS) {
_momentum = 0; // knob at rest — a reversal from here is genuine
}
}
_delta += dir;
if (_delta >= (int8_t)_stepsPerDetent) { // clockwise
countDetent(+1);
_delta -= (int8_t)_stepsPerDetent;
}
if (_delta <= -(int8_t)_stepsPerDetent) { // counter-clockwise
countDetent(-1);
_delta += (int8_t)_stepsPerDetent;
}
}
void RotaryEncoder::countDetent(int8_t dir) {
// Pending cap: REL coalesces on drain, so deep accumulation is cheap (one frame);
// DIR emits one CAN frame per detent — an absurd backlog (multi-second stall while
// spinning) would flood the 16-slot TX ring and silently drop frames, so clamp it.
const int8_t cap = (_mode == EncoderMode::Dir) ? 8 : INT8_MAX;
// Speed is classified HERE, per detent, not at drain time: a loop stalled by an OLED flush
// drains several detents at once and their spacing would be lost. Only the accelerated
// class pays for the millis() read; the plain encoder never enters this branch.
bool fast = false;
if (_filter || _fastStep != 0) {
const uint32_t now = millis();
fast = _fastStep != 0 && _hasLastDetent &&
(uint32_t)(now - _lastDetentMs) < FAST_THRESHOLD_MS;
_lastDetentMs = now;
_hasLastDetent = true;
}
if (fast) {
if (dir > 0) { if (_pendingFast < cap) _pendingFast = _pendingFast + 1; }
else { if (_pendingFast > -cap) _pendingFast = _pendingFast - 1; }
} else {
if (dir > 0) { if (_pendingDetents < cap) _pendingDetents = _pendingDetents + 1; }
else { if (_pendingDetents > -cap) _pendingDetents = _pendingDetents - 1; }
}
}
void RotaryEncoder::sampleTick() {
// Generic InputBase high-rate hook — the encoder does not know (or care) who calls it.
// ISR-safe: cached pin reads + the transition table, no CAN.
// Decline ownership unless BOTH pins are sampler-refreshed sources (ShiftBus '165):
// a GPIO/MCP-pinned encoder on a mixed node keeps its loop-rate decode unchanged —
// its cache only refreshes at loop rate, so sampler ownership would gain nothing and
// silently rewire behavior the SHIFTBUS_ISR_HZ flag has no business touching.
if (!(_pinA.isSampledSource() && _pinB.isSampledSource())) return;
// Claim ownership BEFORE the initialized check: once a sampler ticks at all, poll()
// must never decode again, or a poll between forceReport() and the next tick could be
// preempted mid-decode by this ISR (torn _delta/_lastState).
_sampled = true;
if (!_initialized) return;
decode(readState());
}
void RotaryEncoder::drainPending() {
// Read-and-clear must be atomic against a sampler ticking from ISR context. Masking
// unconditionally costs a few cycles and spares this class any knowledge of whether
// (or from where) a sampler runs.
noInterrupts();
int8_t d = _pendingDetents;
int8_t f = _pendingFast;
_pendingDetents = 0;
_pendingFast = 0;
interrupts();
if (d == 0 && f == 0) return;
#ifdef ROTARYENCODER_TEST
_netDetents += d + f;
#endif
if (_mode == EncoderMode::Dir) {
while (d > 0) { emit(+1); d--; }
while (d < 0) { emit(-1); d++; }
return;
}
if (f == 0) {
// Plain REL path (unchanged): coalesce, chunked in whole detents so detents×step stays
// within int16. Magnitude-based so a negative step (direction-invert config) chunks
// correctly too.
const int16_t mag = (_step < 0) ? (int16_t)-_step : _step;
const int16_t maxChunk = (mag > 1) ? (int16_t)(32767 / mag) : 127;
while (d != 0) {
int8_t chunk = d;
if (chunk > maxChunk) chunk = (int8_t)maxChunk;
if (chunk < -maxChunk) chunk = (int8_t)-maxChunk;
emit((int16_t)(chunk * _step));
d = (int8_t)(d - chunk);
}
return;
}
// Accelerated REL: slow and fast detents drain together as one signed magnitude, split
// only if it overflows int16 (variable_step adds, so a split sums to the same result).
int32_t total = (int32_t)d * _step + (int32_t)f * _fastStep;
while (total != 0) {
int32_t chunk = total;
if (chunk > 32767) chunk = 32767;
if (chunk < -32767) chunk = -32767;
emit((int16_t)chunk);
total -= chunk;
}
}
void RotaryEncoder::forceReport() {
// Resync so the first decode sees no spurious transition. Masked against a sampler
// (SYNC_REQ arrives in loop context).
noInterrupts();
_lastState = readState();
_delta = 0;
_pendingDetents = 0;
_pendingFast = 0;
_momentum = 0;
_hasLastDetent = false; // the first detent after a resync is never "fast"
_lastDetentMs = millis();
_initialized = true;
interrupts();
// No EVT — a relative encoder has no absolute state to report at boot / SYNC.
}
void RotaryEncoder::poll() {
if (!_initialized) return;
// Once a sampler ticks, it owns the decode; poll() only drains.
if (!_sampled) decode(readState());
drainPending();
}
} // namespace OpenSkyhawk
#endif // ARDUINO_ARCH_STM32