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:
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