Interfacing with a Rotary Encoder

The quickstart guide covered the basics of connecting the UI sandbox with an Arduino and interacting with a flashing light. This guide goes a bit further with some more interesting hardware.

For this example, I'll be using an Arduino compatible board (Feather ESP32) and Sparkfun's Qwiic I2C encoder breakout board. The breakout includes a 24 position rotary encoder, push button and RGB led in the stem.

encoder-breakout

We'll implement the following:

  • Ask the library for the encoder position and make it available to the UI with a tracked variable called counts,
  • Implement a position zero function aka home, using a callback function
  • Watch the integrated button for state changes
  • Notify the UI immediately when the encoder or button state changes, rather than waiting for polling to occur.

If you're using a different encoder and library (or a different sensor entirely) the same general concepts should still apply to handling and zeroing the count. You can skip the button handling parts!

Firmware

Using Sparkfun's Qwiic Twist library makes implementation with Electric UI pretty straightforward.

Starting with an empty Arduino sketch, import the encoder library and electricui-embedded, and add the serial handling behaviour needed for Electric UI that you should have seen in the quick-start guide:

#include "electricui.h"
#include "SparkFun_Qwiic_Twist_Arduino_Library.h"
TWIST twist; // Encoder twist library object
// ------------------------------------------------------
char nickname[] = "Encoder Demo";
eui_interface_t serial_comms = EUI_INTERFACE( &serial_write );
eui_message_t tracked_variables[] =
{
EUI_CHAR_ARRAY_RO("name", nickname ),
};
// ------------------------------------------------------
void setup()
{
Serial.begin(115200);
pinMode(LED_BUILTIN, OUTPUT);
// eUI setup
eui_setup_interface( &serial_comms );
EUI_TRACK( tracked_variables );
eui_setup_identifier( "knob", 4 );
// More setup code will go here later
}
void loop()
{
serial_rx_handler();
// Code will go here
}
// ------------------------------------------------------
void serial_rx_handler()
{
while( Serial.available() > 0 )
{
eui_parse( Serial.read(), &serial_comms ); // Ingest a byte
}
}
void serial_write( uint8_t *data, uint16_t len )
{
Serial.write( data, len ); // Output to the primary serial port/USB
}

Notice how we also declare the TWIST twist; object near the top of the skeleton - this is what we'll use to work with the rotary encoder library.

Reading Encoder Position

First, we need to setup the encoder in setup() using twist.begin() . This is done in a simple infinite retry loop here, but for a real project you should consider connecting and retrying with some way to gracefully handle failures.

// Encoder Connection
while( !twist.begin() )
{
// Blocking LED flash if the connection fails
digitalWrite(LED_BUILTIN, HIGH);
delay(80);
digitalWrite(LED_BUILTIN, LOW);
delay(150);
}

With setup working, we can attempt to read the encoder position in the loop() using twist.getCount().

First, create a new signed 32-bit integer variable counts near the top of your file,

#include "electricui.h"
#include "SparkFun_Qwiic_Twist_Arduino_Library.h"
TWIST twist; // Encoder twist library object
// UI facing data
int32_t counts = 0;

and then add it to the tracked variables array to allow the electricui-embedded library to handle the messaging behaviour.

eui_message_t tracked_variables[] =
{
EUI_CHAR_ARRAY_RO("name", nickname ),
EUI_INT32( "pos", counts ),
};

Instead of blindly reading the encoder position into the tracked variable and sending it to the UI, it would be more efficient to check if the encoder value has changed and then send an update if it has.

This results in a more responsive UI experience and reduces the amount of background serial transfers would occur with just updating the value each loop. If you're using a different encoder library, you might need to write your own test to check if the position has changed between subsequent runs of the loop, but in this situation the Twist library lets us ask the encoder breakout directly:

// Check if the encoder has changed position
if( twist.isMoved() )
{
counts = twist.getCount();
eui_send_tracked("pos");
}

If the encoder has changed value, we query it's position reference into the counts tracked variable, and then immediately send the new position to the UI over serial.

Pushbutton Status

Similar to the encoder position, we also want to report the button pressed 1 or released 0 state to the user interface. But this time we need to implement the change detection ourselves.

With two new boolean variables declared at the top,

bool pressed = false;
bool wasPressed = false;

we can add a simple check to our loop

// Check the button state
pressed = twist.isPressed();
if( pressed != wasPressed ) // Update the UI when it changes
{
eui_send_tracked("button");
wasPressed = pressed;
}

Don't forget to track one of those variables for the UI. Variables using the bool type occupy a byte, which is the same thing as an unsigned 8-bit value for Arduino and embedded platforms, so we track it using the EUI_UINT8 macro.

eui_message_t tracked_variables[] =
{
EUI_CHAR_ARRAY_RO("name", nickname ),
EUI_INT32( "pos", counts ),
EUI_UINT8( "button", pressed ),
};

Adding a custom type

2 bytes of version information can be requested from the rotary encoder, expressed as major.minor i.e. 1.3.

Instead of sending those bytes through without formatting, we'll use a C structure to express the information in a more ergonomic manner.

typedef struct {
uint8_t major;
uint8_t minor;
} version_info_t;
version_info_t encoder_fw_version = { 0 };

After connecting to the encoder in setup() we can request this information and store it in the encoder_fw_version structure,

// Get the encoder's version info
uint16_t version_data = twist.getVersion();
encoder_fw_version.major = version_data & 0xFF;
encoder_fw_version.minor = version_data >> 8;

Don't worry about the details of the bitmasking and shifts in this snippet - just extracting the lower byte into major, then the upper byte into minor as needed for this specific breakout.

All that's left is to add this structured data to the list of tracked Electric UI variables. Add a new entry to the eui_message_t array using the custom type. We'll also mark this data as read-only RO, as the UI shouldn't be able to modify the firmware version information.

eui_message_t tracked_variables[] =
{
// ...
EUI_CUSTOM_RO( "ver", encoder_fw_version ),
};

We'll create a custom codec to handle decoding this data in the user interface section later.

Position Reset Callback

We want the UI to support a 'reset position' button to zero out the tracked encoder counts. One fast way to implement this behaviour is to create a new function in firmware that contains the necessary logic to reset the counts,

// UI callable function to zero the tracked position
void home_encoder( void )
{
twist.setCount(0); // Zero the encoder's tracked value
counts = twist.getCount(); // Update UI shared state
eui_send_tracked("pos"); // Publish new value to UI
}

In this callback, we use the Twist library's setCount() method to request that the breakout board's controller set it's internal position reference to 0. We also refresh the eUI tracked variable to match the encoder's position.

We can make this function available to the UI as a callback by adding it to the list of tracked messages with the EUI_FUNC type,

eui_message_t tracked_variables[] =
{
// ...
EUI_FUNC( "home", home_encoder ),
};

This function will be triggered by a button press in the UI.

Troubleshooting Tip!

In typical C or C++ you would normally declare public functions in the header file. The Arduino build process 'auto-magically makes it work' with a single sketch file.

If you get compiler warnings or have issues with this function with your microcontroller, try explicitly declaring the function signature near your variables at the top of the sketch:

void home_encoder( void );

Full Example Code

#include "electricui.h"
#include "SparkFun_Qwiic_Twist_Arduino_Library.h"
TWIST twist; // Encoder twist library object
// UI facing data
int32_t counts = 0;
bool pressed = false;
bool wasPressed = false;
typedef struct {
uint8_t major;
uint8_t minor;
} version_info_t;
version_info_t encoder_fw_version = { 0 };
void home_encoder( void );
// ------------------------------------------------------
char nickname[] = "Encoder Demo";
// Instantiate the communication interface's management object
eui_interface_t serial_comms = EUI_INTERFACE( &serial_write );
eui_message_t tracked_variables[] =
{
EUI_CHAR_ARRAY_RO("name", nickname ),
EUI_INT32( "pos", counts ),
EUI_UINT8( "button", pressed ),
EUI_CUSTOM_RO( "ver", encoder_fw_version ),
EUI_FUNC( "home", home_encoder ),
};
// ------------------------------------------------------
void setup()
{
Serial.begin(115200);
pinMode(LED_BUILTIN, OUTPUT);
// eUI setup
eui_setup_interface( &serial_comms );
EUI_TRACK( tracked_variables );
eui_setup_identifier( "knob", 4 );
// Encoder Connection
while( !twist.begin() )
{
// Blocking LED flash if the connection fails
digitalWrite(LED_BUILTIN, HIGH);
delay(80);
digitalWrite(LED_BUILTIN, LOW);
delay(150);
}
// Connection ok!
digitalWrite(LED_BUILTIN, HIGH);
// Get the encoder's version info
uint16_t version_data = twist.getVersion();
encoder_fw_version.major = version_data & 0xFF;
encoder_fw_version.minor = version_data >> 8;
// Set the knob led to green
twist.setColor(0, 128, 0);
}
void loop()
{
serial_rx_handler();
// Check if the encoder has changed position
if( twist.isMoved() )
{
counts = twist.getCount();
eui_send_tracked("pos");
}
// Check the button state
pressed = twist.isPressed();
if( pressed != wasPressed ) // Update the UI when it changes
{
eui_send_tracked("button");
wasPressed = pressed;
}
}
// UI callable function to zero the tracked position
void home_encoder( void )
{
twist.setCount(0); // Zero the encoder's tracked value
counts = twist.getCount(); // Update UI shared state
eui_send_tracked("pos");
}
// ------------------------------------------------------
// eUI Serial Handling
void serial_rx_handler()
{
while( Serial.available() > 0 )
{
eui_parse( Serial.read(), &serial_comms ); // Ingest a byte
}
}
void serial_write( uint8_t *data, uint16_t len )
{
Serial.write( data, len ); // Output to the primary serial port/USB
}

User Interface

This is written assuming that a template has been prepared for development similar to the quickstart guide. You can use the same project if you want.

Launch the dev environment with arc start and without making any changes we can connect to the hardware and look for our tracked variables using the transport debugger tooling. If the window doesn't open by default, open it in the menubar "DevTools -> Show Transport Window".

transport-window

Rotating the encoder by hand and pushing the button should cause updates to the variables in the "Hardware state" section.

Plotting Position Info

Because the template was expecting led_blink tracked variables we don't see our data without some changes. Lets plot the encoder position and display the button press info as text.

In /src/application/pages/DevicePages/OverviewPage.tsx, rename and update the useMessageDataSource() hook to use the pos tracked variable.

export const OverviewPage = (props: RouteComponentProps) => {
const encoderPosDS = useMessageDataSource('pos')
// ...

We'll use the Printer component to display the current encoder position as text.

encoder-plot
import { Printer } from '@electricui/components-desktop'

Similar to the getting started guide, modify the template's named layout however you like, removing the Slider and Light areas. Here's an broad example which has Chart and Info sections

const layoutDescription = `
Chart Info
`
export const OverviewPage = (props: RouteComponentProps) => {
const encoderPosDS = useMessageDataSource('pos')
return (
<React.Fragment>
<Composition areas={layoutDescription} gap={10} autoCols="3fr 1fr">
{Areas => (
<React.Fragment>
<Areas.Chart>
<ChartContainer>
<LineChart dataSource={encoderPosDS} />
<RealTimeDomain window={20000} yMaxSoft={24} yMinSoft={-24}/>
<TimeAxis />
<VerticalAxis />
</ChartContainer>
</Areas.Chart>
<Areas.Info>
<Card>
Position: <Printer accessor="pos"/>
</Card>
</Areas.Info>
</React.Fragment>
)}
</Composition>
</React.Fragment>
)
}

Callback function with a button

Import the Button component and add it under the position text inside the Info card.

Instead of using a writer prop to change data, we only need to provide the message ID to the callback prop. This will trigger the callback function on the microcontroller when the button is pressed.

import { Button } from '@electricui/components-desktop-blueprint'
<Card>
Position: <Printer accessor="pos"/>
<br/>
<Button callback='home'>
Zero Encoder
</Button>
</Card>

When we click the button on the UI, we can see the value reset!

Making booleans human readable

The push-button information is sent as a bool in the button message ID. This means printing it directly will display a 0 or 1 value instead of readable text.

By using the useHardwareState() hook, we're able to access hardware state information and manipulate it. This example uses a simple conditional to change the value, but any normal Typescript code can be used in this manner.

import { useHardwareState } from '@electricui/components-core'
export const OverviewPage = (props: RouteComponentProps) => {
const encoderPosDS = useMessageDataSource('pos')
const buttonState = useHardwareState('button')
let buttonString = "Unknown"
if(buttonState)
{
buttonString = "Down"
} else {
buttonString = "Up"
}
return (

We can then use our buttonString variable in the UI layout however we like...

Pushbutton: {buttonString}
<br/>
Position: <Printer accessor="pos"/>
<br/>
<Button callback='home'>
Home Encoder
</Button>
push-text

Decoding and Displaying Version Info

Remember the custom type we made for the version information? This requires a few changes which follow the same general process as any other UI side codec.

First, declare it in the /src/application/typedState.ts file

export type VersionInfo = {
major: number
minor: number
}

then write out the codec for the transport-manager to use in /src/transport-manager/config/codecs.tsx.

import { Codec, Message } from '@electricui/core'
import { SmartBuffer } from 'smart-buffer'
import { VersionInfo } from '../../application/typedState'
export class VersionCodec extends Codec<VersionInfo> {
filter(message: Message): boolean {
return message.messageID === 'ver'
}
encode(payload: VersionInfo): never {
throw new Error('The encoder version info is read only.')
}
decode(payload: Buffer) {
const reader = SmartBuffer.fromBuffer(payload)
const settings: VersionInfo = {
major: reader.readUInt8(),
minor: reader.readUInt8(),
}
return settings
}
}

Currently, this custom codec needs to be provided to the serial transport handling code in src/transport-manager/config/serial.tsx. This is replacing the existing template LEDCodec import and usage.

import { VersionCodec } from './codecs'
const customCodecs = [
new VersionCodec(), // Create each instance of the codecs
]

We're aware this process is a bit tedious and a rework is coming with the Device Templates update.

We can now see the structured elements in the Transport Debugger view underneath the ver message ID drop-down,

debugger-structure

Back in the OverviewPage.tsx file, we can display these on the UI if needed. This could be done with a pair of Printer components using accessor syntax or more useHardwareState() hook usage.

<Printer accessor={state => state.ver.major}/>
const vMajor = useHardwareState(state => state.ver.major)

What's Next

This guide should have described how to integrate some more interesting hardware.

final-ui

If you haven't read them already, I strongly recommend reading these guides for understanding how to handle more complicated packet structures as your UI grows in complexity.

We're in the process of re-structuring our docs to ease the progression from quick-start into more advanced topics and would love feedback.