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:
@@ -0,0 +1,6 @@
|
||||
.backup/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.new
|
||||
lgtv.env
|
||||
.env
|
||||
Executable
+27
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
Reference in New Issue
Block a user