ESPHome – sterowanie projektorem Optoma UHD38x

Z pewną nieśmiałością :grin: tworzę pierwszy wątek. Ale coś mi się udało wystrugać, może komuś się przyda a może ktoś podpowie co i jak poprawić.

Do rzeczy: mój projektor Optoma USH38x nie ma żadnych funkcji sieciowych. Sterowałem nim z HA za pośrednictwem uniwersalnego pilota IR, ale takie rozwiązanie ma dla mnie kilka istotnych wad, przede wszystkim brak potwierdzenia wykonania danej komendy oraz brak możliwości weryfikowania aktualnego stanu urządzenia. W końcu choroba uwięziła mnie w domu, więc zabrałem się za działanie. Posiłkowałem się podpowiedziami ChatGPT, ale to dość kapryśne i w sumie mało bystre narzędzie. Ma sporą wiedzę, ale bystrością musiałem wykazać się ja :wink:

Ponieważ projektor jest podpięty do wzmacniacza stereo z obsługą HDMI na projektorze nie uruchamiam żadnych funkcji, tylko i wyłącznie ON/OFF, i tylko to implementowałem w tym zadaniu. Po drodze dodałem jeszcze zgrubną weryfikację pozostałego okresu żywotności lampy. Nie ma zmian źródła czy głośności - nic. Ale to już proste, to w razie potrzeby można dodać samodzielnie.

ESP32 jest za pośrednictwem konwertera RS232-TTL podpięty do portu RS-232 projektora i przez ten port wydaje komendy i odpytuje projektor o bieżący stan.

Sprzęt użyty w projekcie

  • Projektor: Optoma UHD38x
  • Płytka ESP32: ESP32 DevKit V1, 30-pin
  • Konwerter RS232-TTL: Waveshare RS232 do TTL MAX3232
  • Zasilanie ESP32: przez port USB-C

Ogólny opis działania

Sterowanie projektorem Optoma UHD38x poprzez UART/RS232 oraz integrację z Home Assistant. System:

  • cyklicznie odczytuje projektor o stan ON/OFF oraz o godziny pracy lampy,
  • umożliwia włączanie/wyłączanie urządzenia,
  • aktualizuje sensory (stan ON/OFF, godziny pracy lampy, żywotność lampy).

Kilka uwag do pliku YAML

Sekcja “logger” - przede wszystkim wyłączenie logowania wysyłanego na RS-232 projektora (baud_rate: 0), wysyłanie loga na RS232 może ogłupiać projektor.

Sekcja “interval” - dwa harmonogramy cykliczne:

  • co 300 sekund: poll_power_state – odczyt stanu ON/OFF,
  • co 24 godziny: poll_lamp_hours – aktualizacja danych o lampie.

Włączenie/wyłączenie projektora pilotem IR może być niezauważone przez czas odpowiadający interwałowi wywołania poll_power_state. Obecnie już wydłużyłem interwał dla poll_power_state do 15 minut by zredukować liczbę zapytań do projektora.

Sekcja “switch” - przycisk włącz/wyłącz projektor:

  • wysyła odpowiednią komendę przez RS232,
  • czeka: 90s przy włączeniu, 45s przy wyłączeniu,
  • potem uruchamia poll_power_state, który sprawdza rzeczywisty stan.

W kliknięciu przełącznik chwilowo wraca do poprzedniego stanu, do czasu zakończenia badania stanu projektora. Obecnie już zredukowałem oczekiwanie przy włączeniu do 75s i przy wyłączeniu do 35s, ale jest jeszcze możliwość kolejnej redukcji.

Sekcja “sensor”

  • Optoma Lamp Hours: pokazuje liczbę przepracowanych godzin
  • Optoma Lamp Remaining %: przeliczony procent pozostałego czasu pracy lampy

Obliczanie żywotności lampy

Przyjąłem model dwóch trybów pracy lampy (bo tak jest u mnie):

  • Bright (pełna jasność) - żywotność 4000 godzin,
  • Dynamic (tryb eco/dynamiczny) - żywotność 15000 godzin.
    Zmienna bright_hours_initial pozwala ręcznie uwzględnić, że pierwsze godziny pracy lampy odbyły się w trybie Bright - tutaj dwie godziny. W praktyce nie ma to żadnego znaczenia (jeżeli to cały czas tylko dwie godziny).

Planowane ulepszenia

  • Zastąpienie cyklicznego odpytywania rozwiązaniem opartym na wyjściu 12V projektora (przeznaczonym do automatycznego rozwijania ekranu).
  • Po podłączeniu wyjścia 12V (poprzez np. optoizolator) do ESP32 możliwe będzie szybkie i niezawodne wykrywanie faktycznego stanu włączenia projektora - niezależnie od tego, czy został włączony przez Home Assistant, czy fizycznym pilotem IR (obecnie zastosowanie pilota, a dokładnie wywołana tym zmiana stanu projektora, będzie zauważona ze znacznym opóźnieniem).
  • To może całkowicie zastąpić skrypt poll_power_state, zmniejszając opóźnienie wykrywania i zużycie zasobów UART.

Planowane komponenty: optoizolator 1-kanałowy, zasilany z pinu VIN na ESP32, wykrywający obecność napięcia 12V.

No i na koniec najważniejsze, plik .yaml

esphome:
  name: esp32-optoma
  friendly_name: Optoma UHD38x
  on_boot:
    priority: -100
    then:
      - script.execute: poll_lamp_hours

esp32:
  board: esp32dev

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

logger:
  level: DEBUG # po testach warto zmniejszyć poziom logowania
  baud_rate: 0

api:
ota:
  platform: esphome

uart:
  id: uart_bus
  tx_pin: GPIO17
  rx_pin: GPIO16
  baud_rate: 9600
  stop_bits: 1
  parity: NONE

globals:
  - id: lamp_hours
    type: int
    restore_value: yes
  - id: bright_hours_initial
    type: int
    initial_value: '2'  # Pierwsze 2h pracy w trybie Bright

interval:
  - interval: 300s
    then:
      - script.execute: poll_power_state
  - interval: 24h
    then:
      - script.execute: poll_lamp_hours

script:
  - id: poll_power_state
    mode: queued
    then:
      - lambda: |-
          uint8_t d;
          while (id(uart_bus).available())
            id(uart_bus).read_byte(&d);
      - lambda: |-
          id(uart_bus).write_str("~00124 1\r");
      - delay: 300ms
      - lambda: |-
          std::string buf;
          uint8_t c;
          while (id(uart_bus).available()) {
            if (!id(uart_bus).read_byte(&c)) break;
            if (c == '\r' || c == '\n') continue;
            buf.push_back(static_cast<char>(c));
          }
          if (!buf.empty()) {
            id(optoma_response).publish_state(buf.c_str());
            for (auto &ch : buf) ch = tolower(ch);
            if (buf.find("ok1") != std::string::npos || buf.find("info1") != std::string::npos) {
              id(projector_power_state).publish_state(true);
            } else {
              id(projector_power_state).publish_state(false);
            }
          }

  - id: poll_lamp_hours
    mode: queued
    then:
      - lambda: |-
          uint8_t d;
          while (id(uart_bus).available())
            id(uart_bus).read_byte(&d);
      - lambda: |-
          id(uart_bus).write_str("~00150 1\r");
      - delay: 500ms
      - lambda: |-
          std::string buf;
          uint8_t c;
          while (id(uart_bus).available()) {
            if (!id(uart_bus).read_byte(&c)) break;
            if (c == '\r' || c == '\n') continue;
            buf.push_back(static_cast<char>(c));
          }
          ESP_LOGD("optoma", "Lamp RAW → '%s'", buf.c_str());
          if (buf.rfind("OK", 0) == 0 && buf.length() >= 8) {
            id(lamp_hours) = std::stoi(buf.substr(2, 6));
            id(optoma_lamp_hours).publish_state(id(lamp_hours));
            float total = id(lamp_hours);
            float bright = id(bright_hours_initial);
            if (total < bright) total = bright;
            float dyn = total - bright;
            float wear = bright / 4000.0 + dyn / 15000.0;
            if (wear > 1.0) wear = 1.0;
            id(optoma_lamp_remaining).publish_state(round((1.0 - wear) * 100.0));
          }

text_sensor:
  - platform: template
    name: "Optoma Response"
    id: optoma_response

binary_sensor:
  - platform: template
    name: "Optoma Power State"
    id: projector_power_state
    device_class: power

switch:
  - platform: template
    name: "Optoma Power"
    id: optoma_power
    optimistic: false
    lambda: |-
      return id(projector_power_state).state;
    turn_on_action:
      - lambda: |-
          ESP_LOGD("optoma", "Sending POWER ON toggle");
          id(uart_bus).write_str("~0000 1\r");
      - delay: 90s
      - script.execute: poll_power_state
    turn_off_action:
      - lambda: |-
          ESP_LOGD("optoma", "Sending POWER OFF toggle");
          id(uart_bus).write_str("~0000 0\r");
      - delay: 45s
      - script.execute: poll_power_state

sensor:
  - platform: template
    name: "Optoma Lamp Hours"
    id: optoma_lamp_hours
    unit_of_measurement: "h"
    accuracy_decimals: 0
    update_interval: never

  - platform: template
    name: "Optoma Lamp Remaining %"
    id: optoma_lamp_remaining
    unit_of_measurement: "%"
    accuracy_decimals: 0
    update_interval: never

Będę wdzięczny za ewentualne uwagi i wskazówki co i jak poprawić. Całość aktualnie działa od wczoraj, jestem w fazie testowania. Na razie wszystko działa prawidłowo.

EDIT: kilka poprawek stylistycznych i info o zmianie niektórych parametrów czasowych w konfiguracji

EDIT2: niezbędne będą poprawki - wyłączony projektor czasem (nie wiem na jakiej zasadzie i od czego to zależy) raportuje złą liczbę godzin pracy lampy. Obecnie testuję odczytywanie tej wartości podczas startu i wyłączania projektora (wtedy kiedy już albo jeszcze jest włączony) a nie w stałym interwale 24h. Jak będę gotowy wkleję poprawioną wersję pliku .yaml.

2 polubienia