Teensy 4.1 Home Theater Controller — Serial + OSC + HTTPS Bridge

One box to rule the room. A Teensy 4.1 (Ethernet + multiple UARTs) acts as the hub for my home theater: it talks RS-232 to the Denon AVR, Optoma projector, and a 4×4 HDMI matrix, toggles a relay to raise/lower the motorized screen, and sends OSC to a small Python bridge (with pyvizio) to control a HTTPS-only Vizio TV. The whole system is driven from a TouchOSC interface on my phone.

Highlights

System Architecture

[TouchOSC App] --OSC/UDP--> [Teensy 4.1]
                                |  \__________ RS-232 (115200) ---> [HDMI Matrix]
                                |___________ RS-232 (9600)  ---> [Denon AVR]
                                |___________ RS-232 (9600)  ---> [Optoma Projector]
                                |___________ GPIO (active-low) ---> [Screen Relay]
                                \--OSC/UDP--> [Python Bridge on Media Server] --HTTPS--> [Vizio TV]

Why Teensy 4.1?

High clock, solid NativeEthernet stack, and multiple hardware UARTs make it a great fit when you need IP + several independent serial ports without external multiplexers.

Controls & TouchOSC Map

The Teensy listens on UDP 5005 and routes these OSC addresses:

1bundleIN.route("/display",  displayHandler);
2bundleIN.route("/input",    inputHandler);
3bundleIN.route("/volume",   volumeHandler);
4bundleIN.route("/mute",     muteHandler);
5bundleIN.route("/all_off",  allOffHandler);

Device Mapping & Init

1// Serial ports and IO
2#define AVRSERIAL        Serial4     // Denon AVR (9600)
3#define PROJECTORSERIAL  Serial7     // Optoma (9600)
4#define MATRIXSERIAL     Serial8     // HDMI matrix (115200)
5#define SCREENPIN        0           // Active-low relay: LOW=down, HIGH=up
6
7void setup() {
8  AVRSERIAL.begin(9600);
9  PROJECTORSERIAL.begin(9600);
10  MATRIXSERIAL.begin(115200);
11
12  Ethernet.begin(mac, teensyIp);
13  Udp.begin(5005);
14
15  pinMode(SCREENPIN, OUTPUT);
16  digitalWrite(SCREENPIN, HIGH);     // default: screen UP (relay off)
17}

AVR (Denon) Serial Control

1enum { PW, MV, MU, SI }; // power, volume, mute, source input
2
3void avrCommand(int type, int param) {
4  switch (type) {
5    case PW: AVRSERIAL.print("PW"); AVRSERIAL.print(param ? "ON" : "STANDBY"); break;
6    case MV: AVRSERIAL.print("MV"); AVRSERIAL.print(param ? "UP" : "DOWN");    break;
7    case MU: AVRSERIAL.print("MU"); AVRSERIAL.print(param ? "ON" : "OFF");     break;
8    case SI: AVRSERIAL.print("SI"); AVRSERIAL.print(param == 1 ? "DVD" : param == 2 ? "TV" : "VDP"); break;
9  }
10  AVRSERIAL.write(0x0D); // CR
11}

Volume ramping from TouchOSC slider:

1int prevVol = -1;
2
3void volumeHandler(OSCMessage &msg, int) {
4  const int target = msg.getInt(0);
5  const bool dirUp = msg.getInt(1) == 1;
6  if (prevVol != -1) {
7    int steps = target - prevVol;
8    if (steps != 0 && ((steps > 0) == dirUp)) steps += dirUp ? 11 : -11; // nudge in same direction
9    while (steps != 0) {
10      avrCommand(MV, steps > 0);  // UP if positive
11      steps += (steps > 0 ? -1 : 1);
12    }
13  }
14  prevVol = target;
15}

Projector (Optoma) Serial Control

1// Basic power + display mode example (protocol varies by model)
2void projectorPower(bool on) {
3  PROJECTORSERIAL.print("~00");   // header
4  PROJECTORSERIAL.print("00 ");   // command: power
5  PROJECTORSERIAL.write(on ? '1' : '0');
6  PROJECTORSERIAL.write(0x0D);
7}

HDMI Matrix Routing

1// Route input N to all 4 outputs (gofanco-style)
2void matrixRoute(int input /*1..4*/) {
3  MATRIXSERIAL.print("#video_d out1,2,3,4 matrix=");
4  MATRIXSERIAL.write('0' + input);
5  MATRIXSERIAL.write('\\r');
6}

TV via Python (pyvizio) — OSC → HTTPS

The Teensy can't do HTTPS reliably, so it sends OSC to a small Python service (on the media server). The service uses pyvizio to talk to the TV.

1# server.py (bridge)
2from pythonosc.dispatcher import Dispatcher
3from pythonosc.osc_server import BlockingOSCUDPServer
4from pyvizio import Vizio
5
6TV_IP = "192.168.1.50"
7AUTH  = "your-paired-token"
8vizio = Vizio("VizioTV", TV_IP, auth_token=AUTH)
9
10def power_handler(addr, val):
11    if int(val): vizio.pow_on()
12    else:        vizio.pow_off()
13
14def input_handler(addr, source):
15    vizio.set_input(str(source))  # or map to real input names
16
17disp = Dispatcher()
18disp.map("/power", power_handler)
19disp.map("/input", input_handler)
20
21BlockingOSCUDPServer(("0.0.0.0", 5005), disp).serve_forever()

On the Teensy side you send /power or /input to the bridge's IP:5005:

1void tvCommand(int type, int param) {
2  const char* addr = (type == PW) ? "/power" : "/input";
3  OSCMessage msg(addr);
4  msg.add(param);
5  Udp.beginPacket(tvBridgeIp, 5005);
6  msg.send(Udp);
7  Udp.endPacket();
8  msg.empty();
9}

Display Modes & Screen Relay (with planned warm-up delay)

Currently, switching modes automatically turns off the "other" display (TV vs projector) and moves the screen. I'm adding a projector warm-up delay so the TV doesn't turn off and the screen doesn't drop until the projector is ready.

1// Planned timing (tweak per projector spec)
2const uint32_t PROJECTOR_WARMUP_MS  = 30000; // e.g., 30s
3const uint32_t PROJECTOR_COOLDOWN_MS= 60000; // optional, 60s
4
5enum DisplayMode { TVMODE=0, PJMODE=1 };
6
7uint32_t pjWarmStart = 0;
8bool     pendingScreenDown = false;
9
10void setDisplayMode(DisplayMode m) {
11  if (m == PJMODE) {
12    // 1) Turn TV off via bridge (or defer until screen drops if you prefer)
13    tvCommand(PW, 0);
14    // 2) Power projector on
15    projectorPower(true);
16    // 3) Begin warm-up timer; drop screen only after warm-up completes
17    pendingScreenDown = true;
18    pjWarmStart = millis();
19    // AVR on + route inputs as needed
20    avrCommand(PW, 1);
21  } else {
22    // TV mode: screen up, projector off, then TV on
23    digitalWrite(SCREENPIN, HIGH); // screen UP (relay inactive)
24    projectorPower(false);
25    tvCommand(PW, 1);
26  }
27}
28
29void loop() {
30  // Warm-up check
31  if (pendingScreenDown && millis() - pjWarmStart >= PROJECTOR_WARMUP_MS) {
32    digitalWrite(SCREENPIN, LOW);  // screen DOWN (active-low)
33    pendingScreenDown = false;
34  }
35
36  // ...handle OSC, serial IO, etc...
37}
If your projector exposes a status query over RS-232 (e.g., "Lamp On / Ready"), replace the fixed delay with an actual status poll and drop the screen when it reports Ready.

Reliability & UX Notes

What I'd Improve Next