Adding peripherals to a PC with microcontroller assistance

Code for this project can be found here.


Preface

I like my workstation. And I like to augment it with a lot of unconventional components. In this article I will describe how I control a CCFL tube and an audio amplifier PSU with my computer.

These are my light and subwoofer I’m controlling with the device: my ccfl and subwoofer


Why

I need to control the amp of my audio system to save energy (both electrical and emotional).

Nobody wants to go under the desk and flip a switch somewhere on the back every time they leave their pc for a longish time.

Me neither. Therefore, I made an effort to automate this process. While at it, I also got a CCFL tube from an old laptop’s screen and hooked it up to a microcontroller for easy access from my workstation pc over UART.


Interesting points of this project

For me this device brought some new concepts to implement:

* Tracking the state of an ATX computer with a microcontroller
* Interfacing unconventional peripheral devices with a regular pc
* Low power modes on a microcontroller
* Event-driven programming of a microcontroller

How it looks

I’m using an Arduino Uno compatible board to control a 5v relay module and a CCFL invertor module. The MCU is connected to my workstation via single USB cable.

The CCFL (Cold Cathode Fluorescent Light, if you still didn’t look it up) is taped above my desk, along with the invertor board. These come from (different) old laptop screens with fluorescent backlight.


How it operates

Little demo of the ccfl control process:

For control, I have a python script that communicates with the MCU over serial, using a trivial custom messaging system. The support for various peripheral devices can be added very easily to such setup.

For automation, I have a single molex connector from the pc’s PSU hooked up to my Uno to measure the voltage on the 5V rail of the PSU. This rail is only powered, when the computer is on (by ATX specification).

Interconnection: molex, ccfl connection


Hardware

CCFL invertor board I use is powered by 12V rail of the pc’s power supply.

The board has 4 input pins: +12V and GND are connected to the same molex, that’s used for pc state detection. ENABLE and DIM pins are hooked up to the Arduino. ENABLE gets +5v when the tube needs to be enabled, and DIM accepts a PWM signal for dimming the light.

The invertor board: ccfl invertor board

5V rail of the molex is connected directly to a digital input on the Arduino. It’s LOW when the pc if off, and HIGH when on.

Unrelated to this project:

3.3v pin of the Arduino is connected to the amplifier’s ENABLE pin (this is needed, because the audio system doesn’t have a controller and I’m simply feeding it signal from the computer, using 2 3.5mm jacks and software frequency processing).

The device and pinout: device closeup


Software (device side)

The software uses interrupts and low-power states of the MCU to optimize for energy saving. When the controller detects that the pc is turned off, it attaches an interrupt to the pc state monitoring pin and goes to sleep. It will be awoken automatically, when the interrupt is triggered.

There is a major caveat with this detection method. Some household power networks are not very stable in terms of voltage, so false-positives may appear. The code deals with those in 2 ways.

  1. Sampling
  2. Code flow control

Sampling is configured with REQUIRED_OFF_SAMPLE_COUNT and the sample rate is 2 samples per second. This helps to prevent the controller from thinking that the pc was turned off, when it really was not. It also helps with “smoothing” the state transition, which can be unstable if the pc’s power supply doesn’t have enough time to settle down, when turned off.

By code flow control I mean, that the code is designed so that nothing bad will happen, if a false-positive state change occures. The controller will just shift the state once again when it detects the actual state of the pc.

void setup() is very simple. It initializes the pins to a known state and detects if the pc is currently powered on.

  setupIO();
  isHostOn = detectHostState();

void loop() is not any more complex.

    if (isHostOn) {
      dbgp("host on detected\n");
      hostOnLoop();
    }
    else {
      dbgp("host off detected\n");
      hostOffLoop();
    }

When the controller detects an off state, it will power my amplifier off with a relay, attach an interrupt to the host monitoring pin, and go to sleep, until the interrupt triggers and wakes the controller up.

void hostOffLoop() {
  dbgp("Reached host off loop\n");
  Serial.end();
  setAudioRelay(LOW);
  ccflSetState(0);
  delay(BEFORE_SLEEP_DELAY); //delay just before assigning the interrupt and going to sleep to avoid false positives
  attachInterrupt(digitalPinToInterrupt(PC_5V_INPUT), hostOnISR, RISING);
  LowPower.powerDown(SLEEP_FOREVER, ADC_OFF, BOD_OFF);
  //cleanup after waking up
  detachInterrupt(digitalPinToInterrupt(PC_5V_INPUT));
  Serial.begin(9600);
}

When the interrupt is triggered, this ISR is executed.

void hostOnISR() {
  isHostOn = true;
}

While the pc is on, the controller listens to commands, coming from UART and samples the monitoring pin to catch the host state transition to off.

void hostOnLoop() {
  
  dbgp("Reached host on loop\n");
  setAudioRelay(HIGH);
  static byte offSampleCount = 0; //sample the state of host detection pin for improved accuracy
  while (isHostOn) {
    if (Serial.available() > 1) {
      dbgp("Trying to handle a serial command\n");
      handleSerialCommand();
    }
    if (!detectHostState()) {
      dbgp("Host appears to be off... counting\n");
      offSampleCount++;
      if (offSampleCount >= REQUIRED_OFF_SAMPLE_COUNT) {
        dbgp("Alright, host is off\n");
        isHostOn = false;
        offSampleCount = 0;
      }
    }
    else {
      offSampleCount = 0; //reset on single deviation
    }
    delay(500);
  }
}

The serial commands are used to control the available peripherals directly from the host. The communication protocol uses magic values and fixed-size buffers for extreme simplicity.

void handleSerialCommand() {
  byte buf[SERIAL_PACKET_MAX_SIZE];
  byte totalRead = Serial.readBytes(buf, SERIAL_PACKET_MAX_SIZE);
  dbgp("read ");
  dbgp(totalRead);
  dbgp(" bytes\n");

  //integrity checks via magic values:
  if (buf[0] != 'S' || buf[1] != 'I') {
    dbgp("Integrity checks not passed\n");
    return;
  }

  dbgp("Parsing the command\n");

  switch (buf[2]) {
    case SERIAL_COM_AUDIO_ON:
      dbgp("audio on com\n");
      setAudioRelay(HIGH);
      break;
    case SERIAL_COM_AUDIO_OFF:
      dbgp("audio off com\n");
      setAudioRelay(LOW);
      break;
    case SERIAL_COM_AUDIO_TOGGLE:
      dbgp("audio toggle com\n");
      setAudioRelay(!isAudioOn);
      break;
    case SERIAL_COM_LIGHT_ON:
      dbgp("light on com\n");
      ccflSetState(1);
      break;
    case SERIAL_COM_LIGHT_OFF:
      dbgp("light off com\n");
      ccflSetState(0);
      break;
    case SERIAL_COM_LIGHT_SET:
      dbgp("light SET com\n");
      ccflSetDim(buf[3]);
      break;
    case SERIAL_COM_LIGHT_ADD:
      dbgp("light increment com\n");
      ccflAddDim(buf[3]);
      break;
    case SERIAL_COM_LIGHT_SUB:
      dbgp("light decrement com\n");
      ccflSubDim(buf[3]);
      break;  
    case SERIAL_COM_LIGHT_TOGGLE:
      dbgp("light toggle com\n");
      ccflSetState(!isCcflOn);
      break;
    default:
      dbgp("Unknown serial command: ");
      dbgp(buf[2]);
      dbgp("\n");      
  }
}

Software (host side)

On the host side, a simple python script exists for unidirectional communication with the controller.

Available commands are stored in a dictionary

class protocol:
    commands = {
            "ccfl_on"       : 0x81,
            "ccfl_off"      : 0x82,
            "ccfl_set"      : 0x83,
            "ccfl_add"      : 0x84,
            "ccfl_sub"      : 0x85,
            "ccfl_toggle"   : 0x86,
            "audio_on"      : 0x71,
            "audio_off"     : 0x72,
            "audio_toggle"  : 0x73,
    }

Each device is mapped to the “targets” dictionary. Each device implements it’s own commands separately

class devices:
    def ccfl(args):

        if args[0] == "on":
            send(protocol.commands['ccfl_on'])

        elif args[0] == "off":
            send(protocol.commands['ccfl_off'])

        elif args[0] == "dim":
            dimVal = args[1]
            if dimVal[0] == "+":
                send(protocol.commands['ccfl_add'], [int(dimVal[1:])])
            elif dimVal[0] == "-":
                send(protocol.commands['ccfl_sub'], [int(dimVal[1:])])
            else:
                send(protocol.commands['ccfl_set'], [int(dimVal)])

        elif args[0] == "toggle":
            send(protocol.commands['ccfl_toggle'])
        else:
            errPrint(f"Wrong command for ccfl target: {args[0]}")


    def audioRelay(args):
        if args[0] == "on":
            send(protocol.commands['audio_on'])

        elif args[0] == "off":
            send(protocol.commands['audio_off'])

        elif args[0] == "toggle":
            send(protocol.commands['audio_toggle'])

        else:
            errPrint(f"Wrong command for audio target: {args[0]}")

targets = {"light" : devices.ccfl, "audio" : devices.audioRelay }

Communication is handled by pyserial

def send(com, args=None):
    com = b"SI" + com.to_bytes(1, "big")
    if args:
        for i in args:
            com += i.to_bytes(1, "big")
    if len(com) > PACKET_MAXLEN:
        raise ValueError
    #pad the message for serial transmission optimization
    payload = com + b"\x00" * (PACKET_MAXLEN - len(com))
    ser.write(payload)

This very simple script can be easily modified to include more devices with specific control commands.


Reset problem

All fine, except one small problem. Every time, an Arduino board receives a serial connection, it resets itself by default. This is unacceptable in my case, because I need to manipulate the I/O pins to control peripherals. Resetting the board will set the pins to their default state.

There is a very simple (although somewhat hacky) solution. A single ~5-10 uF capacitor between RES and GND pins on the Arduino prevents it from resetting altogether. I have to take it out to update the code on the device, which is no big deal, since it doesn’t happen frequently at all. 4.7uF capacitor


Conclusion

The device turned out very practical. It automates controlling the power supply of my amplifier and serves as a handy auxiliary device controller, that can operate almost anything.