Supplements old keyboards that lack media keys or to provides a more natural interface.
This is a USB HID with a rotary 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.Microcontroller
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)
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 USB.org
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.
Licensing
The hardware is released under CC BY-4.0 and firmware under GPL 3.0.Reference:
HID Usage Tables (.pdf) - USB orgHID Descriptor Tool - create, edit and validate HID Report Descriptors
Microchip forum: HID USB keyboard with MultiMedia key Play / Pause function
Design
I am taking the easy way out using V-USB for ATMega8 and give up on the STM8 USB for now.More pictures in build instructions.
The pads for ISP:
Case and knob construction:
Rotary encoder knob/case from junkYou 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.
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.
A bit of elbow grease with a couple of files: Bastard for most of the removal and smooth for final finish.
I lined up the PCB with the standoffs inside using a flashlight (in the rectangular slot). I marked the spots with a pen for drilling.
I drilled a hole using 1/8" dia. drill bit. The PCB is also the lid for the case. It is a very tight fit and everything lines up nicely.
Assembly: The PCB was tinned and the parts are soldered. The Micro USB breakout board is soldered onto the other side of the PCB. I used 1mm dia drill bit for the header pins.
This is how the back side looks like.
Additional connections
Reset button: USBasp bootloader
Encoder and LED: Preliminary GPIO assignment
Resoldering encoder PCB for reliability: Fixing rotary encoder solder joints
PCB Bring up - basic sanity
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.
USBasp bootloader
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 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_IOPORTNAME D #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). #define BOOTLOADER_ON_RESET 1 #define BOOTLOADER_CAN_EXIT 0 //**** 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 bootloaderconfig.inc 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.
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.
Preliminary GPIO assignment
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 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.
Understanding HID Usage Table
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;
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.
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.
Switch debouncing, double click etc
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.
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.
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:
Using a single momentary switch for multiple function requires a more complex state machine for processing.
// Key press statemachine switch(Sw_State) { case SW_NONE: if(SW_AT_MAKE) { Sw_State = SW_PRESS; Sw_Timer = TIMER_CLICK_MAKE; } break; 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; } 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; break; case SW_DBL_CLICK: // Process double click here Sw_Timer = TIMER_LONG; Sw_State = SW_WAIT; break; // 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; } break; case SW_WAIT: if (SW_AT_BREAK) Sw_State = SW_NONE; break;
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) #define TIMER_CLICK_MAKE ms_TO_TICKS(150) #define TIMER_LONG ms_TO_TICKS(400)
Almost there!
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.
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.
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.
Status
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.
Fixing rotary encoder solder joints
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.
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.
Alternate way of mounting micro USB connector
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.
No comments:
Post a Comment
Note: Only a member of this blog may post a comment.