From 34adf2848b1838e42e93628cb6fa0cf938cabb0a Mon Sep 17 00:00:00 2001 From: Mortdecai Date: Fri, 23 Oct 2026 00:34:53 -0400 Subject: [PATCH] fix: IP-independent pairing key fallback; run commands off the paho thread Co-Authored-By: Claude Opus 5.5 (1M context) --- DECISIONS.md | 2 ++ lgtv.sh | 13 +++++++++++++ lgtv_mqtt.py | 10 ++++++++++ 3 files changed, 25 insertions(+) diff --git a/DECISIONS.md b/DECISIONS.md index b5171bb..1bd1a97 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -51,6 +51,8 @@ Format: `YYYY-MM-DD: — ` - 2026-10-23: TV DHCP-reserved at 192.168.0.20 (Omada) — bscpylgtv keys pairings by IP, so every DHCP drift (7 IPs so far) re-triggered the on-TV auth prompt, and a missed prompt hung `off` past the 60s timeout. New IP seeded with the newest stored key (.31) so no re-prompt. Also: on_message now catches all exceptions — one escaping killed paho's thread while the poll loop kept the process alive (silent zombie). +- 2026-10-23 (later): Reservation alone didn't hold — the TV came back on .45 minutes later and prompted again. lgtv.sh now falls back to the newest stored key when there's no key for the current IP, so IP drift is harmless. Bridge commands run on a single worker thread so a 60s `off` can't starve paho's keepalive and drop queued commands. + ## Deferred / Rejected - **DHCP reservation for the TV (static IP):** not done — Pi-hole doesn't run DHCP (router does), so it's Seth's action on the router. The cached-IP probe diff --git a/lgtv.sh b/lgtv.sh index 09512d7..8f7c0e2 100755 --- a/lgtv.sh +++ b/lgtv.sh @@ -153,8 +153,21 @@ from bscpylgtv import WebOsClient # = works regardless of cwd/env. Override with LGTV_KEY_FILE if needed. KEY = os.environ.get('LGTV_KEY_FILE') or os.path.expanduser('~/.aiopylgtv.sqlite') +# bscpylgtv stores keys per IP, but the TV doesn't care which IP a key comes +# from. Without this, every DHCP drift = a fresh on-TV pairing prompt. +def newest_key(): + import pickle, sqlite3 + try: + row = sqlite3.connect(KEY).execute( + "SELECT value FROM unnamed ORDER BY rowid DESC LIMIT 1").fetchone() + return pickle.loads(row[0]) if row else None + except sqlite3.Error: + return None + async def main(): client = await WebOsClient.create(os.environ['TV_IP'], key_file_path=KEY) + if client.client_key is None: + client.client_key = newest_key() await client.connect() await client.power_off() await asyncio.sleep(1) diff --git a/lgtv_mqtt.py b/lgtv_mqtt.py index e57267c..3c36e0c 100755 --- a/lgtv_mqtt.py +++ b/lgtv_mqtt.py @@ -18,6 +18,9 @@ import time import json import logging import paho.mqtt.client as mqtt +from concurrent.futures import ThreadPoolExecutor + +worker = ThreadPoolExecutor(max_workers=1) # --- Config --- MQTT_HOST = "192.168.0.154" # CT 301 Mosquitto @@ -110,6 +113,13 @@ def on_connect(client, userdata, flags, rc): def on_message(client, userdata, msg): + # Hand off to one worker thread: an `off` can block up to 60s, which starves + # paho's keepalive (broker drops us, queued commands are lost). The single + # worker keeps commands in order. + worker.submit(safe_handle, client, msg) + + +def safe_handle(client, msg): # An exception escaping a paho callback kills the network thread while the # poll loop keeps the process alive — a silent zombie systemd never restarts. try: