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.
[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]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.
The Teensy listens on UDP 5005 and routes these OSC addresses:
/display <0|1> — 0 = TV mode (projector off, screen up, TV on), 1 = Projector mode (projector on, screen down, TV off)/input <1..4> — route the HDMI matrix (and align AVR input)/volume <0..100> <dir 0|1> — smooth ramping volume (down/up)/mute <0|1> — mute toggle on AVR/all_off <any> — all devices off, screen up1bundleIN.route("/display", displayHandler);
2bundleIN.route("/input", inputHandler);
3bundleIN.route("/volume", volumeHandler);
4bundleIN.route("/mute", muteHandler);
5bundleIN.route("/all_off", allOffHandler);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}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}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}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}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}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.
PWON/STANDBY; devices ignore duplicates.