docs: handoff — pairing fix verified on/off/on

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mortdecai
2026-10-23 00:38:40 -04:00
parent 9d6f2d28c1
commit 2f96013794
@@ -0,0 +1,128 @@
# Handoff: pairing-prompt / zombie-bridge fix — verified on→off→on
## Session Metadata
- Created: 2026-10-23 00:38:09
- Project: /home/claude/bin/lgtv
- Branch: master
- Session duration: ~25 min (00:15–00:40)
### Recent Commits (for context)
- 9d6f2d2 docs: handoff — pairing prompt / IP drift / zombie bridge
- 34adf28 fix: IP-independent pairing key fallback; run commands off the paho thread
- f7422c9 fix: survive command exceptions; TV DHCP-reserved so pairing key stays valid
- df0ae87 docs: HA VM lost IPv4 — root cause of months-long TV outage; pinned .146
## Handoff Chain
- **Continues from**: [2026-10-23-pairing-prompt-ip-drift.md](./2026-10-23-pairing-prompt-ip-drift.md)
- Previous title: pairing prompt on every IP change + zombie bridge
- **Supersedes**: 2026-10-23-pairing-prompt-ip-drift.md (its "Open" items are restated here)
## Current State Summary
Google Home → HA → MQTT → lgtv bridge on steel141 works end to end. Seth tested
ON → OFF → ON at 00:37 with no TV prompt, and the journal shows three commands
received, all rc=0. That closes both outages: the 2026-10-22 HA IPv4 loss and
tonight's pairing-prompt/zombie-bridge bug. Nothing is in progress.
## Codebase Understanding
### Architecture Overview
Google Home → HA (VM 706, .146) → Mosquitto (.154) → `lgtv_mqtt.py`
(systemd `lgtv-mqtt.service`, User=seth, steel141) → shells out to `lgtv.sh on|off|status`.
ON = Wake-on-LAN (no key needed). OFF = bscpylgtv WebSocket `power_off` (needs a pairing key).
Status = TCP probe of :3000/:3001 at the cached IP, falling back to a /24 sweep matched by MAC.
### Critical Files
| File | Purpose | Relevance |
|------|---------|-----------|
| `lgtv_mqtt.py` | MQTT↔HA bridge | Commands now run on a 1-thread `ThreadPoolExecutor`, wrapped in try/except |
| `lgtv.sh` | on/off/status | `newest_key()` fallback when no key is stored for the current IP |
| `/home/seth/.aiopylgtv.sqlite` | bscpylgtv key store (keyed by IP, pickled values) | 11 rows; newest = .45 |
| `/home/seth/.cache/lgtv/last_ip` | cached TV IP | currently 192.168.0.45 |
| `install.sh` | deploy → `/usr/local/bin/` | re-run after editing either script |
### Key Patterns Discovered
- bscpylgtv `async_init` loads `client_key` from storage by IP at `create()` time. Setting `client.client_key` before `connect()` overrides it, and a key the TV issues is saved under the current IP (`webos_client.py:237`).
- Never block inside a paho callback. An exception kills the network thread, and a long block starves the keepalive, so QoS 0 commands get dropped.
## Work Completed
### Tasks Finished
- [x] Diagnosed the zombie bridge (paho thread died on TimeoutExpired; the poll loop masked it)
- [x] Diagnosed the per-IP key problem (TV has drifted through 11 IPs)
- [x] Omada reservation f8:01:b4:2a:26:ed → .20 (set, but the TV took .45 anyway)
- [x] Key fallback in lgtv.sh, worker thread + exception guard in lgtv_mqtt.py, deployed
- [x] Seth verified ON→OFF→ON via Google Home
### Files Modified
| File | Changes | Rationale |
|------|---------|-----------|
| `lgtv_mqtt.py` | worker thread + `safe_handle` | survive and not block on slow/failed commands |
| `lgtv.sh` | `newest_key()` fallback | IP drift no longer forces a re-pair |
| `DECISIONS.md` | two 2026-10-23 entries | record the why |
### Decisions Made
| Decision | Options Considered | Rationale |
|----------|-------------------|-----------|
| Newest-key fallback instead of relying on the reservation | reservation only; pass a pinned `client_key` from env | The reservation didn't hold; the fallback self-updates as the TV issues new keys |
| Single worker thread | thread per command; asyncio | Keeps command order; smallest diff |
## Pending Work
### Immediate Next Steps
1. Nothing required. If a prompt reappears, check `last_ip` against the newest sqlite row. A prompt at a *new* IP means the TV rejected the fallback key.
2. Optional: find out why the Omada reservation for .20 wasn't applied (the TV got .45 at 00:31). Either fix it, or re-reserve at .45 to match reality.
### Blockers/Open Questions
- [ ] **Still unverified:** whether the TV accepts an old key from a new IP. The TV stayed at .45 during the test, so the fallback path never ran. The first real IP change will tell.
- [ ] Why didn't the reservation stick? Unknown.
### Deferred Items
- Reducing the 60s `off` timeout: deferred. It no longer harms the bridge, it only delays the next command.
## Context for Resuming Agent
### Important Context
- The service runs as **seth**, so the key store and cache are under `/home/seth`. Edit them as `sudo -u seth`.
- The ON path never needs a key. Any "can't turn on" bug sits upstream of WoL (HA, MQTT, a lost message) or is the TV's WoL setting.
- The bridge reports ON while the TV keeps :3000 open in network standby, so HA state can lag after OFF.
### Assumptions Made
- LG validates the key itself, not the source IP (consistent with the protocol; not yet seen across an IP change).
### Potential Gotchas
- A long-running process with a dead paho thread still looks healthy in `systemctl status`. Check the journal for `Exception in thread paho`.
- Backups of pre-edit files are in `lgtv/.backup/` (also the sqlite store as of 00:2x).
## Environment State
### Tools/Services Used
- `network-tools/omada_reserve_ip.py show|reserve <MAC> [IP]`
- `journalctl -u lgtv-mqtt.service`
### Active Processes
- `lgtv-mqtt.service` on steel141, restarted 00:34:40 with the current code.
### Environment Variables
- `LGTV_KEY_FILE`, `LGTV_STATE`, `TV_IP` (set internally), MQTT creds from the project env file (names only).
## Related Resources
- `DECISIONS.md` (2026-10-23 entries)
- Previous handoffs: `2026-10-22-ha-lost-ipv4.md`, `2026-10-23-pairing-prompt-ip-drift.md`