I2C SeeSaw details

Continuing the discussion from Documentation update:

This was started by a desire to have an I2C device that doesn’t constantly change its readout. I suggested an Adafruit I2C Stemma QT Rotary Encoder as one option, as a rotary encoder will change value only in response to rotation of the device (or power loss, I suppose).

Found the SeeSaw datasheet.

SeeSaw appears to be an Adafruit branding that indicates a specific way to discover and use various functionality over I2C. The predefined register set includes edges for (and code to support) direct GPIO control, timers, analog reads, interrupt lines, eeprom access, neopixels, touch controls, and rotary encoders.

Source for controlling the devices is available in both C and CircuitPython, but I wanted to convert this to BusPirate’s syntax. Below is the successful results … all features became accessible through the buspirate.

1 Like
My brief overview of the Adafruit `SeeSaw` protocol

  1. There are a bunch of well-known registers sets that each device may support. See all the xxxx_BASE definitions in C or in CircuitPython.

  2. For each xxxx_BASE register, there are one or more “edge definitions” associated with that functionality.

  3. Read commands are abstracted with a function that takes the register base, the edge definition register, and the buffer to read into.

  4. Write commands are abstracted with a function that takes the register base, the edge definition register, and the buffer to send to the device.

Everything is built upon these concepts, essentially allowing remote control of many common device types over I2C, without having to write the common code again.

1 Like

Device Identification

By design, the device should come up functional. To help ensure the device being communicated with is the expected device, the following initialization occurs:

First read one byte from STATUS_BASE (0x00) /
STATUS_HW_ID (0x01) to get an identifier of the chip on the far side of the I2C connection. In the below example, the chip responds 0x55, indicating that the controller on the target I2C device is a SAMD09 chip.

I2C> [ 0x6c 0x00 0x01 ] d:100 [ 0x6d r:1 ]

I2C START
TX: 0x6C ACK 0x00 ACK 0x01 ACK
I2C STOP
Delay: 100us
I2C START
TX: 0x6D ACK
RX: 0x55 NACK
I2C STOP

Next get the product ID (PID) by reading int32_t (encoded big-endian) by reading STATUS_BASE (0x00) / STATUS_VERSION (0x02). The PID is the 16 most significant bits of that int32_t value.

In the below example, the target I2C device is reporting a device ID of 4991 (0x137F):

I2C> [ 0x6c 0x00 0x02 ] d:100 [ 0x6d r:4 ]

I2C START
TX: 0x6C ACK 0x00 ACK 0x02 ACK
I2C STOP
Delay: 100us
I2C START
TX: 0x6D ACK
RX: 0x13 ACK 0x7F ACK 0x2A ACK 0xA3 NACK
I2C STOP
I2C> = 4991
 =0x137F.16 =4991.16 =0b0001001101111111.16

The PID is not one of the originally released SeeSaw devices, so the chip ID can be used determine which pins support capacitive touch, analog reads, PWM outputs, etc. In this case, we know the details of the board with PID 4991 (0x137F), which are also confirmed by reading STATUS_BASE (0x00) / STATUS_OPTIONS (0x04):

I2C> [ 0x6C 0x00 0x03 ] d:1000 [ 0x6D r:4 ]

I2C START
TX: 0x6C ACK 0x00 ACK 0x03 ACK
I2C STOP
Delay: 1000us
I2C START
TX: 0x6D ACK
RX: 0x00 ACK 0x02 ACK 0x48 ACK 0x03 NACK
I2C STOP

I2C> # 0x0002'4803 was the returned data
I2C> # Each set bit corresponds to a supported register base:
I2C> # 0x0000'0001 == Bit 0x00 == STATUS_BASE
I2C> # 0x0000'0002 == Bit 0x01 == GPIO_BASE
I2C> # 0x0000'0800 == Bit 0x0B == INTERRUPT_BASE
I2C> # 0x0000'4000 == Bit 0x0E == NEOPIXEL_BASE
I2C> # 0x0002'0000 == Bit 0x11 == ENCODER_BASE

Details for PID 4991

Here, we know that PID 4991 (0x137F) is a board with SeeSaw firmware having the following features:

  • a single neopixel attached to pin 6 (SeeSaw firmware exposing defined register SEESAW_NEOPIXEL_BASE)
  • a single rotary encoder (SeeSaw firmware exposing defined register SEESAW_ENCODER_BASE)
  • a rotary encoder button connected to pin 24 (setup and read via SeeSaw GPIO register / edge modules)

`SEESAW_NEOPIXEL_BASE` (`0x0E`) defines six edge modules / functions:

Symbol Value Type Purpose
NEOPIXEL_STATUS 0x00 N/A Not used
NEOPIXEL_PIN 0x01 W Configure a neopixel on one of the I2C device’s pins
NEOPIXEL_SPEED 0x02 W 0x01 is 800kHz and default, 0x00 for 400kHz
NEOPIXEL_BUF_LENGTH 0x03 W Send big-endian 16-bit value indicating length of buffer for the neopixels
NEOPIXEL_BUF 0x04 W Send color data to on-I2C-device buffer; First two bytes is start address, followed by
NEOPIXEL_SHOW 0x05 W Update neopixel using buffered color data

`SEESAW_ENCODER_BASE` (`0x11`) defines four edge modules / functions:

Symbol Value Type Purpose
SEESAW_ENCODER_STATUS 0x00 N/A Not used
SEESAW_ENCODER_INTENSET 0x10 W Interrupt line will go high for rotation, until position (or delta) is read
SEESAW_ENCODER_INTENCLR 0x20 W Disable interrupt line due to rotation
SEESAW_ENCODER_POSITION 0x30 R/W Encoder Current Position
SEESAW_ENCODER_DELTA 0x40 R Encoder difference from last read position

`SEESAW_GPIO_BASE` (`0x01`) defines eleven edge modules / functions:

All functions take a 32-bit input, encoded big-endian, with each bit corresponding to one of the pins of the mcu of the SeeSaw firmware-enabled device. For SAMD09, bits 0..31 correspond to PA00..PA31. (The mapping is not as straightforward for some of the ATTiny chips.)

With the exception of SEESAW_GPIO_INTFLAG and SEESAW_GPIO_GPIO, the functions will only affect pins whose corresponding bit is set to one. This allows the host to perform bulk updates of pin state with fewer I2C transactions.

Symbol Value Type Purpose
SEESAW_GPIO_DIRSET 0x02 W Configure pins as OUTPUT
SEESAW_GPIO_DIRCLR 0x03 W Configure pins as INPUT
SEESAW_GPIO_GPIO 0x04 R/W Reads all pins (and clears INTFLAG); Writes all pins to high or low based on corresponding bit. THIS AFFECTS ALL PINS WITH EACH CALL.
SEESAW_GPIO_SET 0x05 W Set pins output to HIGH
SEESAW_GPIO_CLR 0x06 W Set pins output to LOW
SEESAW_GPIO_TOGGLE 0x07 W Toggles pins output
SEESAW_GPIO_INTENSET 0x08 W Enable reporting pins’ value changes in INTFLAG register.
SEESAW_GPIO_INTENCLR 0x09 W Disable reporting pins’ value changes in INTFLAG register
SEESAW_GPIO_INTFLAG 0x0A R Read status of all GPIO interrupts. Reading this register will clear the value to zero.
SEESAW_GPIO_PULLENSET 0x0B W Enable the pins internal pullup/pulldown. Direction is determined by the GPIO value: LOW for pulldown, HIGH for pullup.
SEESAW_GPIO_PULLENCLR 0x0C W Disable the pins internal pullup/pulldown.

Here, the button is connected to the SAMD09 pin 24, which encoded as a pin bitmask (BE) is 0x01 0x00 0x00 0x00.

Thus, to configure that pin as input with pullup:

` NOTE: data is big-endian encoded
` NOTE: Mask for pin 24 is 0x01 0x00 0x00 0x00
` Set the pin to INPUT with DIRCLR
[ 0x6C  0x01 0x03  0x01 0x00 0x00 0x00 ]
` Enable the internal pullup/pulldown
[ 0x6C  0x01 0x0B  0x01 0x00 0x00 0x00 ]
` Make it a pullup by setting pin output HIGH
[ 0x6C  0x01 0x05  0x01 0x00 0x00 0x00 ]
` Read the button state
[ 0x6C  0x01 0x04 ] d:250 [ 0x6D r:4 ]

Known Bugs

  • SeeSaw firmware (at least on SAMD09 chips) does NOT support REPEATED START (i.e., sending a new START frame without sending a STOP frame). A STOP frame is required before the next START frame, or the data received will be (at best) unreliable.

  • Setting the current position of the encode has resulted in heisenbugs. Recommending to avoid writing a current position to the SeeSaw encoder.

  • Timing window can leave interrupt line high. Here’s an example of the order in which operations might happen:

    • Interrupt line is enabled
    • Rotation occurs, sets interrupt line (high)
    • Host reads rotation, which clears interrupt line (low)
    • Rotation occurs, sets interrupt line (high)
    • Host sends I2C command to disable interrupt line
      • Note: this does NOT clear the interrupt line … it remains high
    • Host reads rotation
      • NOTE: this does NOT clear the interrupt line … it remains high
    • Therefore, there is a timing window where rotations occurs between the last host read of the encoder, and the host disabling interrupts, which leaves the interrupt line set high. There is no obvious way to clear the interrupt line (yet). Open Question: Can this be “fixed” by writing to the I2C (SeeSaw) GPIO registers?

Initialization Script

NOTE: For old BP5 firmware (where # is a “reset the buspirate” command), simply remove the commented lines, or replace with “`” character (which will report an invalid command, but not do anything unexpected).

# ---- Verify the device's product ID is 4991 (`0x137F`) ----
[ 0x6c  0x00 0x02 ] d:100 [ 0x6d r:4 ]
= 0x137F

# ---- Configure Neopixel
# Set neopixel to use pin 6
[ 0x6C  0x0E 0x01  0x06 ]
# Set neopixel buffer length to 3 bytes (GRB)
[ 0x6C  0x0E 0x03  0x00 0x03 ]
# Update neopixel buffer at address 0x0000
# with RGB 0x22 0x11 0x33 --> GRB 0x11 0x22 0x33
[ 0x6C  0x0E 0x04  0x00 0x00 0x11 0x22 0x33 ]
# Send neopixel data to the pixel(s)
[ 0x6C  0x0E 0x05 ]

# ---- Configure Rotary Encoder
# Optionally, enable an interrupt when encoder rotates
[ 0x6C  0x11  0x10  0x01 ]
# Read the current position
[ 0x6C 0x11 0x30 ] [ 0x6D r:4 ]
# If enabled interrupts, read position when that line goes high
# Otherwise, poll the current position periodically

# ---- Configure the Encoder button ----
# Set the encoder button's pin to INPUT with DIRCLR
[ 0x6C  0x01 0x03  0x01 0x00 0x00 0x00 ]
# Enable the internal pullup/pulldown for encoder button
[ 0x6C  0x01 0x0B  0x01 0x00 0x00 0x00 ]
# Make encoder button pin a pullup by setting pin output HIGH
[ 0x6C  0x01 0x05  0x01 0x00 0x00 0x00 ]

# With button released, read the button state
[ 0x6C  0x01 0x04 ] d:250 [ 0x6D r:4 ]
# Note: returned data & 0x01000000 == 0x01000000
# and therefore the button is NOT pressed

# Now hold the encoder button down and read again
[ 0x6C  0x01 0x04 ] d:250 [ 0x6D r:4 ]
# Note: returned data & 0x01000000 == 0x00000000
# and therefore the button IS pressed



Actual initialization output

Example output of running the above initialization commands:

I2C> # ---- Verify the device's product ID is 4991 (`0x137F`) ----
I2C> [ 0x6c  0x00 0x02 ] d:100 [ 0x6d r:4 ]

I2C START
TX: 0x6C ACK 0x00 ACK 0x02 ACK
I2C STOP
Delay: 100us
I2C START
TX: 0x6D ACK
RX: 0x13 ACK 0x7F ACK 0x2A ACK 0xA3 NACK
I2C STOP

I2C> = 0x137F
 =0x137F.16 =4991.16 =0b0001001101111111.16

I2C> # ---- Configure Neopixel
I2C> # Set neopixel to use pin 6
I2C> [ 0x6C  0x0E 0x01  0x06 ]

I2C START
TX: 0x6C ACK 0x0E ACK 0x01 ACK 0x06 ACK
I2C STOP
I2C> # Set neopixel buffer length to 3 bytes (GRB)
I2C> [ 0x6C  0x0E 0x03  0x00 0x03 ]

I2C START
TX: 0x6C ACK 0x0E ACK 0x03 ACK 0x00 ACK 0x03 ACK
I2C STOP

I2C> # Update neopixel buffer at address 0x0000
I2C> # with RGB 0x22 0x11 0x33 --> GRB 0x11 0x22 0x33
I2C> [ 0x6C  0x0E 0x04  0x00 0x00 0x11 0x22 0x33 ]

I2C START
TX: 0x6C ACK 0x0E ACK 0x04 ACK 0x00 ACK 0x00 ACK 0x11 ACK 0x22 ACK 0x33 ACK
I2C STOP

I2C> # Send neopixel data to the pixel(s)
Invalid command: `. Type ? for help.
I2C> [ 0x6C  0x0E 0x05 ]

I2C START
TX: 0x6C ACK 0x0E ACK 0x05 ACK
I2C STOP

I2C> # ---- Configure Rotary Encoder
I2C> # Optionally, enable an interrupt when encoder rotates
I2C> [ 0x6C  0x11  0x10  0x01 ]

I2C START
TX: 0x6C ACK 0x11 ACK 0x10 ACK 0x01 ACK
I2C STOP

I2C> # Read the current position
I2C> [ 0x6C 0x11 0x30 ] [ 0x6D r:4 ]

I2C START
TX: 0x6C ACK 0x11 ACK 0x30 ACK
I2C STOP
I2C START
TX: 0x6D ACK
RX: 0x00 ACK 0x00 ACK 0x00 ACK 0x00 NACK
I2C STOP

I2C> # ---- Configure the Encoder button ----
I2C> # Set the encoder button's pin to INPUT with DIRCLR
Invalid command: `. Type ? for help.
I2C> [ 0x6C  0x01 0x03  0x01 0x00 0x00 0x00 ]

I2C START
TX: 0x6C ACK 0x01 ACK 0x03 ACK 0x01 ACK 0x00 ACK 0x00 ACK 0x00 ACK
I2C STOP

I2C> # Enable the internal pullup/pulldown for encoder button
I2C> [ 0x6C  0x01 0x0B  0x01 0x00 0x00 0x00 ]

I2C START
TX: 0x6C ACK 0x01 ACK 0x0B ACK 0x01 ACK 0x00 ACK 0x00 ACK 0x00 ACK
I2C STOP

I2C> # Make encoder button pin a pullup by setting pin output HIGH
I2C> [ 0x6C  0x01 0x05  0x01 0x00 0x00 0x00 ]

I2C START
TX: 0x6C ACK 0x01 ACK 0x05 ACK 0x01 ACK 0x00 ACK 0x00 ACK 0x00 ACK
I2C STOP

I2C> # Read the button state
I2C> [ 0x6C  0x01 0x04 ] d:250 [ 0x6D r:4 ]

I2C START
TX: 0x6C ACK 0x01 ACK 0x04 ACK
I2C STOP
Delay: 250us
I2C START
TX: 0x6D ACK
RX: 0x53 ACK 0xC0 ACK 0x85 ACK 0x30 NACK
I2C STOP
I2C> # Note: 0x53C08530 & 0x01000000 == 0x01000000


I2C> # Now hold the encoder button down and read again
I2C> # and therefore the button is NOT pressed
I2C> [ 0x6C  0x01 0x04 ] d:250 [ 0x6D r:4 ]

I2C START
TX: 0x6C ACK 0x01 ACK 0x04 ACK
I2C STOP
Delay: 250us
I2C START
TX: 0x6D ACK
RX: 0x52 ACK 0xC0 ACK 0x85 ACK 0x30 NACK
I2C STOP
I2C> # Note: 0x52C08530 & 0x01000000 == 0x00000000
I2C> # and therefore the button IS pressed


1 Like

That’s it. This covers the setup and configuration of the Adafruit I2C Stemma QT Rotary Encoder using the SeeSaw firmaware interface.

The above shows interaction with a Neopixel (WS281x LED … just like the bus pirate has), a rotary encoder, and the rotary encoder’s pushbutton … all over I2C. The SeeSaw firmware makes it easier to deal with Neopixels and rotary encoders … offloading timing-sensitive operations.

The pushbutton still needs to be polled, although if using a board with multiple buttons, such as the I2C Quad Rotary Encoder, or the ANO Rotary Navigation Encoder, the pins for all the buttons can be queried with a single I2C transaction.

Fun future project

I’ve long liked the idea of using an I2C expander, but using DMA + PIO to expose the relevant data as (essentially) a memory-mapped copy, so the host can just read a position in memory for the most updated values.

Can I squeeze the logic into a single PIO program to do the following, entirely driven by interrups (on the I2C side) and a FIFO for updates of the neopixel (on the host side):

  1. Send I2C commands to read encoder’s position, writing result via FIFO
  2. Send I2C commands to read button state, writing result via FIFO
  3. Send I2C commands to update neopixel colors, when FIFO has sufficient data
  4. Setup DMA to read from the PIO FIFO, continuously writing to the following 64-bit struct:
struct _PIO_ROTARY_ENCODER_STATE {
    uint32_t gpio_state;
    uint32_t encoder_position;
} PIO_ROTARY_ENCODER_STATE;

If so, then the buttons and encoder position essentially become memory-mapped values, and writing an neopixel update becomes as simple as writing a uint32_t value to the PIO’s FIFO queue.

… tis but a dream …