commit 1699276e8ebe8a216d96a6f5a1167b6917b1a728 Author: Mortdecai Date: Mon Jun 1 19:29:01 2026 -0400 feat: consolidate LG TV bridge — cached IP discovery, robust WoL, systemd deploy Rework the MQTT bridge into a single deployable unit: - Drop the self-SSH hop; run locally on steel141 (on the TV's LAN, has bscpylgtv, effectively always-on). - Cache last-known TV IP and probe it directly; /24 ping-sweep only on a cache miss. Stops the 30s HA state poll from flapping/spamming the net. - WoL now unicast (last-known IP) + broadcast, ports 9/7, 3 bursts. - systemd unit (User=seth, EnvironmentFile for MQTT_PASSWORD), install.sh, env template, .gitignore. Removes the half-finished lgtv.sh.new. Co-Authored-By: Claude Opus 4.8 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24943d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.backup/ +__pycache__/ +*.pyc +*.new +lgtv.env +.env diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..b2b0b72 --- /dev/null +++ b/install.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Deploy the LG TV bridge on steel141. Idempotent — safe to re-run. +# Run as a user with sudo. Reads MQTT password from $HOMELAB_PASSWORD. +set -euo pipefail +cd "$(dirname "$0")" + +echo "Installing control script + bridge to /usr/local/bin ..." +sudo install -m 0755 lgtv.sh /usr/local/bin/lgtv.sh +sudo install -m 0755 lgtv_mqtt.py /usr/local/bin/lgtv_mqtt.py + +echo "Writing /etc/lgtv/lgtv.env (root:root 0600) ..." +sudo mkdir -p /etc/lgtv +if [[ -z "${HOMELAB_PASSWORD:-}" ]]; then + echo " WARNING: \$HOMELAB_PASSWORD not set — leaving existing env untouched." >&2 + [[ -f /etc/lgtv/lgtv.env ]] || { echo " ERROR: no /etc/lgtv/lgtv.env exists; set HOMELAB_PASSWORD and re-run." >&2; exit 1; } +else + printf 'MQTT_PASSWORD=%s\n' "$HOMELAB_PASSWORD" | sudo tee /etc/lgtv/lgtv.env >/dev/null + sudo chmod 600 /etc/lgtv/lgtv.env +fi + +echo "Installing systemd unit ..." +sudo install -m 0644 lgtv-mqtt.service /etc/systemd/system/lgtv-mqtt.service +sudo systemctl daemon-reload +sudo systemctl enable --now lgtv-mqtt.service + +echo "Done. Status:" +systemctl --no-pager --full status lgtv-mqtt.service | head -12 diff --git a/lgtv-mqtt.service b/lgtv-mqtt.service new file mode 100644 index 0000000..4539ebb --- /dev/null +++ b/lgtv-mqtt.service @@ -0,0 +1,17 @@ +[Unit] +Description=LG TV MQTT bridge for Home Assistant +Documentation=https://git.sethpc.xyz/Seth/lgtv +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=seth +# MQTT_PASSWORD lives here, root-owned 0600, never committed. See lgtv.env.example. +EnvironmentFile=/etc/lgtv/lgtv.env +ExecStart=/usr/bin/python3 /usr/local/bin/lgtv_mqtt.py +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target diff --git a/lgtv.env.example b/lgtv.env.example new file mode 100644 index 0000000..8ffd8f4 --- /dev/null +++ b/lgtv.env.example @@ -0,0 +1,3 @@ +# Copy to /etc/lgtv/lgtv.env (root:root, chmod 600) — NEVER commit the real file. +# MQTT_PASSWORD is the homelab internal password (same as $HOMELAB_PASSWORD). +MQTT_PASSWORD=changeme diff --git a/lgtv.sh b/lgtv.sh new file mode 100755 index 0000000..3867158 --- /dev/null +++ b/lgtv.sh @@ -0,0 +1,171 @@ +#!/bin/bash +# LG TV control — runs locally on steel141 (192.168.0.141), on the TV's LAN. +# +# Design notes (see DECISIONS.md): +# * No SSH hop. This box is already on the TV's subnet with bscpylgtv + +# network broadcast, and is effectively always-on. The old version SSHed +# to seth@192.168.0.141 from itself — pointless overhead + a failure mode. +# * IP discovery is cached. We persist the last-known TV IP and probe it +# directly (fast TCP). A full /24 ping-sweep only runs on a cache miss, +# so the 30s HA state poll no longer hammers the network and no longer +# flaps when ARP entries go stale. +# * WoL is sent unicast (to last-known IP) AND broadcast, multiple ports, +# several bursts — the robust-as-software-can-be wake path. If the TV +# still won't wake, it's the TV's own settings (Quick Start+ / Mobile TV +# On / wired Ethernet), which no script can fix. +# +# Usage: lgtv.sh [on|off|status] + +set -euo pipefail + +TV_MAC="f8:01:b4:2a:26:ed" +BROADCAST="192.168.0.255" +STATE_FILE="${LGTV_STATE:-${HOME}/.cache/lgtv/last_ip}" +WEBOS_PORTS="3000 3001" + +mkdir -p "$(dirname "$STATE_FILE")" + +# Probe a single IP for an open WebOS port. Echo the IP and succeed if reachable. +probe_ip() { + local ip="$1" + IP="$ip" PORTS="$WEBOS_PORTS" python3 - <<'PY' +import os, socket, sys +ip = os.environ['IP'] +for port in os.environ['PORTS'].split(): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(1.2) + try: + s.connect((ip, int(port))) + print(ip) + sys.exit(0) + except Exception: + pass + finally: + s.close() +sys.exit(1) +PY +} + +# Find the TV's IP. Try the cache first (fast), fall back to an ARP sweep. +find_tv_ip() { + # 1. Cached IP — the common path, one quick TCP probe. + if [[ -s "$STATE_FILE" ]]; then + local cached + cached="$(<"$STATE_FILE")" + if [[ -n "$cached" ]] && probe_ip "$cached" >/dev/null 2>&1; then + echo "$cached" + return 0 + fi + fi + + # 2. Cache miss — sweep the /24 to repopulate ARP, match the TV's MAC, + # confirm a WebOS port is open, then re-cache. + local found + found="$(TV_MAC="$TV_MAC" PORTS="$WEBOS_PORTS" python3 - <<'PY' +import os, socket, subprocess +mac = os.environ['TV_MAC'].lower() +ports = os.environ['PORTS'].split() + +subprocess.run( + 'for i in $(seq 1 254); do ping -c1 -W1 192.168.0.$i >/dev/null 2>&1 & done; wait', + shell=True, +) + +neigh = subprocess.check_output('ip neigh show', shell=True, text=True) +for line in neigh.splitlines(): + low = line.lower() + if mac not in low or 'failed' in low or 'incomplete' in low: + continue + ip = line.split()[0] + for port in ports: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(1.2) + try: + s.connect((ip, int(port))) + print(ip) + raise SystemExit(0) + except Exception: + pass + finally: + s.close() +print('') +PY +)" + + if [[ -n "$found" ]]; then + echo "$found" > "$STATE_FILE" + echo "$found" + return 0 + fi + return 1 +} + +tv_on() { + # Unicast to last-known IP (if we have one) + broadcast, ports 9 & 7, + # three bursts. WoL is fire-and-forget; the TV has no IP to talk to yet. + local target="" + [[ -s "$STATE_FILE" ]] && target="$(<"$STATE_FILE")" + + echo "Sending Wake-on-LAN to $TV_MAC (broadcast${target:+ + unicast $target})..." + TV_MAC="$TV_MAC" BROADCAST="$BROADCAST" TARGET="$target" python3 - <<'PY' +import os, socket, time +mac = os.environ['TV_MAC'] +magic = b'\xff' * 6 + bytes.fromhex(mac.replace(':', '')) * 16 +dests = [os.environ['BROADCAST']] +if os.environ.get('TARGET'): + dests.append(os.environ['TARGET']) + +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) +for _ in range(3): + for dest in dests: + for port in (9, 7): + try: + s.sendto(magic, (dest, port)) + except OSError: + pass + time.sleep(0.4) +s.close() +print('WoL bursts sent') +PY +} + +tv_off() { + echo "Finding TV..." + local ip + if ! ip="$(find_tv_ip)"; then + echo "ERROR: TV not found on network (already off?)" + exit 1 + fi + echo "TV at $ip — sending WebOS power_off..." + TV_IP="$ip" python3 - <<'PY' +import asyncio, os +from bscpylgtv import WebOsClient + +async def main(): + client = await WebOsClient.create(os.environ['TV_IP']) + await client.connect() + await client.power_off() + await asyncio.sleep(1) + await client.disconnect() + +asyncio.run(main()) +print('power_off sent') +PY + echo "Done." +} + +tv_status() { + if find_tv_ip >/dev/null 2>&1; then + echo "TV: ON at $(<"$STATE_FILE")" + else + echo "TV: OFF (not found on network)" + fi +} + +case "${1:-}" in + on) tv_on ;; + off) tv_off ;; + status) tv_status ;; + *) echo "Usage: $0 [on|off|status]"; exit 1 ;; +esac diff --git a/lgtv_mqtt.py b/lgtv_mqtt.py new file mode 100755 index 0000000..9a9d739 --- /dev/null +++ b/lgtv_mqtt.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +""" +LG TV MQTT bridge for Home Assistant. + +Runs locally on steel141 as user `seth` (where bscpylgtv lives) via systemd. +Subscribes to command topic, runs lgtv.sh, publishes state back. +Registers the TV as a switch via MQTT discovery so HA picks it up automatically. + +Topics: + homeassistant/switch/lgtv/config -- discovery payload (published on connect) + lgtv/switch/state -- current state: ON / OFF + lgtv/switch/command -- receives: ON / OFF +""" + +import os +import subprocess +import time +import json +import logging +import paho.mqtt.client as mqtt + +# --- Config --- +MQTT_HOST = "192.168.0.154" # CT 301 Mosquitto +MQTT_PORT = 1883 +MQTT_USER = "lgtv" +MQTT_PASS = os.environ.get("MQTT_PASSWORD", "") + +LGTV_SCRIPT = "/usr/local/bin/lgtv.sh" + +DISCOVERY_TOPIC = "homeassistant/switch/lgtv/config" +STATE_TOPIC = "lgtv/switch/state" +COMMAND_TOPIC = "lgtv/switch/command" + +POLL_INTERVAL = 30 # seconds between state polls + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [lgtv-mqtt] %(levelname)s %(message)s", +) +log = logging.getLogger(__name__) + + +def run(cmd, timeout=60): + result = subprocess.run( + cmd, shell=True, capture_output=True, text=True, timeout=timeout + ) + return result.stdout.strip(), result.returncode + + +def get_tv_state(): + """Returns 'ON' or 'OFF' by using the same probe logic as lgtv.sh status.""" + out, rc = run(f"{LGTV_SCRIPT} status") + if rc != 0: + log.warning(f"status probe failed rc={rc} out={out}") + return "OFF" + return "ON" if "TV: ON" in out else "OFF" + + +def tv_on(): + out, rc = run(f"{LGTV_SCRIPT} on") + log.info(f"tv_on: rc={rc} out={out}") + return rc == 0 + + +def tv_off(): + out, rc = run(f"{LGTV_SCRIPT} off") + log.info(f"tv_off: rc={rc} out={out}") + return rc == 0 + + +def publish_discovery(client): + payload = { + "name": "LG TV", + "unique_id": "lgtv_f801b42a26ed", + "state_topic": STATE_TOPIC, + "command_topic": COMMAND_TOPIC, + "payload_on": "ON", + "payload_off": "OFF", + "state_on": "ON", + "state_off": "OFF", + "optimistic": False, + "device": { + "identifiers": ["lgtv_f801b42a26ed"], + "name": "LG TV", + "manufacturer": "LG", + "model": "WebOS TV", + }, + "icon": "mdi:television", + "availability_topic": "lgtv/switch/availability", + "payload_available": "online", + "payload_not_available": "offline", + } + client.publish(DISCOVERY_TOPIC, json.dumps(payload), retain=True) + client.publish("lgtv/switch/availability", "online", retain=True) + log.info("Published MQTT discovery payload") + + +def on_connect(client, userdata, flags, rc): + if rc == 0: + log.info("Connected to MQTT broker") + publish_discovery(client) + client.subscribe(COMMAND_TOPIC) + log.info(f"Subscribed to {COMMAND_TOPIC}") + # Publish current state on connect + state = get_tv_state() + client.publish(STATE_TOPIC, state, retain=True) + log.info(f"Initial state: {state}") + else: + log.error(f"MQTT connect failed: rc={rc}") + + +def on_message(client, userdata, msg): + command = msg.payload.decode().strip().upper() + log.info(f"Received command: {command}") + + if command == "ON": + tv_on() + # Publish ON optimistically immediately — WoL takes ~20s to appear on network. + # The 30s poll loop will correct it if it failed. + client.publish(STATE_TOPIC, "ON", retain=True) + log.info("State published: ON (optimistic)") + elif command == "OFF": + tv_off() + time.sleep(3) + client.publish(STATE_TOPIC, "OFF", retain=True) + log.info("State published: OFF") + else: + log.warning(f"Unknown command: {command}") + return + + +def main(): + client = mqtt.Client(client_id="lgtv-bridge") + client.username_pw_set(MQTT_USER, MQTT_PASS) + client.on_connect = on_connect + client.on_message = on_message + + client.will_set("lgtv/switch/availability", "offline", retain=True) + + log.info(f"Connecting to {MQTT_HOST}:{MQTT_PORT}...") + client.connect(MQTT_HOST, MQTT_PORT, keepalive=60) + client.loop_start() + + last_poll = 0 + try: + while True: + now = time.time() + if now - last_poll >= POLL_INTERVAL: + state = get_tv_state() + client.publish(STATE_TOPIC, state, retain=True) + log.info(f"Polled state: {state}") + last_poll = now + time.sleep(5) + except KeyboardInterrupt: + log.info("Shutting down") + finally: + client.publish("lgtv/switch/availability", "offline", retain=True) + client.loop_stop() + client.disconnect() + + +if __name__ == "__main__": + main()