USB volume control with a PIC18F4550
As a fun exercise I decided to build a USB volume control device using a rotary encoder and a PIC18F4550 microcontroller. My keyboard of choice doesn't include volume control facilities, so I might rig one of those up with this device some time; the keyboard customization project's on the backburner at the moment though. This project was quite simple to implement using the experience gained in the Sun keyboard project, though this time I decided to build it on Microchip's own USB framework as it's more advanced (and more complicated) than the one used in that project. I put up this small report to record the process involved for future reference, though it might of course be useful to you, the reader, too. I won't be putting up source code for the total project, as the specifics are rather dependent on the USB framework used and exact model of microcontroller; instead, this page contains the steps and code involved in getting this all to work.

The prottype setup.
Hardware
The hardware I'm using is a PIC18F4550 microcontroller; it comes with built-in USB support and mine runs at 48MHz. As can be seen in the picture, I built an experiment board for mine. The device sits on a nice little PCB, which is connected to another board with screw terminals and a self-designed board that includes a push button (for bootloader support), USB header and serial port. The prefabricated PCBs I got at Van Ooijen Technische Informatica, definitely a recommendation if you're looking for microcontroller project components in the Netherlands.
To actually control the volume, I'm using a rotary encoder. A rotary encoder is a dial which can be turned indefinitely; as it doesn't have a start and end position, it transmits the direction in which it was turned as opposed to a value indicating the position. The Wikipedia page on rotary encoders has some great information on it. Simply put, the encoder sets a two-bit code on its output pins. Comparing the code with the previous one (which is saved) allows one to determine the direction in which the encoder was turned. The control I'm using can only be turned, there's also versions that can be pushed, which would make for a great way to implement muting functionality.
The encoder is hooked up to the PIC's RB4 and RB5 pins. These pins can be configured to trigger an interrupt on change (so h-l and l-h transitions), exactly what's needed to easily read the encoder. The USB header is hooked up to the controller's power pins (so it's powered over USB) and of course its USB data pins.
Preparation
As it had been a while since I'd played with the PIC stuff, I began by reinstalling MPLAB and the PIC18 C compiler. Next was the Microchip USB Framework (2.4); when looking for this, be careful as it's easy to end up on the download pages for aged versions. This framework is quite involved, but it does include a number of nice examples; most common device types can be made by just working from one of the demo projects. One of the examples is extremely handy in particular: it's an USB bootloader (MCHPUSB). Installing this allows one to program the PIC over an USB connection, which is vastly preferrable to messing around with a real programmer. It even comes with Vista 64 drivers nowadays. There's also a HID bootloader which doesn't need drivers at all, but I haven't personally used it. Code used with these bootloaders requires some trivial modifications, but the USB framework code automatically incorporates these by setting a simple #define.
USB volume control
Like many other USB input devices, volume control is usually implemented as a HID (human interface device). USB volume controls are rather common: multimedia keyboards are multi-function devices that implement both a keyboard and a volume control device, sometimes a system control (shutdown/standby) one too. As it's a common HID, all major operating systems support volume control out of the box.
HID devices identify themselves to the host PC by sending a descriptor. This descriptor includes information on the device type, the type of buttons and dials it offers, the values those set in which data structures, etc. HID descriptors are somewhat vague in my opinion, and creating one from scratch to match an operating system's idea of how it should act is difficult. Fortunately, the HID spec comes with many examples, and even better, Microsoft's site details the descriptors expected by Windows (which work with Linux too). I decided to use the second of the two descriptors, which uses seperate push buttons for volume up and down. The first descriptor is mostly similar, but instead uses a value of -1 for volume down and +1 for volume up. In testing, I tried both, and although they're both simple to use with the rotary encoder, I opted to go for the seperate buttons and have each encoder turning direction simulate a button press.
The Microsoft descriptor is useful, but unfortunately it doesn't come with the numeric values needed when using the descriptor in an actual device. Fortunately, the USB Implementers Forum provides a tool which vastly simplifies HID creation: I recreated the descriptor in this tool and exported it as a C array, ready to be used in the code.

The descriptor in the HID tool.
Simply put, this descriptor defines the following:
- A Consumer Device type device, and a Consumer Control one in particular
- A collection of data (i.e. bits in a byte array) which consists of:
- Volume up and down, one bit each
- A mute button, one bit
- Five bits of padding to get a full byte
As this descriptor only describes IN data (= to the PC) and all the used bits total one byte, a single byte gets transferred from the device to the PC when it's polled. In this byte, the least significant bit, bit 0, is the state of the volume up button, bit 1 is the volume down button, and bit 2 is the mute button. Incidentally, Windows mutes the volume if any other bit is 1 too. Although this isn't immediately clear from the descriptor, volume up and down auto-repeat. This means that if Windows polls the device a number of times, and the button is read as pressed, it will keep incrementing the volume until it reads the button as being released. The mute button doesn't repeat and operates in a relative fashion: press it once to mute, and a second time to unmute. Holding it (i.e. the device keeps sending '1' for the mute bit) doesn't do anything. USB descriptors are generally rather unclear; it's best to just follow operating system (i.e. Microsoft's) guidelines.
The mouse HID example project
After programming the chip with the bootloader, I set out to get it to run the Mouse example that comes with the USB framework. I copied the files from the "USB Device - HID - Mouse\HID - Mouse - Firmware" directory to a new folder and opened the PICDEM FSUSB version of the project; my experiment board is for all intents and purposes the same as this Microchip offering. After re-locating some files and setting proper include paths for the project, it built.
To get it to work with the non-HID (MCHPUSB) boatloader, I replaced the project's linker script with the correct one, and switched the project to use that bootloader type in the PICDEM FSUSB hardware profile header. After rebuilding and programming, the mouse example worked fine and moved the Windows cursor in a circle upon being plugged in.
As the mouse sample, and as such the USB functionality, worked, I removed the code for this and various buttons/LEDs used by the sample. These, and most further modifications, happened in the mouse.c file, which is the meat of the device-level, non-framework code. This cleaned up device code could then be modified for the volume control application.
Building on the mouse example
The next step was to get the PC's operation system to recognize a volume control. To this end, the discussed HID report descriptor was put in usb_descriptors.c, replacing the mouse one. Additionally, the configuration descriptor was updated to reflect the new size of the HID descriptor, as was the HID_RPT_01 definition in usb_config.h. For some reason the configuration descriptor doesn't use this definition to get the size, though that's obviously easy to change. The endpoint 0 out size was changed from 3 bytes to 1, reflecting the smaller amount of data sent by the volume control. After reprogramming the chip, Windows still showed a mouse in the device manager, albeit with an exclamation mark. This was because the now different device still had the same vendor/device IDs. Uninstalling the device and resetting the chip fixed this, and a (obviously nonfunctional) consumer control device appeared.
$source = <<<___CODE //USB_DESCRIPTORS.C /* Configuration 1 Descriptor */ ROM BYTE configDescriptor1[]={ ... /* HID Class-Specific Descriptor */ 0x09,//sizeof(USB_HID_DSC)+3, // Size of this descriptor in bytes RRoj hack DSC_HID, // HID descriptor type DESC_CONFIG_WORD(0x0111), // HID Spec Release Number in BCD format (1.11) 0x00, // Country Code (0x00 for Not supported) HID_NUM_OF_DSC, // Number of class descriptors, see usbcfg.h DSC_RPT, // Report descriptor type DESC_CONFIG_WORD(31), //sizeof(hid_rpt01), // Size of the report descriptor ... }; ___CODE; $language = 'C'; $geshi = new GeSHi($source, $language); echo $geshi->parse_code(); ?> $source = <<<___CODE //USB_DESCRIPTORS.C ROM struct{BYTE report[HID_RPT01_SIZE];}hid_rpt01={ { 0x05, 0x0c, // USAGE_PAGE (Consumer Devices) 0x09, 0x01, // USAGE (Consumer Control) 0xa1, 0x01, // COLLECTION (Application) 0x15, 0x00, // LOGICAL_MINIMUM (0) 0x25, 0x01, // LOGICAL_MAXIMUM (1) 0x09, 0xe9, // USAGE (Volume Up) 0x09, 0xea, // USAGE (Volume Down) 0x75, 0x01, // REPORT_SIZE (1) 0x95, 0x02, // REPORT_COUNT (2) 0x81, 0x06, // INPUT (Data,Var,Rel) 0x09, 0xe2, // USAGE (Mute) 0x95, 0x01, // REPORT_COUNT (1) 0x81, 0x06, // INPUT (Data,Var,Rel) 0x95, 0x05, // REPORT_COUNT (5) 0x81, 0x07, // INPUT (Cnst,Var,Rel) 0xc0 // END_COLLECTION } }; ___CODE; $language = 'C'; $geshi = new GeSHi($source, $language); echo $geshi->parse_code(); ?> $source = <<<___CODE //USB_CONFIG.H #define HID_INT_OUT_EP_SIZE 1 #define HID_RPT01_SIZE 31 ___CODE; $language = 'C'; $geshi = new GeSHi($source, $language); echo $geshi->parse_code(); ?>Next up was the rotary encoder input code, which I had tested seperately. It uses the high-priority port B interrupts. To get all of this to work, first the device initialization code had to be rewritten:
$source = <<<___CODE //MOUSE.C void UserInit(void) { PORTB=0x00; //Clear port b TRISB=0b00110000; //Set RB4,5 as inputs //Port B interrupts INTCONbits.RBIE=1; //Enable port B interrupts; interrupt flag is not cleared so interrupt is triggered instantly, allowing us to record initial value INTCONbits.GIE=1; //Enable general interrupts //Port B pullups INTCON2bits.RBPU=0; lastTransmission = 0; }//end UserInit ___CODE; $language = 'C'; $geshi = new GeSHi($source, $language); echo $geshi->parse_code(); ?>The framework's mouse.c already includes interrupt routines, so the one for reading the encoder was easily slotted in.
$source = <<<___CODE //MOUSE.C void YourHighPriorityISRCode() { //Read rotary encoder if(INTCONbits.RBIF==1) { static unsigned char prevState=0xFF; unsigned char state = PORTBbits.RB4 | PORTBbits.RB5<<1; //Get value 0-3 from rotary encoder bits if(prevState != 0xFF)//If this is not the first time we enter the interrupt, process the gray codes { if(prevState == 0b00 && state == 0b01 //Turn counterclockwise || prevState == 0b01 && state == 0b11 || prevState == 0b11 && state == 0b10 || prevState == 0b10 && state == 0b00) { hid_report_in[0] = 0b000000010; //Volume down } else if(prevState == 0b00 && state == 0b10 //Turn clockwise || prevState == 0b10 && state == 0b11 || prevState == 0b11 && state == 0b01 || prevState == 0b01 && state == 0b00) { hid_report_in[0] = 0b000000001; //Volume up } } prevState = state; //Save previous port b state. INTCONbits.RBIF=0; //Clear port B interrupts flag } } ___CODE; $language = 'C'; $geshi = new GeSHi($source, $language); echo $geshi->parse_code(); ?>Finally, IO the processing function was updated to send the volume control info. Note that each volume change is sent three times, for increased sensitivity.
$source = <<<___CODE //MOUSE.C void ProcessIO(void) { static unsigned char ticks=0; // User Application USB tasks if((USBDeviceState < CONFIGURED_STATE)||(USBSuspendControl==1)) return; if(!HIDTxHandleBusy(lastTransmission)) { lastTransmission = HIDTxPacket(HID_EP, (BYTE*)hid_report_in, 0x01); //After sending the volume change three times, clear it. ticks++; if(ticks==3) hid_report_in[0]=ticks=0; } return; }//end ProcessIO ___CODE; $language = 'C'; $geshi = new GeSHi($source, $language); echo $geshi->parse_code(); ?>Room for improvement
Although this volume control implementation works just fine, and on all consumer control aware operating systems, there's some improvements to be made:
- Debouncing. The current rotary encoder code doesn't incorporate debouncing. As such, when spinning it very fast, the volume will sometimes make a slight step in the opposite direction. The same can be observed with some wheel mice. In practice, this is hardly a problem.
- Volume mute. A rotary encoder that can be pressed can easily be used to also update and send the control's mute information.
- Sleep/wakeup. The USB framework includes facilities to have the microcontroller sleep when the system's in standby, and USB devices can be made to wake up the host (for example in this case when the encoder is pressed).
Finally, I still haven't crammed the thing into a keyboard yet; not sure if I can bear drilling a hole into my vintage AT101W either.