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) <noreply@anthropic.com>
This commit is contained in:
Mortdecai
2026-06-01 19:29:01 -04:00
commit 1699276e8e
6 changed files with 387 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.backup/
__pycache__/
*.pyc
*.new
lgtv.env
.env
Executable
+27
View File
@@ -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
+17
View File
@@ -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
+3
View File
@@ -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
Executable
+171
View File
@@ -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
Executable
+163
View File
@@ -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()