Once the hard part of the project was done, it was time for the fun part - microcontroller’s firmware development. Because there’s not a lot of stuff to do on the software side and due to available MCU resources, I didn’t need to constrain myself so much on the size optimization side and went with C++14 for the firmware language. After a rough start, I managed to get the first bits working and few weeks later I got a fully functional device - it’s time to wrap it up.
Firmware source code is available under GPLv2 license at github.com/adrian-007/toyota-expansion-board
Parts:
The concept
Every decent software engineer knows that the best way to solve a complex problem is to break it into smaller pieces. Firmware for the project needed to solve a complex problem of converting signals from the remote to impulses that the radio unit could understand. To break this problem into smaller pieces, I arranged the code into several classes that abstracts either a pure hardware (like Two-Wire Interface) or a logical concept (like reading a button state). Let’s go through them and see why they are there and what they do.
DS18B20 - the temperature sensor
Every electronics enthusiast sooner or later gets to know this sensor - it’s a digital temperature sensor working on 1-Wire bus. It’s popular because its affordable, easy to use, quite reliable and provides accurate readings. In order to communicate with the device, firmware implements a logic that works with a single GPIO pin acting as a bus data line. 1-Wire bus, as name suggests, uses only one wire for communication (and another one for ground). Power wire is optional as device has a feature called parasitic power - master device pulls the data line high for a specific period of time in which slave device (DS18B20 in that case) draws the current from it to power itself.
Firmware abstracts sensor handling by providing a DS18B20 class. Since it’s the only device that works on 1-Wire bus, the bus implementation is done inside this class as a handful of private member functions. Class exposes three member functions:
DS18B20::init()
: responsible for detecting the sensor on the bus, reading temperature resolution and ensuring that it is set to 9-bits (I didn’t care for very high accuracy so as a bonus I got just a little bit lower power consumption)DS18B20::poll()
: triggered by a timer interrupt every second, responsible for managing a mini-state machine that toggles temperature conversion request and reading conversion results. New conversion request is made every 5 second (function keep tracks of the ticks since last conversion) - it’s frequent enough to get current temperature, yet not so frequent that could distract me when temperature is toggling between lower and upper bound of given degree.DS18B20::lastTemperatureValue()
: as name suggests, returns last successful temperature conversion result.
SSD1306 - the display
In order to show temperature readings, the device uses a monochromatic 0.9’’ OLED display that communicates with the MCU through I2C (or as Atmel calls it - TWI). This interface uses two wires (SDA - data line, SCL - clock line) to perform communication (four wires when including power lines). Hopefully, ATmega88 has a hardware implementation of this interface, so to start using this interface, the I2C slave device needs to be connected to the right MCU pins and firmware needs to perform a few initialization steps to set up the communication parameters.
In this case, the firmware uses two classes to abstract the hardware and one helper to implement the concept of font:
TWI class
This is a simple wrapper for initialization and communication routines implemented in the MCU. Class exposes the following member functions:
TWI::init()
: sets the bus frequency to 100 kHzTWI::start()
: signals to the slave device the beginning of transmission (acquires the bus). Note that the first sent byte after call to this function must be a slave device address.TWI::write()
: writes a single byte on the bus. Since this is a variadic template function, you may write multiple bytes in one call.TWI::stop()
: signals to the slave device the end of transmission (releases the bus).
SSD1306 class
Abstraction that uses TWI
class to implement communication with the display driver. The screen displays the memory output where each bit represents one pixel - it has 128 visible columns and 8 visible pages, each page having 8 bits (pixels), so in short, it’s a 128x64 pixel screen. In order to improve readability, firmware uses a font that takes 4 pages and 24 columns for each character. To optimize the refresh rates and minimize flicker, the class implements a small buffer that holds last displayed characters and performs comparison to find out which characters needs refreshing.
Public class member functions:
SSD1306::init()
: performs display driver initialization. The process begins with pulling the display’s reset line low for few microseconds and then sending a bunch of configuration parameters (like contrast, orientation, memory mode, etc).SSD1306::clearDisplay()
: writes zeros to display driver’s memory, effectively clearing the output.SSD1306::drawTemp(int8_t)
: converts provided argument (temperature in Celsius) to character representation and updates the screen with new buffer value.
ThermometerFont namespace
Provides character representations needed to display the decimal digits, a degree character and a C for Celsius. Font was generated by GLCD Font Creator and tailored to fit exactly the screen I used for the project - single character is 32-bit high and 24-bit wide. Five characters takes at most 120 columns and 4 pages. The font bytes are placed in program memory using PROGMEM. The namespace also has a member struct Handler
that implements metadata about the font along with helper member functions that helps with getting the particular character raw data or the character’s width. At first I planned to use different fonts for different kinds of information (hence the Handler
structure), but I ended up with just this one and decided to leave it at that.
MCP42100 - the 100k Ohm dual digital potentiometer
Communication with the radio unit goes via analog voltage signals. Easy way to generate those is the resistor ladder, but it comes at a cost - in order to generate a particular voltage, you need multiple MCU pins. With digital potentiometer I could generate 256 distinct voltage values on two independent channels. Since the radio unit connection expects two signals (one purely analog and another one toggled on and off), MCP42100 was the perfect fit. Driving the signal from the firmware has the advantage of being able to compensate for resistance deviations without re-soldering a single resistor - during firmware development it was a big helper.
The device itself is dumb-easy to operate. It works on SPI bus, meaning we need three pins to communicate (MISO, MOSI and CLK) plus one additional pin to toggle the device on and off (CS, or Chip Select pin). Just as with the TWI, ATmega88 also implements SPI in hardware, but there’s a catch. MCU shares SPI pins with a ISP programming interface, so attached device could get in the way of flashing the firmware, rendering the device useless. That’s why we need to attach CS pin to GPIO/RESET pin of ATmega88 - when device is powered on, each GPIO is in high-impedance state. Due to that fact, when CS pin is connected to such output, SPI slave is disabled and won’t talk to MISO/MOSI lines, leaving the programmer in the clear.
The implementations is just a two member functions with a few essential lines:
MCP42100::init()
: sets CS pin to output, initializes SPI bus to of MCU frequency (250 kHz in this case) and shuts down both potentiometer channels.MCP42100::setPOT(uint8_t, uint8_t)
: selects a potentiometer by address and sets the given value.
ADC Buttons - the button handler
This is the most important building block of the project - it encapsulates the whole logic behind interpreting raw button presses and translates it to analog signals to the radio unit. I made a few assumptions and conditions that needs to be met in order to get it working:
- Each physical button must have ability to perform two actions: one on short press (default) and alternate on long press.
- Button presses must be queued, so fast button pressing won’t get dismissed.
- Button state reading must be fast enough to not be noticeable by the user and at the same time enough ADC samples must be collected to be certain which button have been pressed.
- Button actions must cover all essential radio unit functions and provide a seamless experience while driving.
To meet those expectations, the ADCButtons
class does the following:
ADCButtons::init()
performs ADC initialization by enabling conversion on channel 7 with a 62.5 kHz sampling rate and using interrupt to signal conversion results.ADCButtons::newSample(uint16_t)
, called by interrupt, gathers conversion results and depending on internal state:- When pressed button is detected: start gathering samples. Averages conversion results until program gathers
ADCButtons::maxSampleCount()
samples. Function saves only results that are within range of any defined button. - When button is released and up to this call instance was gathering samples: determines if we have at least
ADCButtons::minSampleCount()
samples and if that is true then determines which button was pressed, picks an action depending on sample duration and pushedButtonPOTEvent
to the queue.
- When pressed button is detected: start gathering samples. Averages conversion results until program gathers
ADCButtons::poll()
, called by timer interrupt every 10 millisecond, updates sampling time (if sampling is in progress), evaluates event queue for button presses and if there is an active event, sets a POT value according to button action definition. Until action expires, no other POT value can be set (events are processed one at a time).
To make it easy to maintain button definitions and corresponding actions, the class has helper member objects:
Button enumeration
Enumeration that defines a virtual button that performs a certain action in the radio unit. It may be vendor specific, but in my case, Pioneer defined common set of actions to control the unit, like navigating up/down, volume change, selecting audio source or answering/hanging up a call. In total, there are twelve virtual buttons defined, plus extra one to define None
button.
ButtonInfo structure
Structure is used to define a physical button on the steering wheel remote. Members defines following properties:
button
: (virtual) button type.minSample
: lower bound of ADC conversion result range that can be interpreted as button being pressed.maxSample
: upper bound of ADC conversion result range that can be interpreted as button being pressed.alternateButton
: alternative (virtual) button type, used when physical button long press is detected.
ButtonPOTInfo structure
Structure defines the properties of action that must be performed when corresponding button was pressed:
button
: (virtual) button type.pot
: potentiometer type, corresponds to either aRing
or aTip
of the radio unit wired remote connector.potValue
: the numeric value in range of 0-255 that a potentiometer should be set to.durationType
: type of duration which potentiometer value should be set for. It’s eitherShort
orVariable
duration type.
ButtonPOTEvent structure
Structure used by event queue to define even properties:
button
: (virtual) button type.pot
: potentiometer type (same asButtonPOTInfo::pot
)potValue
: the numeric value in range of 0-255 that a potentiometer should be set to.duration
: time (in milliseconds) which potentiometer value should be set for.elapsed
: time (in milliseconds) which potentiometer value is being set for.
Putting it all together
The glue that holds it all together is in main.cpp
- it defines a main program loop, watchdog and timers initialization, and interrupt functions. Unfortunately interrupt handlers cannot be defines inside a C++ static member function and I wanted to avoid scattering interrupt handlers in multiple files, so they’re gathered in one place.
Once the setup is done, the main program loop performs one simple task - it gets the last temperature reading and passes it down to display driver for drawing. It does that every 200 milliseconds.
Final thoughts
The first idea for this device was to just create a resistor ladder inside stock remote case, wire it directly to the radio unit and be happy with six button functions. Then it evolved into having more functions out of six physical buttons - hence the ATmega88. Once I got the MCU into play, why not display the temperature readings - the stock radio unit had it, why can’t I?… I had a bunch other ideas that this device could do, but I had to stop somewhere, learning from my mistakes: instead of doing it all at once, do it in small steps, in an iterative fashion. It works for software development, it should work with hardware too, and I’m glad I did it this way. For once I managed to get the project from the concept stage to the final product - and I had a great time doing it. I managed to pull off SMD components in my design (laminating machine makes it super easy to print circuit boards), learn a bit more about electronic design and, most importantly, create a practical, working device that I use almost every day.