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

Cranky decodes eight dials and runs lighting under Pulse control

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

Ant64 concept render — close-up of eight illuminated dials in two rows beside the circular screen

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:

  1. Host intent — the base colour the host has set for that dial, immediate or interpolated
  2. 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
  3. 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
XA0XA11 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:

  1. 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
  2. Detent alignment — check that a click lands on a stable quadrature state rather than mid-transition. Some encoders idle at 00 between detents and some at 11; if the count jitters by one at rest, the phase seed is off by a quarter cycle
  3. LED frame timing — confirm no visible tearing or flicker at 200 Hz, and that /BLANK genuinely holds the stems dark from power-on until the first frame latches
  4. 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
  5. 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
  6. 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 CIRQ asserted permanently and floods the host, which looks like a protocol fault rather than an analog one
  7. The CCL tap on XLED — scope PG3 with USART0 transmitting and PORTMUX = 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_scan and 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

Important: The Ant64 family of home computers are at early design/prototype stage, everything you see here is subject to change.