By the end of the previous post, I had decided that the reliability of a direct serial line to the projector wasn’t worth the cost of a MAX232 chip over some IR diodes, so I ordered the latter. I anticipated the process of extracting the IR codes from the remote and replicating them would be difficult, lengthy, and finnicky, but it was actually pretty easy.

Hardware

My final working build uses these components:

A picture of an IR diode stuck into a breadboard positioned in a way that it looks like it is walking, with arms drawn on, captioned: "Day 115 of quarantine: She is getting 👏 her 👏 steps 👏 in 👏"

For recording the IR codes from the remote, I used a receiver diode connected to an Arduino Uno with the following configuration:

Receiver Diode Arduino
+ Cathode 5V out
Signal Pin 11
− Anode GND

Then I flashed the IRrecvDump example from Ken Shirriff’s IRremote library to the Arduino Uno, pointed my projector’s old remote at the receiver, and pressed every single button, noting the hex code output to the serial console. It also printed “Decoded NEC” each time, indicating Sanyo chose to use NEC’s IR protocol, which is important to know when sending these codes to the projector later.

I ended up with the following code table:

Remote button Code
Power 0xCC0000FF
Computer input 0xCC001CE3
Video input 0xCC00A05F
Menu 0xCC0038C7
Left arrow 0xCC007887
Right arrow 0xCC00B847
Up arrow 0xCC0031CE
Down arrow 0xCC00B14E
Select/OK/enter 0xCC00F00F
Digital zoom Up 0xCC00807F
Digital zoom Down 0xCC0040BF
Page up 0xCC009A65
Page down 0xCC005AA5
Keystone 0xCC00DA25
No show 0xCC00D12E
Auto PC adjust 0xCC00916E
Power-off timer 0xCC0051AE
Image settings 0xCC0030CF
Freeze 0xCC00C23D
Mute 0xCC00D02F

And any time I held a button, it sent 0xFFFFFFFF. I saw an “NEC repeat code” referenced in the IRremote documentation, so I’m assuming that’s what it is. I included it in my final project code, but it is never actually sent, and I have not needed it so far.

When I was done recording the codes, I assembled a second breadboard for “production” use where I had just a Particle Photon, an IR emitter diode, and a resistor between A5 on the board and the diode to protect the diode. I then strapped this to the side of my projector so the emitter sat in front of the projector’s IR receiver.

Backend

Speaking of project code, below is the full code I flashed to the Particle Photon (with the MQTT server and credentials emptied, of course).

It hosts a web interface and API for manual control over the network, but it also connects to an MQTT server for control by home automation software.

/*

Copyright 2020 Serial Rodeo

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in the
Software without restriction, including without limitation the rights to use, copy,
modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

*/

#include <IRremote.h>
#include <MQTT.h>
#include <WebServer.h>

#define BUFFER_LENGTH 64

// IR
IRsend emitter; // uses pin A5

// MQTT
#define MQTT_HOST ""
#define MQTT_PORT 1883
#define MQTT_USERNAME ""
#define MQTT_PASSWORD ""
void mqttHandler(char *, byte *, unsigned int);
MQTT mqtt(MQTT_HOST, MQTT_PORT, mqttHandler);

// Web UI
WebServer server("", 80);
P(HTML) = "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Projector</title><style>:root{--btn:#80807c;--dark-btn:#4e4e4c;--power-btn:#a46f5f;--purple:#9f7e9f;}body{background-color:#eee;font-family:sans-serif;font-size:12px;margin:0;padding:0;}.container{background-color:#ccc;border-radius:20px;padding:1rem;width:18rem;margin:2rem auto;position:relative;z-index:0;}#remote{border-collapse:collapse;width:100%;}#remote td{text-transform:uppercase;text-align:center;position:relative;font-weight:bold;}#remote button{background-color:var(--btn);color:white;cursor:pointer;border-radius:4px;border:1px solid rgba(0,0,0,0.5);box-shadow:0 0 10px 0px rgba(0,0,0,0.5);transition:box-shadow 200ms linear;width:3rem;height:2rem;padding:0;}#remote button:active{box-shadow:0 0 2px 2px rgba(0,0,0,0);}#remote button.power{background-color:var(--power-btn);}#remote button.dark{background-color:var(--dark-btn);}#remote tr.separator{border-bottom:2px dashed gray;}#remote tr.separator td{padding-top:3rem;}#remote tr.separator+tr td{padding-top:3rem;}#remote td.circle::before{content:'';background-color:var(--purple);border-radius:50%;width:4rem;height:4rem;display:block;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);z-index:-1;}#remote td.enter::before{content:'';background-color:var(--btn);border-radius:50%;width:7rem;height:7rem;display:block;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);z-index:-1;}#remote .box{padding-top:0.1rem;background-color:var(--btn);}#remote .box.nw{border-top-left-radius:5px;}#remote .box.ne{border-top-right-radius:5px;}#remote .box.sw{border-bottom-left-radius:5px;}#remote .box.se{border-bottom-right-radius:5px;}</style></head><body><div class=\"container\"><table id=\"remote\"><tr class=\"labels\"><td></td><td></td><td></td><td><svg x=\"0px\" y=\"0px\" width=\"12px\" height=\"12px\" viewBox=\"-0.8 -0.5 177 202\" style=\"vertical-align:middle;\"><path fill=\"none\" stroke=\"#000000\" stroke-width=\"30\" stroke-linecap=\"round\" d=\"M33.7,64.3C22.1,77.2,15,94.3,15,113c0,40.1,32.5,72.7,72.7,72.7c40.1,0,72.7-32.5,72.7-72.7c0-18.7-7.1-35.8-18.7-48.7\"/><line fill=\"none\" stroke=\"#000000\" stroke-width=\"30\" stroke-linecap=\"round\" x1=\"87.8\" y1=\"15\" x2=\"87.8\" y2=\"113\"/></svg>&ndash;&vert;</td></tr><tr class=\"buttons\"><td class=\"box nw\"><button value=\"2\" aria-label=\"Computer input\"></button></td><td class=\"box ne\"><button value=\"3\" aria-label=\"Video input\"></button></td><td></td><td><button value=\"1\" aria-label=\"Toggle power\" class=\"power\"></button></td></tr><tr class=\"labels\"><td class=\"box sw\">Computer</td><td class=\"box se\">Video</td><td></td><td>On-Off</td></tr><tr class=\"separator\"><td></td><td></td><td></td><td></td></tr><tr class=\"buttons\"><td></td><td></td><td><button value=\"7\" aria-label=\"Up\" class=\"dark\">&#x25b2;</button></td><td></td></tr><tr class=\"labels\"><td colspan=\"4\">&nbsp;</td></tr><tr class=\"buttons\"><td><button value=\"4\" aria-label=\"Menu\"></button></td><td><button value=\"5\" aria-label=\"Left\" class=\"dark\">&#x25c4;</button></td><td class=\"enter\"><button value=\"9\" aria-label=\"Select\" class=\"dark\"></button></td><td><button value=\"6\" aria-label=\"Right\" class=\"dark\">&#x25ba;</button></td></tr><tr class=\"labels\"><td>Menu</td><td>Volume &ndash;</td><td>Select</td><td>Volume &plus;</td></tr><tr class=\"buttons\"><td></td><td></td><td><button value=\"8\" aria-label=\"down\" class=\"dark\">&#x25bc;</button></td><td></td></tr><tr class=\"separator\"><td></td><td></td><td></td><td></td></tr><tr class=\"buttons\"><td><button value=\"10\" aria-label=\"Zoom in\" class=\"dark\">&#x25b2;</button></td><td><button value=\"12\" aria-label=\"Page up\" class=\"dark\">&#x25b2;</button></td><td><button value=\"14\" aria-label=\"Keystone\" class=\"dark\"></button></td><td><button value=\"15\" aria-label=\"No show\"></button></td></tr><tr class=\"labels\"><td class=\"circle\">D. Zoom</td><td class=\"circle\">Page</td><td>Keystone</td><td>No show</td></tr><tr class=\"buttons\"><td><button value=\"11\" aria-label=\"Zoom out\" class=\"dark\">&#x25bc;</button></td><td><button value=\"13\" aria-label=\"Page down\" class=\"dark\">&#x25bc;</button></td><td><button value=\"16\" aria-label=\"Auto PC\"></button></td><td><button value=\"17\" aria-label=\"P-Timer\"></button></td></tr><tr class=\"labels\"><td></td><td></td><td>Auto PC</td><td>P-Timer</td></tr><tr class=\"buttons\"><td></td><td><button value=\"18\" aria-label=\"Image\"></button></td><td><button value=\"19\" aria-label=\"Freeze\"></button></td><td><button value=\"20\" aria-label=\"Mute\"></button></td></tr><tr class=\"labels\"><td></td><td>Image</td><td>Freeze</td><td>Mute</td></tr></table></div><script>for(let button of document.querySelectorAll('button[value]')){button.onclick=function(){fetch('/',{method:'POST',body:'button='+button.value,});};}</script></body></html>";

uint32_t getCommand(char * button) {
    switch (atoi(button)) {
        case 0:  return 0xFFFFFFFF; // (Repeat)
        case 1:  return 0xCC0000FF; // Power
        case 2:  return 0xCC001CE3; // Computer
        case 3:  return 0xCC00A05F; // Video
        case 4:  return 0xCC0038C7; // Menu
        case 5:  return 0xCC007887; // Left
        case 6:  return 0xCC00B847; // Right
        case 7:  return 0xCC0031CE; // Up
        case 8:  return 0xCC00B14E; // Down
        case 9:  return 0xCC00F00F; // Select
        case 10: return 0xCC00807F; // Zoom in
        case 11: return 0xCC0040BF; // Zoom out
        case 12: return 0xCC009A65; // Page up
        case 13: return 0xCC005AA5; // Page down
        case 14: return 0xCC00DA25; // Keystone
        case 15: return 0xCC00D12E; // No show
        case 16: return 0xCC00916E; // Auto PC
        case 17: return 0xCC0051AE; // P-Timer
        case 18: return 0xCC0030CF; // Image
        case 19: return 0xCC00C23D; // Freeze
        case 20: return 0xCC00D02F; // Mute
        default: return 0x00000000; // (Invalid)
    }
}

void webHandler(WebServer &server, WebServer::ConnectionType type, char *, bool) {
    switch (type) {
        case WebServer::GET: {
            server.httpSuccess();
            server.printP(HTML);
            break;
        }
        
        case WebServer::POST: {
            char key[16], value[16];
            server.readPOSTparam(key, 16, value, 16);
            
            if (strcmp(key, "button")) {
                server.httpFail();
                server.print("Invalid form data");
                return;
            }
            
            uint32_t cmd = getCommand(value);
            
            if (!cmd) {
                server.httpFail();
                server.print("Invalid button");
                return;
            }
            
            server.httpSuccess();
            Particle.publish("button", value);
            emitter.sendNEC(cmd, 32);
            break;
        }
        
        default: {
            server.httpFail();
            server.print("Requests should be GET or POST");
            break;
        }
    }
}

void mqttHandler(char * topic, byte * payload, unsigned int length) {
    char buffer[length + 1];
    memcpy(buffer, payload, length);
    buffer[length] = '\0';
    
    uint32_t cmd = getCommand(buffer);
    
    if (!cmd) {
        mqtt.publish("projector/status", "unknown command");
        return;
    }
    
    emitter.sendNEC(cmd, 32);
    mqtt.publish("projector/status", "OK");
}

void mqttConnect() {
    char buffer[BUFFER_LENGTH];
    snprintf(buffer, BUFFER_LENGTH, "projector_%s", System.deviceID().c_str());
    
    if (!mqtt.connect(buffer, MQTT_USERNAME, MQTT_PASSWORD)) {
        Particle.publish("mqtt", "failed to connect", PRIVATE);
        delay(1000);
        return;
    }
    
    mqtt.subscribe("projector/cmd");
    Particle.publish("mqtt", "connected", PRIVATE);
}

void setup() {
    // Publish local IP address
    IPAddress ip = WiFi.localIP();
    char buffer[BUFFER_LENGTH];
    snprintf(buffer, BUFFER_LENGTH, "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
    Particle.publish("ip", buffer);
    
    // Connect to MQTT server
    mqttConnect();
    
    // Start web server
    server.setDefaultCommand(&webHandler);
    server.begin();
    
    // Turn off status LED
    RGB.control(true);
    RGB.brightness(0);
}

void loop() {
    // MQTT server
    if (mqtt.isConnected()) {
        mqtt.loop();
    } else {
        mqttConnect();
    }
    
    // Web server
    char buffer[BUFFER_LENGTH];
    int length = BUFFER_LENGTH;
    server.processConnection(buffer, &length);
}

Download C++ source

And the libraries included in it:

Frontend

For fun, I designed the web interface it hosts to look exactly like the original remote:

Hopefully, your browser has rendered the HTML document beautifully above. The HTML document behind this isn’t as pretty as the result, (especially when represented as a string in the C++ source code) but you can download it as a formatted HTML file if you wish.

Clicking a button on that preview will not do anything, but when hosted from the board, it will make an HTTP POST request to the board that contains the form data key button and a numeric value. The getCommand function parses this value and maps it to an IR code for transmitting to the projector.

Sure, a web UI is fun, but it’s not very convenient. Navigating to the page in a browser isn’t as fast as picking up the remote off the table. This is where the MQTT functionality comes in.

In Home Assistant, which is connected to the same MQTT broker as the projector control, I created two scripts: one to turn the projector on by “pressing” the power button, and the other to turn it off by “pressing” the power button twice (once to open the “are you sure?” dialog, and again to confirm).

script:
  ...
  projector_on:
    alias: Projector on
    icon: mdi:projector
    mode: single
    sequence:
    - service: mqtt.publish
      data:
        topic: projector/cmd
        payload: 1
  projector_off:
    alias: Projector off
    icon: mdi:projector
    mode: single
    sequence:
    - service: mqtt.publish
      data:
        topic: projector/cmd
        payload: 1
    - service: mqtt.publish
      data:
        topic: projector/cmd
        payload: 1

Note here that I am just sending raw MQTT commands to the broker for the topic listened to by the board code (projector/cmd) with the command number (1) that maps to the power button. Once for on, twice for off.

Okay, now I can control it with a different web interface (Home Assistant). It’s still not that convenient. That’s where Google Assistant helps out. One more addition to my Home Assistant configuration:

google_assistant:
  ...
  script.projector_on:
    expose: true
  script.projector_off:
    expose: true

That makes the projector on and off scripts show up in my Google Home app, but I then created a routine for each action and added a step to call the correct “scene”. Now I can say, “Okay Google, projector on” and it will turn on the projector (and the lamp so I can turn off the overhead light).

Areas for improvement

  • Using IR instead of serial means I don’t have a way to get the current status of the projector, only set it. This prevents idempotence; for example, I can’t send “power off” twice and assume the power is off. If it was already off, it will now be on.
  • This would also be solved if there were separate “power off” and “power on” commands. There may be more IR codes recognized by the projector than there were buttons for on my remote, but I don’t have a way of discovering them. If the codes I knew about were closer together, I would iterate through the values and try more codes to see what happened.

Hopefully this helps anyone looking to automate a device with IR. Questions? Comments? Need help? Let me know below!