Back to Hardware

Etch-a-Sketch USB Mouse

18 Mar 2016
Progress: Complete

This project log is intended to be less wordy and more video-y.

I thought I'd build an etch-a-sketch style input to control a computer, as a USB mouse. I'm going to use the rotary encoders I have left over from the Midi Interceptor project.

Simple rotary encoders are cheap and fun

Unfortunately these are clicky as you rotate them, but that can correspond to the discreteness of the pixels on the display. I had thought about using stepper motors as encoders but ended up thinking they wouldn't be as precise as their smooth, damped rotation suggested they should be. The illusion of higher resolution than reality. Also it seems a waste to use a stepper motor for anything non-robotic.

The encoders output a 2-bit gray code. This just means two pulses that overlap each other, and from that we can deduce both direction and speed of rotation, since it steps through a complete cycle of the gray code in just one click of rotation.

Unfortunately just wiring this up to the chip isn't going to work because we don't have enough pins. By using the technique given in the EasyLogger example we can calibrate the internal oscillator/PLL to 16.5MHz from the USB frame length, which means we don't need a crystal, but that still leaves us just three IO pins to read our encoders. Not much of a matrix can be made with just three pins.

Much like the keypad for my Precision Clock Mk I, we can use various resistors to turn multiple switches into a single analogue reading. But with a keypad we don't generally care about multiple buttons at once.

Simply pulling down/pulling up using the encoder pins would work, but the voltages would be very unevenly spaced and I was concerned about how quickly and accurately we could read it (rotary encoders are notoriously difficult to debounce).

I eventually gave each encoder a miniature R-2R ladder DAC for four voltages corresponding to the four possible states of its gray code. The voltages are not spaced entirely evenly but easily good enough for our needs.

Scope trace of the analog signal when turning the encoder

The waveform from turning the knob one click, 0.5V/division.

I figured three pins, three analogue inputs – being the two rotations for the axes, and the two mouse buttons going into the third input. Unfortunately I forgot the details of the pinout of the ATtiny85:

ATtiny85 pinout

PB1 and PB0 have no ADC channel, and PB2 is the INT0 pin, needed for the hardware interrupt for V-USB. Possibly we could rewrite it to use one of the PCINT pins? Too much effort. There's another ADC channel on the reset pin. Normally to use the reset pin as an input/output you have to disable it via the fuses, which means the chip can no longer be programmed via ISP, only HVSP (high voltage serial programming). I've been meaning to make a high voltage fuse resetter, but haven't gotten around to it yet.

However you can still read the analogue channel without disabling the reset pin, so I tried an idea and it worked: so long as the analogue voltage is above the 'low' level, of about 2.5 volts, it won't reset the chip and we can read it without problems. Excellent! For that reason I turned the button resistors upside down, so the voltage is 5V without pressing anything, dropping to about 3V with both pressed.

Schematic for the etch-a-sketch USB mouse

The USB bits (zeners and inline resistors, pullup resistor for data line) I fitted inside of the USB plug.

Inside of the USB plug the diodes and resistors are hiding

Pullup is SMD resistor on the right. Couldn't find any suitable cable, but this is a low speed device so ordinary ribbon cable is fine.

Inside of the USB port with the wires attached

Next I laser cut some knobs and a case.

Top side of the mouse enclosure

The underside, the ribbon cable is stuck into a channel I carved with a stanley knife. The encoders go through the laser cut holes of course. The whole case, apart from the back that screws on, is glued together with cyanoacrylate. I must say, laser cut project boxes are very quick to make. And tailored to size of course!

Initial placement of the ATtiny inside the enclosure

A better look at the R-2R ladders. They are a bit squished.

Inside of the mouse showing how the ATtiny and encoders are wired up

That was before changing the plan for the buttons. This is after.

Inside of the mouse with the encoder buttons wired up too

So. I should talk about the code. I used the V-USB library, just like with the Morse Code USB Keyboard, only this time we need the report descriptor for a mouse. Luckily V-USB comes with an example for just that, and also the hardware and vendor IDs from a logitech mouse.

First thing I made to try it out was sending a report of just Mouse X= +1. This makes your mouse continuously drift to the right.

Then I used the ADC to read a potentiometer, and made a sort of one-dimenional joystick.

Then I made it recognise the encoders.

The encoders, since they're clicky, should equal one pixel for each click. But that makes travelling across the screen very slow. So we make movements proportional to velocity. But this breaks the one-to-one link between wheel position and cursor position.

Well, this turned out not to be so bad. Especially not for using it as a mouse. For drawing, it can be a little difficult to judge, but the simplest thing to do is slow right down and go at one pixel per click. Or go at full speed, which I limited to 16 pixels per click, which is also easy to judge. It's just the intermediate speeds that are impossible to draw with.

The front of the laser cut enclosure and knobs

Last of all are the mouse buttons, which can easily be sent directly, but preferably, we should toggle the buttons on or off each time the encoder button is pressed. This will make drawing far easier. But it would make doing anything else very annoying. So I gave the device two modes: plug it in holding a button down to enable toggle-mode.

To do the toggling I used some funtimes bitwise logic. Actually identical to how I detected button presses when I made the games console. XOR the difference between this state and the previous state to get those buttons that have changed. AND that with the current state for those that have changed to 1, i.e. those that have been pressed. Then XOR all that with the reportBuffer button mask to toggle those.

 reportBuffer.buttonMask ^= (prevButtonState^state) & state; 

This worked fine for the left mouse button (light pulldown) but not so for the right button (heavy pulldown). The reason was to do with capacitance, and the rise time of the reset pin. When the right mouse button was released it would pass through the voltage representing the left button, so sometimes it would toggle the left as you release the right. This is, as you can imagine, annoying and unacceptable. But thankfully there's an easy way to eliminate it: just lower the sample rate, as a primitive form of debouncing. This solves the problem entirely, and since a button press takes hundreds of milliseconds it has no effect on responsiveness.

Now to close up the case.

Rear of the enclosure with screws holding the panel in place

The holes were laser cut, obviously, but I had to countersink them by hand. Manual labour, in this day and age? sheesh.

Finished image of the Etch-A-Sketch USB mouse

You know what's just occured to me, I should paint it red and have the knobs white...

Finished mouse with USB plug

And yes, we could have just taken apart an old ball mouse and stuck knobs on the rotary encoders inside. That may even have produced a better result, with non-clicky encoders. But I didn't have a ball mouse to hand, and I wasn't exactly going to buy one to do this, that's way beyond my budget. Total cost of this creation: about £1.50.

This was quite a quick project, but filming the process – lighting, sound, talking, editing and uploading took more than twice as long as the actual build. I definitely need to streamline the process.

I have probably now shared far more information on this than anyone could possibly want, but just to top it off here is the source code too. Phew.

/* Etch a Sketch Mouse
 * using V-USB library by Objective Development
 * mitxela.com/projects/etch_mouse
 */

#include <avr/io.h>
#include <avr/wdt.h>
#include <avr/eeprom.h>
#include <avr/interrupt.h>
#include <avr/pgmspace.h>
#include <util/delay.h>

#include "usbdrv.h"

#define UTIL_BIN4(x)    (uchar)((0##x & 01000)/64 + (0##x & 0100)/16 + (0##x & 010)/4 + (0##x & 1))
#define UTIL_BIN8(hi, lo)   (uchar)(UTIL_BIN4(hi) * 16 + UTIL_BIN4(lo))

#ifndef NULL
#define NULL  ((void *)0)
#endif


/* ------------------------------------------------------------------------- */

PROGMEM const char usbHidReportDescriptor[52] = { /* USB report descriptor, size must match usbconfig.h */
  0x05, 0x01,          // USAGE_PAGE (Generic Desktop)
  0x09, 0x02,          // USAGE (Mouse)
  0xa1, 0x01,          // COLLECTION (Application)
  0x09, 0x01,          //   USAGE (Pointer)
  0xA1, 0x00,          //   COLLECTION (Physical)
  0x05, 0x09,          //   USAGE_PAGE (Button)
  0x19, 0x01,          //   USAGE_MINIMUM
  0x29, 0x03,          //   USAGE_MAXIMUM
  0x15, 0x00,          //   LOGICAL_MINIMUM (0)
  0x25, 0x01,          //   LOGICAL_MAXIMUM (1)
  0x95, 0x03,          //   REPORT_COUNT (3)
  0x75, 0x01,          //   REPORT_SIZE (1)
  0x81, 0x02,          //   INPUT (Data,Var,Abs)
  0x95, 0x01,          //   REPORT_COUNT (1)
  0x75, 0x05,          //   REPORT_SIZE (5)
  0x81, 0x03,          //   INPUT (Const,Var,Abs)
  0x05, 0x01,          //   USAGE_PAGE (Generic Desktop)
  0x09, 0x30,          //   USAGE (X)
  0x09, 0x31,          //   USAGE (Y)
  0x09, 0x38,          //   USAGE (Wheel)
  0x15, 0x81,          //   LOGICAL_MINIMUM (-127)
  0x25, 0x7F,          //   LOGICAL_MAXIMUM (127)
  0x75, 0x08,          //   REPORT_SIZE (8)
  0x95, 0x03,          //   REPORT_COUNT (3)
  0x81, 0x06,          //   INPUT (Data,Var,Rel)
  0xC0,              //   END_COLLECTION
  0xC0,              // END COLLECTION
};


/* This is the same report descriptor as seen in a Logitech mouse. The data
 * described by this descriptor consists of 4 bytes:
 *    .  .  .  .  . B2 B1 B0 .... one byte with mouse button states
 *   X7 X6 X5 X4 X3 X2 X1 X0 .... 8 bit signed relative coordinate x
 *   Y7 Y6 Y5 Y4 Y3 Y2 Y1 Y0 .... 8 bit signed relative coordinate y
 *   W7 W6 W5 W4 W3 W2 W1 W0 .... 8 bit signed relative coordinate wheel
 */
typedef struct{
  uchar   buttonMask;
  signed char  dx;
  signed char  dy;
  char  dWheel;
}report_t;

static report_t reportBuffer;
static uchar  idleRate;   /* repeat rate for keyboards, never used for mice */

char curStateX  = 0;
char prevStateX = 0;
char curStateY  = 0;
char prevStateY = 0;
char toggle = 0;
char prevButtonState=0;
char togglewait =0;

char getState(){
  while (!(ADCSRA & (1<<ADIF)));
  char val = ADCH;
  if (val<45) return 0;
  if (val<104) return 1;
  if (val<139) return 2;
  return 3;
}

char getButtonState(){
  while (!(ADCSRA & (1<<ADIF)));
  char val = ADCH;
  if (val>215) return 0;  // These values were chosen somewhat by trial and error,
  if (val>190) return 1;  // since the reset has an internal pullup as well which
  if (val>170) return 2;  // skews it a bit
  return 3;
}

static void pollControls(void) {
  
  #define incr(v) {if (v >= 1 && v <= 8) v*=2; else v++;}
  #define decr(v) {if (v <=-1 && v >=-8) v*=2; else v--;}
  
  char state;
  
  ADMUX = (1<<ADLAR | 1<<MUX1); //PB4
  ADCSRA = (1<<ADEN|1<<ADSC|1<<ADIF|1<<ADPS2);
  
  state= getState();
  
  if (curStateX!=state){
    prevStateX=curStateX;
    curStateX=state;    

    if (prevStateX==3){
      if (curStateX==2) decr(reportBuffer.dx)
      else if (curStateX==1) incr(reportBuffer.dx)
    } else if (curStateX==3){
      if (prevStateX==1) decr(reportBuffer.dx)
      else if (prevStateX==2) incr(reportBuffer.dx)
    }
  }

  ADMUX = (1<<ADLAR | 1<<MUX1 | 1<<MUX0); //PB3  
  ADCSRA = (1<<ADEN|1<<ADSC|1<<ADIF|1<<ADPS2);

  state= getState();
  
  if (curStateY!=state){
    prevStateY=curStateY;
    curStateY=state;

    if (prevStateY==3){
      if (curStateY==2) decr(reportBuffer.dy)
      else if (curStateY==1) incr(reportBuffer.dy)
    } else if (curStateY==3){
      if (prevStateY==1) decr(reportBuffer.dy)
      else if (prevStateY==2) incr(reportBuffer.dy)
    }
  }
  
  ADMUX = (1<<ADLAR); //PB5/rst
  ADCSRA = (1<<ADEN|1<<ADSC|1<<ADIF|1<<ADPS2);
  state=getButtonState();
  
  if (toggle) {
    if (!togglewait--){
      reportBuffer.buttonMask ^= (prevButtonState^state)&state;
      prevButtonState=state;
    }
  } else reportBuffer.buttonMask=state;
  
}





/* ------------------------ interface to USB driver ------------------------ */
// Taken from the HID-mouse example that comes with V-USB

usbMsgLen_t usbFunctionSetup(uchar data[8]) {
usbRequest_t  *rq = (void *)data;

  /* The following requests are never used. But since they are required by
   * the specification, we implement them in this example.
   */
  if((rq->bmRequestType & USBRQ_TYPE_MASK) == USBRQ_TYPE_CLASS){  /* class request type */
    if(rq->bRequest == USBRQ_HID_GET_REPORT){  /* wValue: ReportType (highbyte), ReportID (lowbyte) */
      /* we only have one report type, so don't look at wValue */
      usbMsgPtr = (void *)&reportBuffer;
      return sizeof(reportBuffer);
    }else if(rq->bRequest == USBRQ_HID_GET_IDLE){
      usbMsgPtr = &idleRate;
      return 1;
    }else if(rq->bRequest == USBRQ_HID_SET_IDLE){
      idleRate = rq->wValue.bytes[1];
    }
  }else{
    /* no vendor specific requests implemented */
  }
  return 0;   /* default for not implemented requests: return no data back to host */
}

/* ------------------------ Oscillator Calibration ------------------------- */
// This is taken straight from the EasyLogger project from Objective Development.

static void calibrateOscillator(void) {
  uchar  step = 128;
  uchar  trialValue = 0, optimumValue;
  int    x, optimumDev, targetValue = (unsigned)(1499 * (double)F_CPU / 10.5e6 + 0.5);

  /* do a binary search: */
  do{
    OSCCAL = trialValue + step;
    x = usbMeasureFrameLength();  /* proportional to current real frequency */
    if(x < targetValue)        /* frequency still too low */
      trialValue += step;
    step >>= 1;
  }while(step > 0);
  /* We have a precision of +/- 1 for optimum OSCCAL here */
  /* now do a neighborhood search for optimum value */
  optimumValue = trialValue;
  optimumDev = x; /* this is certainly far away from optimum */
  for(OSCCAL = trialValue - 1; OSCCAL <= trialValue + 1; OSCCAL++){
    x = usbMeasureFrameLength() - targetValue;
    if(x < 0)
      x = -x;
    if(x < optimumDev){
      optimumDev = x;
      optimumValue = OSCCAL;
    }
  }
  OSCCAL = optimumValue;
}

void usbEventResetReady(void) {
  /* Disable interrupts during oscillator calibration since
   * usbMeasureFrameLength() counts CPU cycles.
   */
  cli();
  calibrateOscillator();
  sei();
  eeprom_write_byte(0, OSCCAL);   /* store the calibrated value in EEPROM */
}

/* ------------------------------------------------------------------------- */
/* --------------------------------- main ---------------------------------- */
/* ------------------------------------------------------------------------- */

int main(void) {
  uchar   i;
  uchar   calibrationValue;

  calibrationValue = eeprom_read_byte(0); /* calibration value from last time */
  if(calibrationValue != 0xff){
    OSCCAL = calibrationValue;
  }
  usbDeviceDisconnect();
  
  ADMUX = (1<<ADLAR); //PB5/rst
  ADCSRA = (1<<ADEN|1<<ADSC|1<<ADIF|1<<ADPS2);
  toggle = getButtonState();
  
  for(i=0;i<20;i++){  /* 300 ms disconnect */
    _delay_ms(15);
  }
  usbDeviceConnect();
  
  wdt_enable(WDTO_1S);

  usbInit();
  sei();
  for(;;){  /* main event loop */
    wdt_reset();
    usbPoll();
    if(usbInterruptIsReady()){
      /* called after every poll of the interrupt endpoint */
      usbSetInterrupt((void *)&reportBuffer, sizeof(reportBuffer));
      reportBuffer.dy/=2;
      reportBuffer.dx/=2;
    }
    pollControls();
  }
  return 0;
}