Files
lgtv/lgtv.sh
T
Mortdecai 1699276e8e 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>
2026-06-01 19:29:01 -04:00

172 lines
4.9 KiB
Bash
Executable File

#!/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