HID Multimedia Dial

Supplements old keyboards that lack media keys or to provides a more natural interface.

Similar projects worth following
This is a USB HID with a rotory encoder. It is used to supplement old keyboard layouts by providing a more natural alternative. It does a bit more than just a volume knob as the dial can change operating mode for media player control.

This project was initially inspired by the Microsoft Surface Dial, but has evolved towards supplementing a dial for multimedia purposes. Sometime Youtube video or new video game default volume setting is set too high. In Windows, the access to the volume control on the menu bar can takes seconds. It would be nice to have a physical interface that is accessible at all time.

The theme of this project is try to make use of recycled materials or old forgotten parts as much as possible.  There is a bit of physical construction by hand while trying for a more polished look.

Control Mapping

The idea is to map the media keys functions into the very few controls for a rotary encoder in an intuitive manner. The LED provides visual clue to the operating mode of the device while doubling clicking is used to switch between them.  I keep the LED subtle and to not distract from the media I am watching.


I have considered a few options, but I am going to be using old Microchip/Atmel ATMega8 using firmware only USB implementation V-USB.  I  have some of the leftover parts before I went with ARM and others.

GPIO Port Match interrupts are used to read the rotatory encoder.  I am going to be porting my code from Snooping PS/2 keystrokes for hotkey - part 2 for detecting double clicking/hold timing.

USB Communication

The dial acts as a USB HID, so there are operating system level device driver support in place. The dial communicates with the PC using HID reports. The USB standard defines device Usage Tables that describes the formatting of the data packets for the device that the operating system use for parsing. 

There are additional plug-in/key mapper for media players which are outside of the scope of this project.  

Volume control falls under HID Consumer page (0x0C) in HID Usage Tables (.pdf) 

The rest of the functions are under transport controls:

Note: OOC = On/Off Control, OSC = One Shot Control, RTC = ReTrigger Control (auto repeat)

The hardest part of this project is coming up with a HID report table.  There are different ways for implementing this, and also a lot more ways of not working.  I modified the HID report table from Microchip forum: HID USB keyboard with MultiMedia key Play / Pause function using HID Descriptor Tool from 

This usage table uses a 7-bit bitmap to represent the commands that are mapped in the dial. A different style of table could be used to transmit the commands as 16-bit usage ID which is more for a larger set of commands such as IR receiver.

Variations/Future Expansion

The current code size is:

Program Memory Usage : 2340 bytes   28.6 % Full
Data Memory Usage : 68 bytes   6.6 % Full

Even with the 2kB for the USBasp bootloader, there are still 3.5kB of code space left for additional features.  e.g. NEC IR Remote Protocol to HID Consumer Control

The GPIO assignments are done with future expansion in mind.  The SPI, 2-wire, serial, timers 1-2, ADC, and analog comparator are available.


The hardware is released under CC BY-4.0 and firmware under GPL 3.0.

HID Usage Tables (.pdf) - USB org
HID Descriptor Tool - create, edit and validate HID Report Descriptors
Microchip forum: HID USB keyboard with MultiMedia key Play / Pause function

View all 6 components

  • Alternate way of mounting micro USB connector

    K.C. Lee09/14/2017 at 18:06 0 comments

    I have initially looked at surface mounting the USB breakout PCB to the main PCB.  It might seem a minor thing, but there are some tradeoffs to long term reliability.  The home made PCB doesn't have through hole plating nor covered by soldermask, so the solder pads and traces could be peeled off more easily due to external tensional stress and shear stress.  Replacing the USB breakout PCB is also going to be a bit more difficult.

    I have decided to surface mount the PCB anyways.  I left the through hole pins in as they help to anchor the board and conduct heat from the top to the solder joint between the two PCB which is hard to get at with a soldering iron.

    The pins also help to reduce shear stress on the solder joints as the connector is attached/detached.  I glued a small sheet of clear plastic packing onto the PCB to insulate the pins from any conductive junk I might have lying around.

    The top part of the PCB is resting against the case which reduces the amount of flexing (tensional stress) on the breakout PCB if it is dropped.  I added a piece of packaging foam which helps to seal the opening and keep dust out.

    I think it looks a bit better this way.

  • Fixing rotary encoder solder joints

    K.C. Lee09/10/2017 at 20:25 0 comments

    I noticed some erratic behavior on the momentary switch.  It turns out to be a solder joint issue on the encoder PCB.  The two solder joints for the switch developed a crack.

    Here is a closer look:

    Look closely between the PCB and the encoder, there is a tiny gap on the right hand side.  The right side is supported by the pins instead of the PCB.  As the momentary switch is pressed/released, the force get transmitted onto the solder joints eventually causing a fatigue.  A double side PCB could have helped a bit, but it is only a single side one.

    The way of fixing it is simple.  Desolder the connections and resolder the encoder while it is mounted flush with the PCB so that it  is fully supported.

    I actually tried to buy encoders PCB with the hex nut, but the reseller did a "bait and switch" on me and sold me ones without.

  • Project milestone

    K.C. Lee09/01/2017 at 15:49 0 comments

    A demo has been uploaded on youtube.   It is just a quick demo showing the features.  It is a dozer and not a sit down and enjoy the movie kind of a deal.  I used my DVD for the demo. The dial works much better skipping tracks in chaptered movies stored on HDD or the network.

    Firmware files have been pushed to my project page on github.  

    This project is now in the completed state.  There are still lots of memory and peripheral left for additional features.  You are welcome to fork the project.

  • Almost there!

    K.C. Lee09/01/2017 at 01:59 0 comments

    I have recoded the HID based on the discussion I read on Microchip forum.  Things gets a bit more complicated as I am using different HID messages for the commands. The messages also requires sending a break event.

    Since there are now two streams of commands from the encoder and from the switch, I cheated a bit to add a queuing mechanism so the two stream can take turns sending commands.

    For the switch  in the switch state machine, I delayed a couple of state transitions until V-USB is ready for the next command.  The timer threshold expired at that point and does not relies on the current state of the switch.  The polling is done at each call to the state machine task and does not interfere with other tasks that needs to be run under the same loop.  Coding in state machines can be a bit of a pain for some codes, but they are useful for situations that need a simple of running a few tasks.

    For the encoder, a sequence of increment or decrement commands is now sent instead of a single delta value previously.  The delta value can accumulate between updates and drain as reports are sent.  The delta acts as a queue.

    I logged the USB packets with a trial version of USBlyzer.  It confirms that I am sending the right HID commands, but not recognized by any of my PC running XP, Win 7 or Win 10.  The code is mostly working and the mode switching via double click also works toggles the use of the encoder between track select and volume control.

    There are 2 outstanding Interrupt In requests at the beginning and this resulted in the funny Elapsed time in the log.  I'll need to do some more thinking looking at the initial state and state transitions.  Really missing not having a decent hardware debugger for source code level and register level debugging on the ATMega8.  That's the reason why I mostly play with ARM and STM8 these days.

    After turning the dial a bit etc., it starts to work in Windows 10. Windows 10 recognizes the volume control and MPC-HC recognizes the Play/Pause, Next Track/Previous Track and Stop.    I'll also need to increase the time threshold for long click.  Almost there.

    As for Windows 7, I guess I wasn't thinking.  I RDP into Windows 7 from my new PC which means the (HDMI) audio is non-local.  (It would have worked had I plugged the dial into the new PC as Windows redirects the HID messages.)  After switching to that PC via my Low Cost KVM Switch, the volume control starts to work.   It works in Win XP too. The dial is now connected to the KVM so it should work. :) 

    They use a green pop up volume control for win 7. 

    It works in Linux too.

    Found the issue that delays the dial until after a few turns.  It turns out that I should have read the documentation before cutting/pasting my old code.

    #define usbInterruptIsReady()   (usbTxLen1 & 0x10)
    /* This macro indicates whether the last interrupt message has already been
     * sent. If you set a new interrupt message before the old was sent, the
     * message already buffered will be lost.

    This macro is needed for polling after sending the first packet.  The code can send the first one without checking.  So that's why my code was waiting.  After changing a few line, the dial now works for all my Windows boxes right after plugging in.

    Now I'll need to make some popcorn and run some real life test watching a few movies etc.  :) A long weekend is coming up next.

  • Switch debouncing, double click etc

    K.C. Lee08/31/2017 at 13:19 0 comments

    I added in some capacitors for filtering glitches, but the filtering is only good for removing glitches below their RC time constant.  For R=10K, C = 0.1uF the time constant is about 1ms.

    This is what a glitch looks like (without the filter)  For the rotary encoder, the debouncing is inherent in the state machine as they are often invalid state transitions.

    The momentary switch requires software debouncing.  Since it is a single switch, the debouncing can be implemented as a digital glitch filter in software.  The following code implements a shift register that takes snapshots of the input signal every 16.38ms.  It's like having a scope trace of the input signal.

    Switch_Status = Switch_Status<<1;
      Switch_Status |= 1;

     The make/break event can be detected by comparing the register with the following constants.  

    // Switch status is a shift register that shift left every 16.38ms
    #define SW_RISING_EDGE        0x07    // 00000111  <- turned high for 49ms 
    #define SW_FALLING_EDGE       0xf8    // 11111000  <- turned low for 49ms 
    #define SW_EDGE_MASK          0x0f
    #define SW_DEBOUNCE_MASK      0x03

    SW_EDGE_MASK can be used for edge detection, while SW_DEBOUNCE_MASK can be used to look at the debounced signal.

    Here are the macros for detecting these events:

    #define SW_AT_MAKE  ((Switch_Status&SW_EDGE_MASK)==(SW_FALLING_EDGE&SW_EDGE_MASK))

    Using a single momentary switch for multiple function requires a more complex state machine for processing.

    // Key press statemachine
      case SW_NONE:
           Sw_State = SW_PRESS;
           Sw_Timer = TIMER_CLICK_MAKE;
      case SW_PRESS:
        if(!Sw_Timer)  // Double click timesout -> Pressed
          Sw_State = SW_HOLD;
          Sw_Timer = TIMER_LONG;
          // Process Normal click here
         else if (SW_AT_BREAK)
           Sw_State = SW_DBL_BREAK;
           Sw_Timer = TIMER_DBL_BREAK;
      case SW_DBL_BREAK:
        if(!Sw_Timer)   // break is too long, treat it as no pressed
          Sw_State = SW_NONE;
        else if(SW_AT_MAKE)
          Sw_State = SW_DBL_CLICK; 
      case SW_DBL_CLICK:
        // Process double click here
        Sw_Timer = TIMER_LONG;
        Sw_State = SW_WAIT;        
    // Keypress statemachine 
     case SW_HOLD:
        if (SW_AT_BREAK)
          Sw_State = SW_NONE;
        else if(!Sw_Timer)
          // Process Long press here
          Sw_State = SW_WAIT; 
      case SW_WAIT:
        if (SW_AT_BREAK)
          Sw_State = SW_NONE;

     Most of the complexity is in measuring the switch timing and try to determine if it is a double click, normal click or a long click.  

    • If there is a break before reaching the normal click threshold, then the state machine will look for a double click event.
    • Once the time threshold has reached, it is treated as a normal click event.  If the switch is still press after a long click threshold, then the action for normal click needs to be reversed some how.  The switch functions are picked to make this possible.

    There is a bit of compromise coming up with the timing for double click vs normal click as there is no way the uC can predict the user action ahead of time.  If the code waits too long for a double click, then the normal click response will be sluggish.  I have empirically determined the following for myself. TIMER_CLICK_MAKE needs to be tweaked for someone else.

    #define TIMER_DBL_BREAK ms_TO_TICKS(250)...
    Read more »

  • Understanding HID Usage Table

    K.C. Lee08/30/2017 at 22:09 0 comments

    HID usage table is a data structure that tells the OS how to parse the raw HID data that your device is sending.  Here are the HID Usage Page and Usage supported under Windows.

    Here is my minimalistic HID usage table for the volume control.

    const PROGMEM char usbHidReportDescriptor[] =
        0x05, 0x0c,                    // USAGE_PAGE (Consumer Devices)
        0x09, 0x01,                    //   USAGE (Consumer Control)
        0xa1, 0x01,                    // COLLECTION (Application)
        0x85, HID_REPORT_VOLUME,       //   REPORT_ID (1)
        0x09, 0xe0,                    //   USAGE (Volume)
        0x15, 0x81,                    //   LOGICAL_MINIMUM (-127)
        0x25, 0x7f,                    //   LOGICAL_MAXIMUM (127)
        0x95, 0x01,                    //   REPORT_COUNT (1)
        0x75, 0x08,                    //   REPORT_SIZE (8)
        0x81, 0x06,                    //   INPUT (Data,Var,Rel)
        0xc0                           // END_COLLECTION
    • Windows only supports the following USAGE in page 0x0c.

    • REPORT_ID is a user defined tag that identifies the packet 
    • USAGE (Volume) tells the OS what the data is for.  
    • LOGICAL_MINIMUM, LOGICAL_MAXIMUM specifies the bounds of the raw data as -127 to 127.
    • REPORT_COUNT, REPORT_SIZE tells the OS the data: 8 bit data x quantity 1.
    • INPUT (Data,Var,Rel) tells the OS that the data is relative to current value.

    When the uC detects a new value for volume, it starts an interrupt packet tagged with the HID_REPORT_VOLUME and the raw data.

              ReportIn[0] = HID_REPORT_VOLUME;
              ReportIn[1] = Encoder;
              ReportSize = 2;

    In windows 10, the volume control pops up in responding to the dial.  Windows 7 however doesn't.  I can't find the details for the exact commands that Windows supports.  

    I am guessing that it might not support USAGE (Volume) and only Volume Increment/Decrement.  I guess I'll have to modify my code.

    I found the list of supported usage ID that are used for multimedia keyboards.  This means that those  subset are supported across multiple OS.  Additional usage ID might be supported by each.

    The original document: In case they change their URL (which they do a lot), google for "USB HID to PS/2 Scan Code Translation Table".

    Useful links:
    Microchip forum: HID USB keyboard with MultiMedia key Play / Pause function

    Note: The table is incorrect: REPORT_ID has to be placed after Collection (Application) for Windows 7/Windows 10.

    Microchip forum: mute on consumer control  - mute only function as toggle which is fine for this project.

    Thanks to the Microchip forum, here is the working HID table I am using.

  • Preliminary GPIO assignment

    K.C. Lee08/30/2017 at 02:40 0 comments

    The preliminary GPIO assignment is as follows.

    I tried to free up the I/O for peripherals such as SPI, 2-wire, serial, timers for future expansions.  ATMega8 unlike the newer chips does not have port change interrupts, the AIN0 could be used as additional interrupt line.

    I wired up the signals to the encoder and LED's.  I use dental floss lacing for cable management.

    I redid the wiring to the LED as they have to work at 3.3V.  The Amber LED's require 7.77mA of current while the Aqua green LED requires 0.38mA.

  • USBasp bootloader

    K.C. Lee08/29/2017 at 19:15 0 comments

    To be honest, I haven't play with bootloader because most of the time I run low on memory space or have proper debug/programming connector for my projects.  This time around I am using USBaspLoader so I don't have to solder a bunch of wires each time I upgrade the firmware.

    The loader comes with makefile for setting things up if command line is your thing, but I kind of like using an IDE.    The bootloader requires 2kB and sits at an offset of 0xc00 words leaving 6kB for the application code which is plenty for this project.  I set the optimization level to -Os (for size).

    I tell the linker to start at .text=0xc00  by adding an entry in "FLASH segment".

    I added the following files to the project build.  It took me a few tries because I am not using the included makefile or have read the fine prints. It turns out that some functions in usbdrv.c is already inside main.c

    The file: bootloaderconfig.h  customizes the bootloader.  I have set the following to trim the code size to under 2kB.  The reset button is a nice way of entering the bootloader without tying up an I/O.

    // Required: port and bits used for both USB data lines (D+ must also connect to INT0)
    #define USB_CFG_DMINUS_BIT  2
    #define USB_CFG_DPLUS_BIT   4
    // Nothing more is required in this file. Everything else is optional and customizes options.
    // Without any configuration options below this, the bootloader will run after any kind of reset
    // and wait indefinitely until avrdude connects. Once avrdude disconnects, the user
    // program gets run. Override by copying configuration lines from bootloaderconfig-palette.h
    // Bootloader runs only when reset is triggered externally (e.g. a reset button).
    //**** Code size reduction
    // Least-important features listed first
    //#define HAVE_READ_LOCK_FUSE         0 // Disable read fuse bytes
    #define HAVE_FLASH_BYTE_READACCESS  0 // Disable read individual flash bytes
    //#define HAVE_EEPROM_BYTE_ACCESS     0 // Disable read/write individual eeprom bytes
    #define HAVE_EEPROM_PAGED_ACCESS    0 // Disable upload/download eeprom
    //#define HAVE_FLASH_PAGED_READ       0 // Disable download flash
    // move into here
    #define F_CPU 16000000
    #define BOOTLOADER_ADDRESS 0x1800

    The bootloader takes up 2024 bytes for 12MHz and 1974 bytes for 16MHz becauses of different bitbanging USB code involved.

    I used my programmer to program in the bootloader.  The fuses settings are as follows:

    I drill a hole between the two mounting holes with a 9/64" (3.6mm) dia. drill bit. The vertical column of contacts are connected together as a lead frame.  I bent the pins of a push button back.  On the right side, I bent them a bit off to the side to reach the new pad that I'll have to make by cutting a flipped C shape island on the PCB with a box cutter.  After the initial cut through the copper coil, I rotated the knife by an angle to follow the cut which helps to widen the trench.  I check the new pad with a multimeter to make sure that the cut area is isolated.

    I soldered the push button and connect the wire to the /Reset line.  

    This is how it looks on the back side. 

    The push button when pushed grounds the /Reset line and starts the USBasp bootloader. After programming, the normal operation is restored by unplugging/replugging the USB A connector on the other end of the cable.

  • PCB Bring up - basic sanity

    K.C. Lee08/28/2017 at 03:14 0 comments

    I soldered up the ISP connector to the board.  It is sending back the right AVR signature.

    I was scared a little bit there when the chip stopped responding after I programmed in the fuses.  It turns out that I programmed in the wrong value  for CKSEL and the chip was trying to look for external RC oscillator instead of crystal.  Thankfully one of my multimeter has a square wave output, so I injected that to the XTAL1 and that gets me back to programming the right bits for external crystal. 

    I like these tiny crystals as they are cheap, take up less space, shorter clock tracks and easier to route. I have left CKOPT = 1 (i.e. fuse unprogrammed) for a lower swing as the tiny crystal probably has a low drive.  That seems to work. (ATMega8A is rated for 10MHz at 3.3V and CKOPT is only for up to 8MHz crystals.)

    I have a hard time getting this PCB to work.  So it turns out the Micro USB breakout board has a cracked right between the trace and the through hole pad.   This is why they put in teardrop. I probably broke it while flexing the PCB during fitting. 

    After replacing the breakout board, a test firmware(from a different project) shows up correctly.  Now I have to sit down and code the firmware.

  • Update

    K.C. Lee08/26/2017 at 20:19 0 comments

    I am taking the easy way out using V-USB for ATMega8 and give up on the STM8 USB for now.  

    It is a single sided PCB with 12 mil tracks/spacing for toner transfer.  I made breakouts for most of the I/O as it didn't take too much additional work and the PCB feels kind of empty.  The rectangular slot is for visual alignment for drilling a couple of mounting holes.

    More pictures in build instructions.

    The pads for ISP:

View all 14 project logs

  • 1
    Step 1

    Case and knob construction: 
    You might also find somethings else that can be used and be creative. e.g. vapor rub can make a nice attractive case + dial.  

    I added a couple of 4-40 standoffs from an old DB9 connector.

    I modified the encoder by adding a 10K pull up at R1 on the switch.  I also added 0.1uF caps to all of the encoder outputs for debouncing.

  • 2
    Step 2

    The single sided PCB was made using toner transfer.  It was etched using a tablespoon of Ferric Chloride for about 25 minutes in a heated bath. 

  • 3
    Step 3

    I trimmed the outline with a pair of shear.  This helps to save a lot of filing work.

View all 10 instructions

Enjoy this project?



triton9000 wrote 09/11/2017 at 23:23 point

Excellent project!!! The cap-dial is great...this is exactly how I do things.  Once I get back from vacation, this is one of the first things I am going to build.  If you are interested, send me a private message and I will 3D print some knobs and enclosures and send them out to you, with the files to add to your project. Your hard work definitely needs its own enclosure.  Keep it up...

  Are you sure? yes | no

alan_r_cam wrote 09/03/2017 at 10:03 point

This should work with SDR apps as well.

You can change the panadaptor (waterfall) frequency, or change the specific frequency for reception, increase / decrease frequency step size, etc.

  Are you sure? yes | no

Similar Projects

Does this project spark your interest?

Become a member to follow this project and never miss any updates