This is (perhaps, if I get around to it) one of a series of posts offering a bit of a sneak peak into the technical side of a small theatre, and how we solve some problems with techbodgery (that feels like it should be a hashtag, #techbodgery)

Now the audience is still
And the house lights are gone
Curtain going up
And I am on!

(from I Want to Make Magic - FAME)

House Lights are Important

I've loved theatre from a young age and, whilst it sounds ridiculous, that starts from the moment the house lights dim. There is a palpable atmosphere throughout the auditorium at that moment. That small reduction in brightness silences a room of 2000 people (or 70 in our case!) in an instant.

As we move to LED, the warmth and beauty of this moment is getting lost. To recreate the colour temperature shift well requires expensive fixtures and control equipment that is out of the reach of tiny venues like ours. (Philips Hue actually gets pretty close at a low cost, but as a domestic product the control isn't reliable enough - even just with our 25 downlights - to ensure they all fade out together!)

But, that's not what this is about. This post is about control. The LED lights we have now aren't horrendous, but they're not the best. It's a compromise and reused our existing dimmers.

Typical Control Systems

Professional large venues have proper control systems allowing control from various places (stage, control booth, sometimes even the auditorium behind a hidden panel) whilst ensuring the state remains consistent.

These are perfect, but expensive.

Our dimmers use 0-10v control, a simple lightweight control protocol where 0v = off, and 10v = full. This is currently controlled with a manual fader, in one place.

ESP32-to-the-Rescue

Enter my favourite microcontroller, the ESP32. Specifically the fantastic variant from Olimex with PoE

Our new house light control system uses one of these and two standard ESP32 dev boards as outstations, the advantage is they are wireless (powered by a USB cable) and can be positioned anywhere, so we can give visiting companies a remote to control them from the stage if they want.

House Light Controller / Server

The controller connects directly to the existing 0-10v control of the dimmer via this simple I2C DAC from DFRobot.

The decision was made to use OSC rather than some proprietary protocol, this means the house lights can be controlled from QLab or anything else that speaks OSC.

We ended up building some additional logic into this (the Full and Half levels can be reconfigured by OSC, and we added a simple webpage for controlling the house lights) but the initial basic version of this code is below;

Outstations

The outstations use a simple ESP8266 dev board, with three backlit buttons (Off, Half, and Full) when pressed, they send an OSC message to the controller, they also listen to OSC messages from the controller so they all show the current state, irrespective of where it was last changed from.


Our original (simplified) ESP32/ESP8266 code is below.

NB This is intended to be educational and illustrative rather than something you can copy/paste and it will work.

Controller Code

// OSC
int recv_port = 4840; // receive port for OSC
int send_port = 4850; // this broadcasts current state
const char* tx_host = "255.255.255.255";

// current level (always as voltage (e.g. x100)
uint16_t current_level = 0;
uint16_t start_level = 0;
unsigned long start_fade_time = 0;
uint16_t target_level = 0;
String current_name = "";
unsigned int fade_time = 3000; // 3.5s

// full and half aren't quite full and half for now
unsigned int hl_full = 70;
unsigned int hl_half = 20;
unsigned int pre_wait = 900;

// stuff for updates
long updContentLength = 0;
bool updIsValidContentType = false;

DFRobot_GP8403 dac(&Wire,0x5f);
AsyncWebServer server(80);

// update functions
String getHeaderValue(String header, String headerName) {
  return header.substring(strlen(headerName.c_str()));
}

int getTargetLevel() {
  return target_level;
}

void WiFiEvent(WiFiEvent_t event)
{
  switch (event)
  {
  case ARDUINO_EVENT_ETH_START:
    Serial.println("ETH Started");
    ETH.setHostname("hl-controller");
    break;
  case ARDUINO_EVENT_ETH_GOT_IP:
    Serial.print(", IPv4: ");
    Serial.print(ETH.localIP());
    break;
  default:
    break;
  }
}

void setup() {
  Serial.begin(115200);
  delay(300); // needed for ETHERNET
  WiFi.onEvent(WiFiEvent);
  ETH.begin();

  // init the DAC (0-10v)
  while(dac.begin()!=0){
    delay(1000);
  }

  Serial.println("init succeed");
  dac.setDACOutRange(dac.eOutputRange10V);

  // OSC Commands
  OscWiFi.subscribe(recv_port, "/house/full",
      [](const OscMessage& m) {
          fadeTo(hl_full);
      }
  );

  OscWiFi.subscribe(recv_port, "/house/half",
      [](const OscMessage& m) {
          fadeTo(hl_half);
      }
  );

  OscWiFi.subscribe(recv_port, "/house/out",
      [](const OscMessage& m) {
          fadeTo(0);
      }
  );
  
  OscWiFi.subscribe(recv_port, "/house/snap",
      [](const OscMessage& m) {
          setLevel(100); 
          OscWiFi.post();
      }
  );

  OscWiFi.subscribe(recv_port, "/house/query",
      [](const OscMessage& m) {
          Serial.print("/house/query ");
          if (target_level == 0) { 
            OscWiFi.send(tx_host, send_port, "/house/status", "out");
          } else if (target_level > 10 && target_level < hl_full*100) { 
            OscWiFi.send(tx_host, send_port, "/house/status", "half");
          } else if (target_level >= hl_full*100) { 
            OscWiFi.send(tx_host, send_port, "/house/status", "full");
          }
          OscWiFi.post();
      }
  );

  OscWiFi.send(tx_host, send_port, "/house/status", "restarted");
  OscWiFi.send(tx_host, send_port, "/house/status", "full");
}

void loop(){
  OscWiFi.update();
  doFadeLoop();
}

void fadeTo(int lvl) {
  // set the name here
  if (lvl == 0) { 
    current_name = "out"; 
  } else if (lvl >= 5 && lvl < hl_full) { 
    current_name = "half"; 
  } else if (lvl >= hl_full) { 
    current_name = "full"; 
  }

  // transmit the status to other listening stations
  OscWiFi.send(tx_host, send_port, "/house/status", current_name);
  OscWiFi.send(tx_host, send_port, "/house/status", "xfade");
  OscWiFi.post();

  if (current_level == 0 && lvl > 0) {
    // soft start
    setLevel(3);
    delay(pre_wait);
  }
  
  start_fade_time = millis();
  start_level = current_level;
  target_level = lvl*100;
}

void doFadeLoop() {
  if (target_level < current_level || target_level > current_level) {
    unsigned long progress = millis() - start_fade_time;

    // fade in progress
    if (progress <= fade_time) {
      uint16_t voltage = map(progress, 0, fade_time, start_level, target_level);
      current_level = voltage;
      dac.setDACOutVoltage(voltage,0);
    } else {
      // jump to the final value just in case
      dac.setDACOutVoltage(target_level,0); 
      current_level = target_level;
      OscWiFi.send(tx_host, send_port, "/house/status", "stop");
      OscWiFi.post();    
    }
  }
  delay(26);
}

void setLevel(int lvl) {
  int voltage = lvl*100;
  target_level = voltage;
  current_level = voltage;
  dac.setDACOutVoltage(voltage,0);  
}

Outstaton Code

// OSC
int recv_port = 4850; // receive port for OSC
int send_port = 4840; // this broadcasts current state
const char* tx_host = "255.255.255.255";
const char* localhost = "127.0.0.1";

ESP8266WiFiMulti wifiMulti;
WiFiUDP udp;

// Status LEDs
const int LOP_OFF = D8;
const int LOP_HALF = D7;
const int LOP_FULL = D6;
// Buttons
const int BIN_OFF = D3;
const int BIN_HALF = D2;
const int BIN_FULL = D1;

int stateCurrent = 9;
int stateBtnOff = 1;
int stateBtnHalf = 1;
int stateBtnFull = 1;
int stateLedPulse = 0;
int stateChanging = 0;
int changeExpected = 0; 

unsigned long ts = 0; 
unsigned long prevTimerTs = 0; 
unsigned long startTimerTs = 0; 
unsigned long lastChangeTs = 0; 
unsigned long lastReceivedTs = 0; 
unsigned long lastTxTs = 0; 
const long blinkInterval = 300;

void setup() {
  Serial.begin(115200);
  
  pinMode(LOP_OFF, OUTPUT);
  pinMode(LOP_HALF, OUTPUT);
  pinMode(LOP_FULL, OUTPUT);
  pinMode(BIN_OFF, INPUT_PULLUP);
  pinMode(BIN_HALF, INPUT_PULLUP);
  pinMode(BIN_FULL, INPUT_PULLUP);

  OscWiFi.subscribe(recv_port, "/house/status",
      [](const OscMessage& m) {
          String level = m.arg<String>(0);
          lastReceivedTs = millis();
          if (level == "full") {
            stateCurrent = 2;
          } else if (level == "half") {
            stateCurrent = 1;
          } else if (level == "out") {
            stateCurrent = 0;
          } else if (level == "off") {
            stateCurrent = 0;
          } else if (level == "xfade") {
            if (stateChanging == 0) {
              startTimerTs = millis();
              stateChanging = 1;
            }
          } else if (level == "stop") {
            stateChanging = 0;
            setCurrentState();
          }
          changeExpected = 0;
          setCurrentState();
      }
  );

  // get the current state (the reply will update the buttons)
  OscWiFi.send(tx_host, send_port, "/house/query");
  OscWiFi.update();
}

void setCurrentState() {         
  analogWrite(LOP_FULL, 0);
  analogWrite(LOP_HALF, 0);
  analogWrite(LOP_OFF, 0);
  if (stateCurrent == 2) { analogWrite(LOP_FULL, 255); }
  if (stateCurrent == 1) { analogWrite(LOP_HALF, 255); }
  if (stateCurrent == 0) { analogWrite(LOP_OFF, 32); }
}

void retryIfUnset() {
  if (changeExpected == 1) {
    ts = millis();
    if (ts-lastChangeTs > 2000) {
      changeExpected = 0; // give up!
    } else if (ts-lastTxTs > 80) {
      if (stateCurrent == 0) { OscWiFi.send(tx_host, send_port, "/house/out"); }
      if (stateCurrent == 1) { OscWiFi.send(tx_host, send_port, "/house/half"); }
      if (stateCurrent == 2) { OscWiFi.send(tx_host, send_port, "/house/full"); }
      lastTxTs = ts;
    }
  }
}

void changeState(int newState) {
  ts = millis();
  if (newState != stateCurrent && (ts-lastChangeTs >= 700)) {
    lastChangeTs = ts;
    stateCurrent = newState;
    Serial.println(newState);
    if (newState == 0) {
      OscWiFi.send(tx_host, send_port, "/house/out");
    } else if (newState == 1) {
      OscWiFi.send(tx_host, send_port, "/house/half");
    } else if (newState == 2) {
      OscWiFi.send(tx_host, send_port, "/house/full");
    } else if (newState == 3) {
      OscWiFi.send(tx_host, send_port, "/house/snap");
    }

    changeExpected = 1;
    lastTxTs = ts;
    OscWiFi.update();
  }
}

void loop() {
  stateBtnOff = digitalRead(BIN_OFF);
  stateBtnHalf = digitalRead(BIN_HALF);
  stateBtnFull = digitalRead(BIN_FULL);  

  if (stateBtnOff == LOW) {
    changeState(0);
  } else if (stateBtnHalf == LOW) {
    changeState(1);
  } else if (stateBtnFull == LOW) {
    changeState(2);
  }
  
  // are we changing state, if so blink the light 
  if (stateChanging == 1) {
    ts = millis();
    if (ts - prevTimerTs >= blinkInterval) {
      prevTimerTs = ts;
      if (stateLedPulse == 0) {
        if (stateCurrent == 0) { analogWrite(LOP_OFF, 254); }
        if (stateCurrent == 1) { analogWrite(LOP_HALF, 254); }
        if (stateCurrent == 2) { analogWrite(LOP_FULL, 254); }
        stateLedPulse = 1;
      } else {
        if (stateCurrent == 0) { analogWrite(LOP_OFF, 20); }
        if (stateCurrent == 1) { analogWrite(LOP_HALF, 20); }
        if (stateCurrent == 2) { analogWrite(LOP_FULL, 20); }
        stateLedPulse = 0;
      }
    }
    // if we've been flashing more than 5s, stop!
    if (ts - startTimerTs >= 3200) {
      stateChanging = 0;
      setCurrentState();
    }
  } else if (stateChanging == 0 && stateLedPulse == 0) {
    // we've ended a flashing sequence, make sure the lamp is on
    setCurrentState();
    stateLedPulse = 1;
  }

  // implement OSC retry to work around unreliable UDP
  retryIfUnset();

  // process any OscWiFi operations
  OscWiFi.update();
}

Simple, but it works.