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)
: _controlId(controlId),
_pinA(pinA),
_pinB(pinB),
_mode(mode),
_step(step),
_stepsPerDetent((uint8_t)stepsPerDetent),
_lastState(0),
_delta(0),
_pendingDetents(0),
_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(int8_t detents) {
// REL → one frame carrying detents×step (coalesced 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).
const int16_t value = (_mode == EncoderMode::Rel) ? (int16_t)(detents * _step)
: (int16_t)detents;
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 _pendingDetents;
// emission always happens loop-side in drainPending().
switch (_lastState) {
case 0: if (state == 2) _delta--; if (state == 1) _delta++; break;
case 1: if (state == 0) _delta--; if (state == 3) _delta++; break;
case 2: if (state == 3) _delta--; if (state == 0) _delta++; break;
case 3: if (state == 1) _delta--; if (state == 2) _delta++; break;
}
_lastState = state;
// 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;
if (_delta >= (int8_t)_stepsPerDetent) { // clockwise
if (_pendingDetents < cap) _pendingDetents = _pendingDetents + 1;
_delta -= (int8_t)_stepsPerDetent;
}
if (_delta <= -(int8_t)_stepsPerDetent) { // counter-clockwise
if (_pendingDetents > -cap) _pendingDetents = _pendingDetents - 1;
_delta += (int8_t)_stepsPerDetent;
}
}
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;
_pendingDetents = 0;
interrupts();
if (d == 0) return;
#ifdef ROTARYENCODER_TEST
_netDetents += d;
#endif
if (_mode == EncoderMode::Rel) {
// Coalesce, chunked 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(chunk);
d = (int8_t)(d - chunk);
}
} else {
while (d > 0) { emit(+1); d--; }
while (d < 0) { emit(-1); d++; }
}
}
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;
_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