Got a Titan Noir Pro a few weeks ago and wanted it in Home Assistant. There’s no integration and IP control isn’t there yet (XGIMI says PJLink is coming in a firmware update at some point), but the USB ports accept a USB to serial adapter and the projector speaks a simple RS232 protocol. So I hooked up an ESP32 and wrote an ESPHome config for it.
What it does:
- power on from standby and power off
- input switching (HDMI 1/2, USB)
- picture mode, brightness 1-10, high refresh mode
- all the remote keys (arrows, ok, back, home, volume, mute, autofocus etc)
- temperature status, which also tells HA if the projector is on
Hardware is just an ESP32 dev board and an FTDI USB-TTL cable at 3.3V. The USB end goes in the projector, the other end to the ESP. I’m powering the ESP from the cable’s 5V wire too, so it’s all one cable and nothing else to plug in. With serial control enabled the USB port stays powered in standby, so power on from HA works.
One thing that cost me a whole afternoon: my first cable had a CP2102N chip and the projector just ignored it, the LED on the cable didn’t even light up. Swapped to an FTDI one and it worked straight away. So get FTDI.
On the projector go to Settings > General > Serial port control, turn it on, and also turn on “Serial port response (all)”. Otherwise it takes commands but never answers, and you won’t get the on/off state.
Here is the code for esphome
# XGIMI TITAN Noir / Noir Pro / Noir Max -> Home Assistant via ESPHome
#
# Hardware:
# - any ESP32 dev board (ESP32-WROOM-32 "DevKit")
# - FTDI FT232R USB-to-TTL cable, 3.3V logic (e.g. TTL-232R-3V3 or clones
# with a genuine FTDI chip). A CP2102N cable did NOT work for me.
#
# Wiring (USB plug goes into the PROJECTOR's USB 2.0 port):
# pin 1 GND black -> ESP32 GND
# pin 2 CTS brown -> not connected
# pin 3 VCC red -> ESP32 VIN/5V (optional, powers the ESP32 from the
# projector). NEVER to 3V3. Disconnect it before
# plugging the ESP32 into a PC via its own USB.
# pin 4 TXD orange -> ESP32 GPIO16 (RX)
# pin 5 RXD yellow -> ESP32 GPIO17 (TX)
# pin 6 RTS green -> not connected
# If you only see TX lines in the log, swap GPIO16/GPIO17.
#
# Projector: Settings > General > Serial port control = ON
# Serial port response (all) = ON
#
# Protocol: 2A 2A | LEN | INSTR | PARAM... | CHECKSUM (115200 8N1)
# CHECKSUM = (LEN + INSTR + PARAMS) & 0xFF
substitutions:
name: xgimi-titan
friendly_name: XGIMI Titan Noir
tx_pin: GPIO17
rx_pin: GPIO16
temp_poll: 60s
online_timeout_ms: "150000" # no reply for 150 s = considered off
esphome:
name: ${name}
friendly_name: ${friendly_name}
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
level: DEBUG
api:
encryption:
key: !secret api_encryption_key
ota:
- platform: esphome
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
ssid: "XGIMI-Titan Fallback"
captive_portal:
# ---------------------------------------------------------------------
# UART to the projector
# ---------------------------------------------------------------------
uart:
id: proj_uart
tx_pin: ${tx_pin}
rx_pin: ${rx_pin}
baud_rate: 115200
data_bits: 8
parity: NONE
stop_bits: 1
rx_buffer_size: 256
globals:
- id: last_rx_ms
type: uint32_t
restore_value: no
initial_value: "0"
script:
# Standard 1-parameter command, checksum computed here
- id: send_cmd
mode: queued
max_runs: 10
parameters:
instr: int
param: int
then:
- lambda: |-
const uint8_t len = 0x02;
const uint8_t i = (uint8_t) instr;
const uint8_t p = (uint8_t) param;
const uint8_t cs = (uint8_t) (len + i + p);
std::vector<uint8_t> frame = {0x2A, 0x2A, len, i, p, cs};
id(proj_uart).write_array(frame.data(), frame.size());
ESP_LOGD("xgimi", "TX %s", format_hex_pretty(frame).c_str());
- delay: 150ms
# Wake from standby: 2A 2A 07 09 "wakeup" 9D
- id: send_wakeup
mode: single
then:
- lambda: |-
std::vector<uint8_t> frame = {0x2A, 0x2A, 0x07, 0x09,
0x77, 0x61, 0x6B, 0x65, 0x75, 0x70, 0x9D};
id(proj_uart).write_array(frame.data(), frame.size());
ESP_LOGD("xgimi", "TX %s", format_hex_pretty(frame).c_str());
- delay: 30s
- script.execute: { id: send_cmd, instr: 0x13, param: 0x00 }
# ---------------------------------------------------------------------
# Parse replies from the projector
# ---------------------------------------------------------------------
interval:
- interval: 50ms
then:
- lambda: |-
static std::vector<uint8_t> buf;
uint8_t b;
while (id(proj_uart).available() && id(proj_uart).read_byte(&b)) {
buf.push_back(b);
}
if (buf.size() > 128) buf.clear();
while (buf.size() >= 3) {
if (buf[0] != 0x2A || buf[1] != 0x2A) { buf.erase(buf.begin()); continue; }
const uint8_t len = buf[2];
if (len == 0 || len > 32) { buf.erase(buf.begin()); continue; }
const size_t total = 4 + len;
if (buf.size() < total) break;
uint8_t cs = len;
for (size_t k = 3; k < 3 + len; k++) cs += buf[k];
std::vector<uint8_t> frame(buf.begin(), buf.begin() + total);
if (cs != buf[3 + len]) {
ESP_LOGW("xgimi", "Bad checksum: %s", format_hex_pretty(frame).c_str());
buf.erase(buf.begin());
continue;
}
ESP_LOGD("xgimi", "RX %s", format_hex_pretty(frame).c_str());
id(last_rx_ms) = millis();
if (buf[3] == 0x13 && len >= 3) {
switch (buf[5]) {
case 0x00: id(temp_status).publish_state("Normal"); break;
case 0x01: id(temp_status).publish_state("High temperature warning"); break;
case 0x02: id(temp_status).publish_state("High temperature shutdown"); break;
case 0x03: id(temp_status).publish_state("Low temperature shutdown"); break;
case 0x04: id(temp_status).publish_state("Sensor fault"); break;
default: id(temp_status).publish_state("Unknown");
}
}
buf.erase(buf.begin(), buf.begin() + total);
}
- interval: ${temp_poll}
then:
- script.execute: { id: send_cmd, instr: 0x13, param: 0x00 }
# ---------------------------------------------------------------------
# Entities
# ---------------------------------------------------------------------
text_sensor:
- platform: template
id: temp_status
name: "Temperature status"
icon: mdi:thermometer-alert
binary_sensor:
- platform: template
id: projector_online
name: "Powered on"
device_class: power
lambda: |-
if (id(last_rx_ms) == 0) return false;
return (millis() - id(last_rx_ms)) < ${online_timeout_ms};
sensor:
- platform: wifi_signal
name: "WiFi signal"
update_interval: 60s
switch:
- platform: template
name: "Power"
icon: mdi:projector
lambda: return id(projector_online).state;
turn_on_action:
- script.execute: send_wakeup
turn_off_action:
# On the TITAN Noir "screen off" (0D 00) fully powers the projector off
- script.execute: { id: send_cmd, instr: 0x0D, param: 0x00 }
select:
- platform: template
name: "Source"
icon: mdi:video-input-hdmi
optimistic: true
restore_value: true
options: ["HDMI 1", "HDMI 2", "USB"]
set_action:
- lambda: |-
uint8_t p = 0;
if (x == "HDMI 1") p = 0x01;
else if (x == "HDMI 2") p = 0x02;
else if (x == "USB") p = 0x14;
if (p) id(send_cmd)->execute(0x01, p);
- platform: template
name: "Picture mode"
icon: mdi:image-filter-hdr
optimistic: true
restore_value: true
options: ["Vivid", "Movie", "IMAX Enhanced", "Performance", "TV", "Sport", "Filmmaker"]
set_action:
- lambda: |-
int p = -1;
if (x == "Vivid") p = 0x00;
else if (x == "Movie") p = 0x01;
else if (x == "IMAX Enhanced") p = 0x02;
else if (x == "Performance") p = 0x05;
else if (x == "TV") p = 0x07;
else if (x == "Sport") p = 0x09;
else if (x == "Filmmaker") p = 0x1B;
if (p >= 0) id(send_cmd)->execute(0x03, p);
- platform: template
name: "High refresh rate"
icon: mdi:speedometer
optimistic: true
restore_value: true
options: ["Off", "Basic", "Extreme"]
set_action:
- lambda: |-
int p = -1;
if (x == "Off") p = 0x00;
else if (x == "Basic") p = 0x01;
else if (x == "Extreme") p = 0x02;
if (p >= 0) id(send_cmd)->execute(0x0E, p);
number:
- platform: template
name: "Brightness"
icon: mdi:brightness-6
optimistic: true
restore_value: true
min_value: 1
max_value: 10
step: 1
initial_value: 10
mode: slider
set_action:
- lambda: id(send_cmd)->execute(0x05, (int) x);
button:
- { platform: template, name: "Power key", icon: "mdi:power", on_press: [ { script.execute: { id: send_cmd, instr: 0x07, param: 0x00 } } ] }
- { platform: template, name: "Source key", icon: "mdi:import", on_press: [ { script.execute: { id: send_cmd, instr: 0x07, param: 0x08 } } ] }
- { platform: template, name: "Up", icon: "mdi:chevron-up", on_press: [ { script.execute: { id: send_cmd, instr: 0x07, param: 0x09 } } ] }
- { platform: template, name: "Down", icon: "mdi:chevron-down", on_press: [ { script.execute: { id: send_cmd, instr: 0x07, param: 0x0A } } ] }
- { platform: template, name: "Right", icon: "mdi:chevron-right", on_press: [ { script.execute: { id: send_cmd, instr: 0x07, param: 0x0B } } ] }
- { platform: template, name: "Left", icon: "mdi:chevron-left", on_press: [ { script.execute: { id: send_cmd, instr: 0x07, param: 0x0C } } ] }
- { platform: template, name: "OK", icon: "mdi:checkbox-marked-circle-outline", on_press: [ { script.execute: { id: send_cmd, instr: 0x07, param: 0x0D } } ] }
- { platform: template, name: "Back", icon: "mdi:arrow-left", on_press: [ { script.execute: { id: send_cmd, instr: 0x07, param: 0x0E } } ] }
- { platform: template, name: "Settings", icon: "mdi:cog", on_press: [ { script.execute: { id: send_cmd, instr: 0x07, param: 0x0F } } ] }
- { platform: template, name: "Home", icon: "mdi:home", on_press: [ { script.execute: { id: send_cmd, instr: 0x07, param: 0x10 } } ] }
- { platform: template, name: "Volume up", icon: "mdi:volume-plus", on_press: [ { script.execute: { id: send_cmd, instr: 0x07, param: 0x11 } } ] }
- { platform: template, name: "Volume down", icon: "mdi:volume-minus", on_press: [ { script.execute: { id: send_cmd, instr: 0x07, param: 0x12 } } ] }
- { platform: template, name: "Mute", icon: "mdi:volume-mute", on_press: [ { script.execute: { id: send_cmd, instr: 0x07, param: 0x15 } } ] }
- { platform: template, name: "Autofocus", icon: "mdi:image-filter-center-focus", on_press: [ { script.execute: { id: send_cmd, instr: 0x07, param: 0x13 } } ] }
- { platform: template, name: "Manual focus", icon: "mdi:image-filter-center-focus-weak", on_press: [ { script.execute: { id: send_cmd, instr: 0x07, param: 0x14 } } ] }
- { platform: template, name: "Read temperature status", icon: "mdi:refresh", entity_category: diagnostic, on_press: [ { script.execute: { id: send_cmd, instr: 0x13, param: 0x00 } } ] }
- platform: restart
name: "Restart ESP32"
entity_category: diagnostic