Cranky — Jog Dial Controller
Built on the AVR128DB64 — the same controller as Clicky and Sticky — Cranky owns the eight illuminated jog dials. It decodes all sixteen quadrature phases and eight push buttons on-chip, reports normalised deltas and button events to the host over SPI with an interrupt line so the host never polls, and drives the RGB LED inside each dial's transparent stem. Lighting is an engine, not an authority: the host sends colour intent, Cranky runs the animation. Fully reprogrammable over UPDI at any time.
Overview
| Feature | Detail |
|---|---|
| Chip | AVR128DB64 @ 24 MHz (same part as Clicky and Sticky) |
| Dials | 8 × rotary encoder with push button and an illuminated transparent stem |
| Lighting | 8 × 4-pin common-anode RGB LED, 24 channels, driven by an external constant-current PWM driver |
| Expansion | Audio expansion connector — 12 analog inputs and a WS2812 chain output, on the pins the dial subsystem does not need |
| Comms | SPI client with a dedicated interrupt line; I²C client. No UART |
| Programming | UPDI — update cranky |
What Cranky Does
- Decodes eight quadrature encoders — sixteen phases across two MCU port groups, so a single pin-change interrupt reads four dials at once from one register
- Quantises to detents so the host receives the clicks a user feels, not the raw 4× edge count, with the raw count available when something wants finer resolution
- Debounces eight push buttons in firmware, reporting press, release and hold
- Drives 24 LED channels through an external constant-current driver, since the pin budget cannot carry them natively — see below
- Runs its own lighting animations — idle breathing, a flash on detent, a colour ramp that tracks a dial's value — composed against whatever colour intent the host has last set
- Reads twelve analog controls on the audio expansion connector, with oversampling and a deadband so a resting pot does not spam the host
- Drives a second, addressable lighting chain out to the expansion — one WS2812 per pot, on a single pin
- Interrupts the host on change rather than being polled, so eight idle dials cost the system bus nothing
The Pin Budget, and Why There Is an LED Driver
Eight dials and eight RGB LEDs do not fit on a 64-pin part. The arithmetic is worth stating plainly, because it is the one architectural decision on this board:
| Requirement | Pins |
|---|---|
| 8 × quadrature A/B | 16 |
| 8 × push button | 8 |
| 8 × RGB, driven natively | 24 |
| SPI client + interrupt | 5 |
| I²C client | 2 |
| Crystals, RESET, UPDI | 6 |
| Total | 61 |
The AVR128DB64 has 56 I/O pins. Even before the shortfall, native PWM would not have worked: TCA0 and TCA1 in split mode give six 8-bit channels each, TCD0 gives up to four and the five TCBs one apiece — twenty-one in total, three short of the twenty-four needed, and the TCB channels carry no documented guarantee of a true 0 % duty, which is exactly the thing you notice on an LED in a dark room.
So the LEDs move to an external driver, and the board fits with room to spare:
| Requirement | Pins |
|---|---|
| 8 × quadrature A/B | 16 |
| 8 × push button | 8 |
| LED driver interface | 4 |
| SPI client + interrupt | 5 |
| I²C client | 2 |
| Crystals, RESET, UPDI | 6 |
| Total | 41 of 56 |
Fifteen pins spare, including all eight of PORTD's ADC channels. Thirteen of those go to the audio expansion connector — twelve analog inputs and one WS2812 output — and the other two are the I²C client to the host.
The I²C client stays. It was a candidate to drop if the budget got tight, but it costs two of those fifteen pins and removing it changes nothing else: even with those two pins back, native RGB would need 59 of 56, and the part only has twenty-one PWM channels to offer against twenty-four LEDs. The external driver is required either way, so the second control path is free and worth keeping — it lets something query Cranky's identity or reconfigure a dial without contending for the SPI bus mid-stream.
Jog Dials
Concept render of the Ant64 enclosure.
Pin group structure
The dials are laid out so that one register read decodes four of them. Each dial's A and B phases occupy an adjacent bit pair, four dials to a port group:
| Dial | A phase | B phase | Button |
|---|---|---|---|
| 1 | PB0 | PB1 | PE0 |
| 2 | PB2 | PB3 | PE1 |
| 3 | PB4 | PB5 | PE2 |
| 4 | PB6 | PB7 | PE3 |
| 5 | PC0 | PC1 | PE4 |
| 6 | PC2 | PC3 | PE5 |
| 7 | PC4 | PC5 | PE6 |
| 8 | PC6 | PC7 | PE7 |
Two pin-change interrupt vectors — PORTB_PORT_vect and PORTC_PORT_vect — cover all eight dials, and each one is a byte read, four table lookups and four adds. All eight buttons come from a single PORTE.IN read in the 1 kHz scan tick, where polling suits them better than interrupts anyway because they need debouncing regardless.
Decoding
Standard 4× quadrature through a sixteen-entry transition table. The zero entries are the illegal double transitions — a contact glitch, or a dial spun faster than the interrupt latency, which for a human hand does not happen.
#include <avr/io.h>
#include <avr/interrupt.h>
#include <stdint.h>
#define DIALS 8
typedef struct {
uint8_t last; /* previous phase pair */
int16_t raw; /* 4x edge count, signed */
int16_t detent; /* raw / 4, consumed by the host */
uint8_t btn_hist; /* debounce shift register */
bool btn; /* debounced level */
} dial_t;
static volatile dial_t DIAL[DIALS];
static const int8_t QUAD_LUT[16] = {
0, +1, -1, 0,
-1, 0, 0, +1,
+1, 0, 0, -1,
0, -1, +1, 0
};
/* Four dials per call: bits 1:0 are dial n, 3:2 dial n+1, and so on. */
static inline void dials_update(uint8_t base, uint8_t raw)
{
for (uint8_t i = 0; i < 4; i++) {
uint8_t cur = (uint8_t)((raw >> (i * 2)) & 0x03u);
volatile dial_t *d = &DIAL[base + i];
d->raw += QUAD_LUT[(d->last << 2) | cur];
d->last = cur;
}
}
ISR(PORTB_PORT_vect) { dials_update(0, PORTB.IN); PORTB.INTFLAGS = 0xFF; }
ISR(PORTC_PORT_vect) { dials_update(4, PORTC.IN); PORTC.INTFLAGS = 0xFF; }
void dials_init(void)
{
for (uint8_t b = 0; b < 8; b++) {
(&PORTB.PIN0CTRL)[b] = PORT_ISC_BOTHEDGES_gc; /* 10k externals */
(&PORTC.PIN0CTRL)[b] = PORT_ISC_BOTHEDGES_gc;
(&PORTE.PIN0CTRL)[b] = PORT_PULLUPEN_bm; /* buttons */
}
PORTB.DIRCLR = 0xFF;
PORTC.DIRCLR = 0xFF;
PORTE.DIRCLR = 0xFF;
DIAL[0].last = PORTB.IN & 0x03; /* seed, then the rest */
for (uint8_t i = 0; i < 4; i++) {
DIAL[i].last = (uint8_t)((PORTB.IN >> (i * 2)) & 0x03u);
DIAL[i + 4].last = (uint8_t)((PORTC.IN >> (i * 2)) & 0x03u);
}
}
Detents and buttons
A detented encoder — an EC11 or equivalent — produces four quadrature transitions per click, so the raw count divides by four to give the detent count a user would recognise. Cranky reports both: detents as the primary value, and the raw count for anything that wants sub-detent resolution, such as a smooth scroll.
/* Called from the 1 kHz scan tick. */
void dials_scan(void)
{
uint8_t sw = (uint8_t)~PORTE.IN; /* buttons close to GND */
for (uint8_t i = 0; i < DIALS; i++) {
volatile dial_t *d = &DIAL[i];
/* Consume whole detents, leaving the remainder in place so a
slow turn does not lose the fraction it has accumulated. */
uint8_t s = SREG;
cli();
int16_t whole = d->raw / 4;
d->raw -= whole * 4;
SREG = s;
d->detent += whole;
/* Eight-sample agreement: about 8 ms at 1 kHz, comfortably
past the bounce on a rotary encoder's integrated switch. */
d->btn_hist = (uint8_t)((d->btn_hist << 1) | ((sw >> i) & 1u));
if (d->btn_hist == 0xFF) d->btn = true;
if (d->btn_hist == 0x00) d->btn = false;
}
}
d->raw is int16_t and divided by four each tick, so it cannot run away: a dial would have to accumulate more than 32767 edges inside one millisecond, which is four orders of magnitude beyond a hand.
CPU cost is negligible. A dial spun hard is perhaps five revolutions per second; at twenty detents per revolution that is 400 quadrature edges per second per dial, or 3200 across all eight. Each edge costs an interrupt entry, a byte read and four table lookups — well under 0.5 % of one core at 24 MHz. The CCL was considered and left entirely free: hardware decoding costs two LUTs, a sequencer and a counter per axis, so the part could accelerate at most three of the eight dials, and none of them need it.
Lighting
Each dial's stem is transparent and lit from below by a 4-pin common-anode RGB LED. Twenty-four channels in total, driven by one external constant-current PWM driver rather than by the MCU.
Driver circuit
+5V_LED
│
┌────────────────┼──────────────────────────────┐
│ │ │
│ ┌────────────┴─────────────┐ (× 8 dials) │
│ │ RGB LED, common anode │ │
│ │ A R G B │ │
│ └───┬────┬─────┬─────┬─────┘ │
│ │ │ │ │ │
└───────┘ │ │ │ │
R_r │ │ │ │
150R│ │ │ │
│ │ │ │
┌────────────┴─────┴─────┴──────────────────────┴───┐
│ OUT0 OUT1 OUT2 ... OUT23 │
│ │
│ U1 TLC5947 │
│ 24-channel, 12-bit, constant-current sink │
│ │
│ SIN SCLK XLAT /BLANK IREF GND │
└───┬─────┬──────┬───────┬───────┬───────┬──────────┘
│ │ │ │ │ │
PG4 PG6 PG1 PG2 R_iref GND
(SPI1 (SPI1 (GPIO) (GPIO) │
MOSI) SCK) GND
| Ref | Value | Purpose |
|---|---|---|
| U1 | TLC5947 (HTSSOP-32, thermal pad) | 24 channels — exactly eight RGBs — at 12-bit resolution, constant-current, daisy-chainable. Constant current means no per-channel resistor array and no brightness drift between dies |
| R_iref | sized for 5–10 mA per channel | One resistor sets full-scale current for all 24 outputs. Compute from the driver datasheet; an illuminated stem needs far less than the part's 30 mA ceiling |
| R_r | 150 Ω, red channels only | Not for current limiting — the driver does that. It moves the red channel's excess voltage drop off the driver die. See the thermal note below |
| /BLANK | PG2, pull-down fitted | All outputs off. Held asserted through reset so the stems do not flash on power-up before firmware has a frame ready |
| XLAT | PG1 | Latches the shifted frame. 288 bits at a few MHz on SPI1 takes about 100 µs, so a 200 Hz refresh costs 2 % of the bus and nothing of the CPU |
Thermal note, and the reason for R_r. A constant-current sink dissipates the difference between the supply and the LED's forward voltage. On a 5 V rail, green and blue dice at roughly 3.2 V leave the driver dropping 1.8 V, but red at 2.0 V leaves it dropping 3.0 V — half again as much, on eight channels at once. At 10 mA that is 240 mW from the red channels alone against 288 mW from the other sixteen combined. The 150 Ω in each red leg moves about half of that off the package and into a resistor that does not care.
Alternative if the TLC5947 is awkward to source: two PCA9685 give 32 channels of 12-bit PWM over an I²C bus, at the cost of 24 series resistors and open-drain outputs rather than regulated current. Note the pin arithmetic, though — TWI1's only pin positions are PF2/PF3 and PB2/PB3, which are now expansion analog inputs and dial quadrature, so this alternative costs two of the twelve analog channels rather than saving pins. Everything above the driver layer is unchanged.
And one worth mentioning only to dismiss: eight SK6812-class addressable LEDs would collapse the whole subsystem to a single pin, using the same CCL-plus-serial trick Clicky uses for its WS2812 chain. That is a different LED and a different mechanical fit inside a dial stem, so it is not a change to make lightly — but if the stems are ever re-tooled, it is the cheaper answer.
Frame composition
Cranky holds a 24-channel frame and refreshes it at 200 Hz. Each channel's value is composed from three layers, in order:
- Host intent — the base colour the host has set for that dial, immediate or interpolated
- Animation — whatever pattern is running: idle breathing, a value ramp that tracks the dial's position, a low-intensity pulse to mark a modal state
- Feedback — a short bright flash on each detent and on button press, decaying over about 80 ms
Apply a gamma ≈ 2.2 lookup before writing the frame. Twelve bits of linear PWM through a transparent stem looks badly bunched at the bright end without it; with it, a slow fade reads as smooth.
The host sends colour intent and pattern selection; Cranky owns the timing. The same "engine, not authority" split Clicky uses for its status pixels.
Audio Expansion Connector
The thirteen pins the dial subsystem does not need go to one connector, and they turn out to divide almost perfectly for a pot-and-light control surface: twelve of the thirteen are ADC-capable, and the thirteenth — PG3 — is a CCL LUT output pin, which is exactly what a WS2812 chain needs. The one spare pin with no analog function is the one the addressable LEDs want.
What the connector carries
| Function | Pins | Notes |
|---|---|---|
| Analog inputs | PD0–PD7, PF2–PF5 | AIN0–AIN7 and AIN18–AIN21. Twelve channels — every ADC-capable pin Cranky has left |
| WS2812 data out | PG3 | CCL LUT5's output position. One pin drives the whole chain |
| Power and ground | — | +5 V, and grounds interleaved between the analog lines |
No I²C bus here. PF2 and PF3 are AIN18 and AIN19, and putting TWI1 on them would have cost two analog channels to buy an identity and configuration bus. Cranky is not the only board with pins spare, so anything the expansion needs beyond analog and lighting — a codec's control bus, an ID EEPROM, a digital pot — is better fed from whichever chip has the room, leaving Cranky to contribute what only Cranky can: twelve ADC channels and a CCL output.
If presence detection is ever wanted on Cranky's side alone, it costs nothing: fit a resistor divider on the highest analog channel on the expansion board and read it as a board-ID code. An absent connector, with a pull-down at the MCU pin, reads zero.
Connector pinout
A 2×10 IDC header, with grounds interleaved so no analog line runs beside another for the length of a ribbon:
| 1 | +5 V | 2 | GND | |
| 3 | XA0 — PD0 |
4 | XA1 — PD1 |
|
| 5 | GND | 6 | GND | |
| 7 | XA2 — PD2 |
8 | XA3 — PD3 |
|
| 9 | GND | 10 | GND | |
| 11 | XA4 — PD4 |
12 | XA5 — PD5 |
|
| 13 | GND | 14 | GND | |
| 15 | XA6 — PD6 |
16 | XA7 — PD7 |
|
| 17 | XA8 — PF2 |
18 | XA9 — PF3 |
|
| 19 | XA10 — PF4 |
20 | XA11 — PF5 |
XLED (PG3) and its 330 Ω series resistor sit on a separate 3-way header with their own +5 V and ground. Keeping the WS2812 line out of the analog ribbon is worth the extra connector: it is a 2.4 Mbaud edge-rich signal, and running it alongside twelve high-impedance analog lines for the length of a cable is the one layout mistake that would be genuinely hard to debug afterwards. It also lets the LED chain take its own power feed, which it needs anyway.
Do not draw the NeoPixel current through a ribbon. Twelve WS2812s at full white is roughly 700 mA, which a 0.1" header and a metre of flat cable will not enjoy and which will pull the analog reference around as it changes. Feed the expansion board's LED rail separately, and cap global brightness in firmware as well — the second is free and is what the brightness intent message is for.
Reading the pots
Audio pots are typically 10 kΩ, which is right at the ADC's recommended source impedance — far easier than the megohm paddles Sticky has to deal with. A modest SAMPCTRL.SAMPLEN is enough, and the accumulator earns its keep instead:
#define XPOTS 12
static const uint8_t XPOT_AIN[XPOTS] = {
ADC_MUXPOS_AIN0_gc, ADC_MUXPOS_AIN1_gc, ADC_MUXPOS_AIN2_gc,
ADC_MUXPOS_AIN3_gc, ADC_MUXPOS_AIN4_gc, ADC_MUXPOS_AIN5_gc,
ADC_MUXPOS_AIN6_gc, ADC_MUXPOS_AIN7_gc, ADC_MUXPOS_AIN18_gc,
ADC_MUXPOS_AIN19_gc, ADC_MUXPOS_AIN20_gc, ADC_MUXPOS_AIN21_gc
};
static uint16_t xpot[XPOTS]; /* 14-bit, published to the host */
void xpot_init(void)
{
/* INITDLY stays zero - erratum 2.3.2 delays MUXPOS updates by a
conversion on every silicon revision when it is non-zero, and
we rotate the mux across ten channels every scan. */
ADC0.CTRLC = ADC_PRESC_DIV16_gc; /* 1.5 MHz CLK_ADC */
ADC0.SAMPCTRL = 8; /* ample for 10k */
ADC0.CTRLB = ADC_SAMPNUM_ACC16_gc; /* 16x accumulate */
ADC0.CTRLD = 0;
ADC0.CTRLA = ADC_RESSEL_12BIT_gc | ADC_ENABLE_bm;
}
/* One channel per scan tick: twelve pots at 1 kHz gives each an 83 Hz
update, which is well past what a hand on a knob can produce. */
void xpot_scan(void)
{
static uint8_t ch = 0;
ADC0.MUXPOS = XPOT_AIN[ch];
ADC0.COMMAND = ADC_STCONV_bm;
while (!(ADC0.INTFLAGS & ADC_RESRDY_bm))
;
uint16_t acc = ADC0.RES; /* 16 x 12-bit sum */
uint16_t val = acc >> 2; /* 14-bit effective */
/* Deadband. A resting pot dithers by a few LSB, and without this
it would hold CIRQ asserted permanently and flood the host. */
int16_t diff = (int16_t)val - (int16_t)xpot[ch];
if (diff > XPOT_DEADBAND || diff < -XPOT_DEADBAND) {
xpot[ch] = val;
report_dirty = true;
}
if (++ch >= XPOTS) ch = 0;
}
The deadband is the part that matters architecturally. Cranky is interrupt-driven, so a pot that jitters by one count is a pot that asserts CIRQ a hundred times a second forever. Sixteen-times accumulation buys roughly two bits of noise rejection; a deadband of a few counts on top of that buys silence. Publish 14 bits and let the host scale — a control surface wants the resolution, and the deadband is cheaper than a filter with lag in it.
Driving the expansion NeoPixels
Same arrangement Clicky uses, one LUT further along. USART0 runs with PORTMUX = NONE — essential here as there, since USART0's pin positions are PA0/PA1 (the crystal) and PA4/PA7 (the SPI client) — and its TXD is tapped internally by CCL LUT5, whose default output position is PG3.
void xled_init(void)
{
/* USART0 drives no pins; it exists only to feed the CCL. */
PORTMUX.USARTROUTEA = (PORTMUX.USARTROUTEA & ~PORTMUX_USART0_gm)
| PORTMUX_USART0_NONE_gc;
USART0.BAUD = 80; /* 64*24e6/(8*2.4e6) exact */
USART0.CTRLB = USART_TXEN_bm | USART_RXMODE_CLK2X_gc;
USART0.CTRLC = USART_CMODE_ASYNCHRONOUS_gc /* CCL needs async */
| USART_CHSIZE_7BIT_gc
| USART_PMODE_DISABLED_gc
| USART_SBMODE_1BIT_gc;
/* LUT5 inverts: a UART idles high, WS2812 idles low. */
CCL.LUT5CTRLB = CCL_INSEL0_USART0_gc; /* INSEL0 0x8 = TXD */
CCL.TRUTH5 = 0x01; /* out = NOT in0 */
CCL.LUT5CTRLA = CCL_ENABLE_bm | CCL_OUTEN_bm; /* -> PG3 */
CCL.CTRLA = CCL_ENABLE_bm;
}
/* One 7N1 frame carries three WS2812 bits. Inverted, the line reads
1 !d0 !d1 | !d2 !d3 !d4 | !d5 !d6 0, so fixing d1=1 d2=0 d4=1 d5=0
makes every triplet a valid 1-X-0 cell with three payload bits. */
static inline uint8_t frame3(uint8_t a, uint8_t b, uint8_t c)
{
uint8_t f = 0x5B;
if (a) f &= (uint8_t)~0x01;
if (b) f &= (uint8_t)~0x08;
if (c) f &= (uint8_t)~0x40;
return f;
}
At 24 MHz that baud is exact — T0H 417 ns and T1H 833 ns, dead centre of the WS2812B windows. Eight UART bytes per LED, so a twelve-pixel chain is 96 bytes and 360 µs on the wire; at a 60 Hz refresh that is under 2 % of the CPU through the DRE interrupt.
The one open item, and why it is smaller here. As on Clicky, the datasheet never states outright that the CCL's internal TXD tap survives PORTMUX = NONE — it documents NONE as a pin-routing selection and CCL peripheral inputs as a separate category, which strongly implies the tap sits upstream of the mux, but never says so. On Clicky that assumption is load-bearing. Here it is not: PG3 is an ordinary GPIO as well, and a twelve-LED chain is only 360 µs of bit-banging. If the tap does not work, bit-bang it at 30 Hz and lose about 1 % of a core — annoying, not fatal.
Round Display Control
The Ant64's round status TFT is driven by Pulse — Pulse has the PSRAM to render it and owns its QSPI data, chip-select and TE frame-sync lines. Cranky owns only the two slow control lines that don't belong on the SPI master and sit naturally on the adjacent AVR: reset (CDISP_RST, PF0) and backlight brightness (CDISP_BL, PF1, PWM off a free TCB). Both drop onto the pins freed by removing the 32.768 kHz crystal.
Pulse commands them over the existing Pulse → Cranky SPI link: a display-reset intent pulses CDISP_RST for Pulse's power-on init sequence, and a display-brightness intent sets the backlight, so the screen can dim with the stem ring in a dark room. The engine/authority split is the stem LEDs' again — Pulse says what, Cranky does the timing.
Host Interfaces
| Line | Direction | Carries |
|---|---|---|
| SPI client | bidirectional | Dial and pot reports out, colour and configuration commands in |
CIRQ |
Cranky → host | Asserted when any dial moves or any button changes; cleared when the pending report is read |
| I²C client | bidirectional | Out-of-band configuration, status and identity |
| UPDI | host → Cranky | Firmware programming |
SPI with an interrupt line, not polling. Eight idle dials produce no bus traffic at all. CIRQ is a plain push-pull GPIO; if it ever needs to share a line with other peers it becomes open-drain with a pull-up, which costs nothing in firmware.
Report format
| Field | Size | Description |
|---|---|---|
| Sequence | 1 byte | Increments per report; lets the host detect a missed one |
| Detent deltas | 8 × 1 byte | Signed clicks since the last read, cleared on read |
| Raw deltas | 8 × 1 byte | Signed sub-detent remainder, for smooth-scroll consumers |
| Buttons | 1 byte | One debounced bit per dial |
| Button edges | 1 byte | Set on press, cleared on read — so a tap between two polls is never lost |
| Expansion pots | 12 × 2 bytes | 14-bit absolute value per channel, deadbanded. Reads zero on any channel with nothing fitted |
The edge byte matters more than it looks. A dial's button is often tapped rather than held, and a 1 kHz scan against a host that polls at 60 Hz would otherwise drop presses shorter than 16 ms.
Intent messages
| Intent | Carries |
|---|---|
| Set dial colour | (dial 0–7, RGB, optional fade duration) |
| Set all | (RGB) — one message for the whole ring |
| Set pattern | (dial or all, pattern ID, parameters) |
| Set value ramp | (dial, value 0–255) — lights the stem proportionally, for volume or level dials |
| Set brightness | (0–255) — global scale applied after composition. Also caps the expansion chain, which is how the ribbon's current budget is enforced |
| Set expansion pixel | (index, RGB, optional fade) — one WS2812 on the expansion chain |
| Set chain length | (count) — how many pixels the expansion board actually has |
| Query state | (query type) |
| Display reset | (assert / release) — pulse the round screen's reset for Pulse's init sequence |
| Display brightness | (0–255) — round-screen backlight level |
Peripheral Allocation
| Subsystem | Timer | Peripheral | Pins |
|---|---|---|---|
| Scan tick | TCA0 (1 kHz) | — | — |
| Stem LED refresh | TCB0 (200 Hz) | SPI1 host | PG1, PG2, PG4, PG6 |
| Quadrature | — | — | PB0–PB7, PC0–PC7 |
| Buttons | — | — | PE0–PE7 |
| SPI client + IRQ | — | SPI0 client | PA4–PA7, PG0 |
| I²C client | — | TWI0 client | PA2, PA3 |
| Expansion pots | — | ADC0, AIN0–7 and AIN18–21 | PD0–PD7, PF2–PF5 |
| Expansion NeoPixels | — | USART0 (PORTMUX = NONE) → CCL LUT5 |
PG3 |
| Round-display control | free TCB (BL PWM) | — | PF0, PF1 |
Remaining after allocation:
| Resource | Free |
|---|---|
| Pins | None — every I/O is assigned |
| CCL | LUT0–LUT4, all 3 sequencers |
| Timers | TCA1, TCB1–TCB4, TCD0 |
| Event channels | All 10 |
| Serial | TWI1, USART1–5, and SPI1's unused MISO and SS |
Cranky is the only one of the three boards that ends up fully allocated. That is a choice rather than a squeeze — the expansion connector exists precisely to spend the pins the dial subsystem left over, and it spends every ADC-capable one of them on an analog channel. If the expansion is ever dropped, thirteen pins and all of PORTD come straight back.
Note that TWI1 remains unrouted rather than unavailable: the peripheral is free, but both of its pin positions land on PORTF and PORTB, which are now analog inputs and dial quadrature respectively. Adding an I²C bus to Cranky later would cost two analog channels.
A note on the crystals. The 32.768 kHz crystal is dropped — nothing on Cranky needs an RTC — and its pins PF0/PF1 carry the round-display reset and backlight instead. The 24 MHz crystal stays: it keeps the WS2812 expansion timing exact and matches Clicky/Sticky. (Dropping it too, for the internal high-frequency oscillator, would return PA0/PA1 as well — but nothing needs them, and the WS2812 timing is cleaner on the crystal.)
Pin Naming
| Signal | Direction | Count | Notes |
|---|---|---|---|
Dn_A, Dn_B |
Cranky input | 2 per dial | Quadrature phases. Must be an adjacent bit pair within one port group |
Dn_SW |
Cranky input | 1 per dial | Integrated push button, debounced in firmware |
CLED_SIN, CLED_SCK |
Cranky output | 2 | Serial data and clock to the LED driver (SPI1 host) |
CLED_XLAT |
Cranky output | 1 | Latches a shifted frame |
CLED_BLANK |
Cranky output | 1 | All outputs off. Pull-down fitted so the stems stay dark through reset |
CSPI_MOSI, CSPI_MISO, CSPI_SCK, CSPI_SS |
SPI client from host | 4 | Cranky is always the client; it never drives the clock |
CIRQ |
Cranky output | 1 | Asserted on dial or button change, cleared when the report is read |
CI2C_SDA, CI2C_SCL |
I²C client | 2 | Open-drain, external pull-ups |
XA0 … XA11 |
Cranky input | 12 | Expansion analog inputs. Must be ADC-capable pins |
XLED |
Cranky output | 1 | Expansion WS2812 chain. Must be a CCL LUT output pin |
CDISP_RST |
Cranky output | 1 | Round-display reset — Pulse drives the panel's SPI + DC; Cranky owns reset |
CDISP_BL |
Cranky output | 1 | Round-display backlight brightness (PWM) |
CUPDI |
UPDI from host | 1 | Firmware update only |
Pinouts
| Pin | Function | Description |
|---|---|---|
| PA0 | XTALHF1 | 24 MHz crystal — optional, see above |
| PA1 | XTALHF2 | |
| PA2 | CI2C_SDA | I²C client — TWI0 default position |
| PA3 | CI2C_SCL | |
| PA4 | CSPI_MOSI | SPI client — SPI0 default position |
| PA5 | CSPI_MISO | |
| PA6 | CSPI_SCK | |
| PA7 | CSPI_SS | |
| PB0 | D1_A | Dial 1 quadrature A |
| PB1 | D1_B | Dial 1 quadrature B |
| PB2 | D2_A | |
| PB3 | D2_B | |
| PB4 | D3_A | |
| PB5 | D3_B | |
| PB6 | D4_A | |
| PB7 | D4_B | |
| PC0 | D5_A | Dial 5 quadrature A |
| PC1 | D5_B | |
| PC2 | D6_A | |
| PC3 | D6_B | |
| PC4 | D7_A | |
| PC5 | D7_B | |
| PC6 | D8_A | |
| PC7 | D8_B | |
| PD0 | XA0 — AIN0 | Expansion analog input 0 |
| PD1 | XA1 — AIN1 | |
| PD2 | XA2 — AIN2 | |
| PD3 | XA3 — AIN3 | |
| PD4 | XA4 — AIN4 | |
| PD5 | XA5 — AIN5 | |
| PD6 | XA6 — AIN6 | Also DAC0 OUT — DAC0 unavailable |
| PD7 | XA7 — AIN7 | Also VREFA — use an internal ADC reference |
| PE0 | D1_SW | Dial 1 push button |
| PE1 | D2_SW | |
| PE2 | D3_SW | |
| PE3 | D4_SW | |
| PE4 | D5_SW | |
| PE5 | D6_SW | |
| PE6 | D7_SW | |
| PE7 | D8_SW | |
| PF0 | CDISP_RST | Round-display reset (out) — Pulse's screen, on command |
| PF1 | CDISP_BL | Round-display backlight brightness (PWM out) |
| PF2 | XA8 — AIN18 | Expansion analog input 8 |
| PF3 | XA9 — AIN19 | Expansion analog input 9 |
| PF4 | XA10 — AIN20 | Expansion analog input 10 |
| PF5 | XA11 — AIN21 | Expansion analog input 11 |
| PF6 | RESET | |
| PF7 | CUPDI | UPDI |
| PG0 | CIRQ | Interrupt to host |
| PG1 | CLED_XLAT | Stem LED driver latch |
| PG2 | CLED_BLANK | Stem LED driver blank, pull-down fitted |
| PG3 | XLED — CCL LUT5 out | Expansion WS2812 chain. LUT5's default output position; 330 Ω series at the connector |
| PG4 | CLED_SIN | SPI1 MOSI — ALT3 position |
| PG5 | (SPI1 MISO) | Unused by the driver; claimed by the PORTMUX position |
| PG6 | CLED_SCK | SPI1 SCK — ALT3 position |
| PG7 | (SPI1 SS) | Unused; SSD set so a floating SS cannot force client mode |
Signal Conditioning
Jog dials are user-touched mechanical parts, so the encoder lines need both debouncing and a little ESD tolerance. Neither is elaborate.
| Element | Value | Purpose |
|---|---|---|
| Pull-ups, all 16 quadrature lines | 10 kΩ to +5 V | External rather than internal. The AVR's internal pull-ups are around 35 kΩ, which against the filter cap below would give a 350 µs rise — slower than a fast dial's edge spacing |
| Filter caps, all 16 quadrature lines | 10 nF to GND | With the 10 kΩ pull-up that is a 100 µs time constant, comfortably past the contact bounce of an EC11-class encoder and comfortably inside the ~2.5 ms between edges on a hard-spun dial |
| Pull-ups, 8 button lines | Internal, 35 kΩ | Buttons are debounced in firmware over 8 ms, so they need no analog filtering and no external parts |
| TVS, if the dials are panel-mounted | Low-capacitance array on the 16 quadrature lines | Only needed if the encoder bodies are exposed through the case. If they sit behind a grounded panel and the shafts are plastic, the filter caps are enough |
| Series, expansion analog lines | 1 kΩ | Against a 10 kΩ pot this is a 10 % error at the extremes if left alone — so it belongs on the Cranky side of the divider, between the connector and the pin, where it only sees the ADC's sample current. It limits fault current from a hot-plugged ribbon |
| Filter caps, expansion analog lines | 100 nF to GND at the MCU pin | With a 10 kΩ pot's mid-scale 5 kΩ source that is a 500 µs settle — well inside the 10 ms each channel gets in the round-robin, and it gives the ADC's sample capacitor a local reservoir |
Series, XLED |
330 Ω at the connector | Standard WS2812 practice. Damps the edge into a long ribbon and limits fault current |
The 4× transition table also rejects illegal double transitions on its own, which cleans up whatever the RC filter misses. Between the two, no software debounce is needed on the quadrature — only on the buttons.
Silicon Revision Notes
Errata references are DS80000915F rev. F, covering AVR128DB28/32/48/64.
| Erratum | Effect on Cranky | Revisions |
|---|---|---|
| 2.15.1 TWI output pin override does not function as expected | Ensure PORTA.OUT bits 2 and 3 are 0 before enabling TWI0, or the lines can be held high |
A4, A5 — fixed in B0 |
| 2.15.2 TWI Flush non-functional | Host-mode only. Cranky is an I²C client, so this does not apply | All revisions |
| 2.11.1 SPI1 ALT2 non-functional | Does not apply — names 48-pin devices only, and Cranky uses SPI1 at ALT3 in any case | A4, A5 — fixed in B0 |
| 2.12.1 TCA restart resets count direction in NORMAL/FRQ mode | TCA0 is a periodic tick and is never restarted by command or event | A4, A5 — fixed in B0 |
| 2.3.2 ADC MUX update delayed when initialization delay is used | Directly relevant now the expansion pots exist. Cranky rotates MUXPOS across twelve channels; with a non-zero INITDLY each result would come from the previous channel. Keep INITDLY = 0 and use SAMPCTRL.SAMPLEN for acquisition time |
All revisions |
| 2.3.1 Increased offset in single-ended mode | −3 mV typical. Below the deadband, and irrelevant to a pot the host scales anyway | A4 only |
Bring-up verification list:
- Encoder direction — confirm every dial counts positive clockwise. A swapped A/B pair inverts one dial silently, and it is far easier to fix in the port table than on the board
- Detent alignment — check that a click lands on a stable quadrature state rather than mid-transition. Some encoders idle at
00between detents and some at11; if the count jitters by one at rest, the phase seed is off by a quarter cycle - LED frame timing — confirm no visible tearing or flicker at 200 Hz, and that
/BLANKgenuinely holds the stems dark from power-on until the first frame latches - Red channel thermals — run all eight stems at full white for ten minutes and check the driver's package temperature. This is the case the 150 Ω resistors exist for; measure rather than assume
- Cross-talk — spin two adjacent dials hard at once and confirm neither loses counts. Both ISRs read a whole port, so a missed edge would point at interrupt latency from the LED refresh rather than at the decode
- Pot noise floor — with the expansion fitted, leave every pot untouched and confirm no channel crosses the deadband. A pot that dithers across the threshold holds
CIRQasserted permanently and floods the host, which looks like a protocol fault rather than an analog one - The CCL tap on
XLED— scope PG3 with USART0 transmitting andPORTMUX = NONE. If the tap does not survive NONE, fall back to bit-banging: a twelve-pixel chain is only 360 µs, so at 30 Hz it costs about 1 % of a core
Future Considerations
- Ambient light sensing for auto-brightness — one of the twelve analog channels, or a sensor on whichever board has a spare pin. The stems would then dim in a dark room along with the pot ring, which matters for a control surface that sits under someone's hands in the evening
- A longer WS2812 chain — the lighting extends without new pins; only the analog channels are finite. A second expansion board with no pots of its own could add lighting indefinitely, subject only to its own power feed and the 60 Hz frame budget
- Per-dial haptic detent — a small coil or actuator under each dial could synthesise detents in software, so a dial's feel changes with what it currently controls: fine steps for a value, hard stops at the ends of a range, free-spin for scrolling. It needs eight drive lines and the pin budget has thirteen, though the amplifier count makes it a board-level decision rather than a firmware one
- Higher-resolution encoders — the decode path is agnostic to detents per revolution, so optical encoders would drop in unchanged. Only the divide-by-four in
dials_scanand the host's expectations would move - Hardware quadrature for one or two dials — the CCL is entirely unallocated and TCA1, four TCBs and TCD0 are free. Two LUTs, a sequencer and a counter per dial means at most three could be promoted, which is why none are. It only becomes worth doing if a dial is ever motorised or geared to spin far faster than a hand
- A second LED driver in the chain — the TLC5947 daisy-chains, so under-panel or bezel lighting could be added by extending the same shift register with no new pins at all. The frame simply gets longer
