Controle remoto da TV LG (webOS) que roda no navegador do celular, sem assinatura e sem loja de aplicativos. Além dos botões, ele busca uma série ou filme, descobre em qual serviço o título está no Brasil e abre o app da TV já com a busca preenchida.
iPhone (PWA) ──HTTP──▶ este servidor ──wss://3001──▶ TV LG
│
└──HTTPS──▶ TMDb (o que está em qual streaming)
O servidor existe por um motivo específico: TVs LG de 2018+ só aceitam conexão em
wss://IP:3001 com certificado autoassinado, e nenhum navegador de celular aceita isso.
O servidor ignora o certificado e vira a ponte.
- Um PC (ou Raspberry Pi / NAS) ligado e na mesma rede da TV enquanto você usa o controle.
- Python 3.12+. No Windows, o jeito mais simples é o
uv. - Opcional: token gratuito do TMDb, se quiser a busca de títulos.
Ligue a TV, deixe-a na mesma rede que o PC e clique duas vezes em setup.bat.
Ele instala o que falta (o uv, se ainda não existir), procura a TV na rede, pareia,
lê os apps instalados, pergunta o PIN e sobe o servidor. Um passo só.
Se preferir pelo terminal:
.\setup.bat # Prompt de Comando
.\setup.ps1 # PowerShellDurante o processo:
- A TV vai pedir autorização — aceite com o controle físico. Acontece uma vez só, e há cerca de um minuto para responder. A TV precisa estar ligada e na tela inicial: em standby ela continua respondendo na rede, mas recusa sem exibir o pedido (o assistente reconhece esse caso e diz o que fazer).
- O PIN protege a TV de qualquer outro aparelho na rede. O assistente sugere um.
- A credencial do TMDb é opcional e liga a busca por nome. Gratuita em
themoviedb.org → Settings → API. Tanto a chave v3 (32 caracteres) quanto o
token v4 (longo) funcionam — o assistente detecta qual você colou e testa na hora.
Dá para pular e adicionar depois no
.env. - O Windows vai pedir liberação de firewall — aceite para rede privada, senão o celular não alcança o servidor.
O IP da TV pode mudar sem aviso — o controle guarda a identidade dela (TV_UUID) e a
reencontra sozinho quando isso acontece, regravando o .env. Reservar o IP no roteador
continua sendo melhor, porque evita a pausa de alguns segundos na primeira falha.
Para ligar a TV pelo celular, ela precisa de Configurações → Geral → Mobile TV On / Ativar via Wi-Fi habilitado. Sem isso, o app só desliga.
Não é para rodar o setup.bat de novo. Ele é de instalação, não de uso.
O assistente pergunta se você quer que o controle suba junto com o Windows — respondendo
que sim, não precisa fazer mais nada: ele fica no ar desde o login, sem janela, e continua no
ar mesmo se a TV mudar de IP. O log vai para local-data/serve.log.
Para ligar ou desligar isso depois:
uv run lgremote autostart --install # --remove desfaz, --status mostra o estadoSem o início automático, clique duas vezes em serve.bat e deixe a janela aberta.
Se um dia parar de funcionar, o primeiro comando é sempre este — ele diz o que está certo, o que falta e o que fazer:
uv run lgremote doctorRodar o assistente outra vez também é seguro e rápido: ele reaproveita o IP e o pareamento que já estão salvos, e não repete as perguntas já respondidas.
Em Windows 11 com Smart App Control ligado, o Python que o uv baixa fica em AppData
sem assinatura reconhecida, e o sistema bloqueia suas DLLs — o erro aparece como
DLL load failed while importing _ctypes. O setup.bat detecta isso e passa a usar um
Python do sistema automaticamente; se não houver nenhum, ele pede para instalar:
winget install Python.Python.3.12Não desligue o Smart App Control. No Windows 11 essa mudança é irreversível sem reinstalar o sistema, e enfraquece a proteção da máquina inteira para resolver um problema que tem saída melhor.
O assistente é só a soma destes comandos:
uv run lgremote pair --host 192.168.0.10 # parear (o IP está em Configurações → Rede)
uv run lgremote discover # ler os apps reais da TV
# preencher UI_PIN, TMDB_TOKEN e TV_MAC no .env
uv run lgremote serveMesmo Wi-Fi → Safari → http://<IP-do-PC>:8765 → Compartilhar → Adicionar à Tela de Início.
Vira um ícone com tela cheia, sem barra do Safari.
Não há service worker: em HTTP na rede local o iOS não considera contexto seguro e não o registraria. Não faz diferença — um controle remoto é inútil offline.
Os apps de streaming não expõem nenhuma API para legenda ou faixa de áudio — a LG confirma que não há API pública de legendas, e o menu é desenhado pelo próprio app, que só responde a botões. A saída é gravar o caminho uma vez:
- Deixe algo tocando no Max (ou Disney+), sem menus abertos.
- No app, toque em ⋯ (Ajustes) → deixe "Legenda/áudio" selecionado → Gravar sequência.
- Volte à aba Controle e navegue pela área de deslize até trocar a legenda, como sempre faz.
- Ajustes → Parar e salvar → dê um nome (ex.: "Dublado").
Repita para a outra combinação ("Legendado"). Os dois viram botões na aba Controle, mostrados só quando aquele app está em foco na TV.
Por que dois botões e não um que alterna: para alternar, o app precisaria saber o estado atual — e isso não dá para ler do Max. Bastaria você mexer no controle físico uma vez para ele dessincronizar e passar a fazer o oposto do que promete. Dois botões nomeados dão o mesmo um-toque, sempre previsíveis.
Os presets ficam em config/presets.yaml. Esse arquivo é reescrito pelo gravador —
não coloque comentários nele.
As sequências do apps.yaml são chutes até você calibrá-las: layout de app de streaming
muda por região e por versão. Dá para ajustar na mão, mas o caminho curto é gravar:
- Abra o app na TV, na tela onde a sequência começa.
- No celular: ⋯ (Ajustes) → escolha o que gravar:
- Tela "Quem está assistindo?" — como confirmar o seu perfil;
- Caminho até a busca — informe também o título que você vai digitar, para ele
virar
{title}e a macro servir a qualquer outra série; - Caminho até o episódio.
- Gravar sequência → volte à aba Controle e navegue como sempre faz.
- Parar e salvar.
A macro vai direto para o config/apps.yaml, com os comentários preservados e uma
cópia .bak ao lado. Os tempos gravados são os reais da sua TV, não estimativas.
Na busca, séries ganham um botão Episódios: escolha a temporada e o episódio pela
miniatura. Para o app realmente tocar o episódio, é preciso calibrar a macro episode
do serviço em config/apps.yaml — grave a navegação até o episódio 1 e o
{episode_index} faz o deslocamento:
episode:
- {button: DOWN, times: 2} # até a faixa de episódios
- {button: RIGHT, times: "{episode_index}"} # até o episódio pedido
- {button: ENTER}Sem essa macro, o app abre a série e para ali — o mesmo comportamento de antes, e o PWA avisa disso antes de você escolher. Episódios distantes (o 20 de uma temporada) erram mais, porque a grade rola na tela.
Max e Disney+ mostram o seletor de perfil toda vez que o app abre do zero — a ajuda do Max diz que em TV isso é por design para perfis adultos, e não há como desligar em smart TV. Sem tratar isso, a macro de busca digitaria no seletor e nada aconteceria.
O apps.yaml já traz um profile para Max e Disney+ que confirma o perfil destacado (o
último usado):
profile:
- {button: ENTER}
wait_before_profile: 8Ele só roda em abertura fria. Com o app já em primeiro plano o seletor não aparece, e o app detecta isso e pula — apertar ENTER ali abriria o que estivesse em foco na home.
Se o seu perfil não for o que vem destacado, acrescente os deslocamentos antes do ENTER:
profile:
- {button: RIGHT, times: 2}
- {button: ENTER}Para calibrar, force a abertura fria (senão o app já aberto pula a tela e o teste não reproduz o caso real):
uv run lgremote try-title "The Last of Us" --service max --coldEsta é a única parte que exige trabalho manual, e vale entender por quê.
O webOS aceita abrir um app direto num título (params.contentTarget), mas o formato desse
parâmetro é definido por cada app e não é documentado para Max/Disney+. Então a base do
projeto é uma macro: abrir o app, esperar carregar, navegar até a lupa e digitar o título
pelo teclado da TV. É o que uma pessoa faria — em um toque.
As macros do config/apps.yaml são chutes iniciais. Ajustar na primeira vez é esperado:
uv run lgremote try-title "The Last of Us" --service maxCada passo é impresso enquanto acontece. Olhe a TV, veja onde a sequência erra e ajuste o
bloco search daquele serviço:
search:
- {wait: 3} # deu tempo do app abrir?
- {button: LEFT, times: 4} # chegou no menu lateral?
- {button: UP, times: 3} # chegou na lupa?
- {button: ENTER} # abriu a busca?
- {wait: 2.5}
- {text: "{title}"} # digitou no campo certo?Repita até funcionar. Depois disso não mexe mais — só quando o app mudar de layout.
Se você descobrir um deep link válido para algum app, preencha content_target e ele passa
a ser usado no lugar da macro (instantâneo, sem navegação):
content_target: "https://exemplo/titulo/{tmdb_id}"| Passo | O que faz |
|---|---|
{button: UP, times: 3, delay: 0.2} |
aperta um botão N vezes |
{wait: 1.5} |
espera segundos |
{text: "{title}"} |
digita via IME; {title}, {year} e {tmdb_id} são substituídos |
{enter: true} |
tecla Enter do teclado (≠ botão ENTER do direcional) |
{clear: 40} |
apaga N caracteres |
| Comando | Para quê |
|---|---|
lgremote setup |
assistente: acha a TV, pareia, lê os apps e escreve o .env |
lgremote pair [--host IP] |
parear e gravar a chave no .env |
lgremote apps |
listar os apps instalados na TV |
lgremote discover [--dry-run] |
casar o apps.yaml com os apps reais |
lgremote try-title "X" --service max |
rodar a macro passo a passo para calibrar |
… --cold |
fecha o app antes, para testar a tela de perfil |
… --season 1 --episode 4 |
testa também a macro de episódio |
lgremote serve [--port N] |
subir o servidor e o PWA |
lgremote autostart --install |
subir o controle junto com o Windows (--remove desfaz) |
lgremote doctor |
diagnóstico: o que está certo, o que falta e o que fazer |
O servidor escuta na rede local inteira, então o PIN não é opcional: sem UI_PIN no
.env ele recusa todos os comandos. Os nomes de botão passam por uma whitelist — o que chega
pela rede não vira comando arbitrário na TV.
Não há HTTPS: para uma rede doméstica é aceitável, e é justamente o que torna o PIN necessário.
Sem a TV (roda em qualquer máquina):
uv run pytest # 303 testes, com uma TV falsa que grava os comandos
uv run ruff check .
uv run mypyCom a TV:
lgremote pair→ a TV mostra o pedido de autorizaçãolgremote discover→ Max, Disney+ e Netflix aparecem com os IDs reais- no PWA: deslizar na área de navegação move o foco e o volume muda na tela
- digitar no campo "Digitar na TV" escreve num campo de busca aberto na TV
lgremote try-title "The Last of Us" --service max→ o Max abre com a busca preenchida
Antes de qualquer coisa: uv run lgremote doctor. Ele checa o .env, se a TV responde,
se a chave de pareamento ainda vale, se o servidor já está no ar e se o início automático
está instalado — e termina dizendo o que resolver.
| Sintoma | Causa provável |
|---|---|
| "Não consegui falar com a TV" | TV desligada. Se foi só o IP que mudou, o controle se corrige sozinho |
403 User denied access sem nada na tela da TV |
TV em standby, ou uma recusa antiga gravada nela: Configurações → Geral → Dispositivos → apague o histórico de conexões |
| Pareamento não aparece na TV | TV em rede diferente (Wi-Fi vs cabo), ou firewall bloqueando 3000/3001 |
| Celular não abre a página | firewall do Windows; e o celular precisa estar no mesmo Wi-Fi |
| App abre mas não busca nada | macro precisa de calibração → grave em ⋯ Ajustes, ou use try-title |
| Macro funciona "às vezes" | wait_after_launch curto demais: o app ainda não estava pronto |
| Para no seletor de perfil | aumente wait_before_profile (nunca menor que wait_after_launch), ou grave a macro profile em ⋯ Ajustes |
| Entra no perfil errado | acrescente {button: RIGHT} antes do ENTER em profile |
| Botão de ligar não faz nada | falta TV_MAC, ou "Mobile TV On" desligado na TV |
DLL load failed ... _ctypes |
Smart App Control bloqueou o Python do uv — veja acima |
Acesso negado ao instalar, ou .venv\Scripts\lgremote.exe ... em uso |
o controle já estava no ar (início automático). O setup.bat para o servidor sozinho antes de atualizar; se rodar o uv sync na mão, pare antes com Stop-Process -Name lgremote -Force |
ModuleNotFoundError: No module named 'lgremote' |
.venv pela metade, sobra de uma instalação interrompida. O setup.bat detecta e recria; na mão: Remove-Item -Recurse -Force .venv e uv sync --extra dev |
| Preset não aparece | ele só aparece quando aquele app está em foco na TV |
| Preset erra a legenda | o título tem mais faixas de áudio que o gravado — regrave |
| Não dá para gravar | abra o app na TV primeiro: é ele que define de quem é o preset |
| Episódio abre o errado | a grade rolou; regrave a macro episode com a série em cima |
| Busca não retorna nada | TMDB_TOKEN ausente ou recusado — rode lgremote setup para testar |
setup.bat / setup.ps1 ← instalação em um clique (Windows)
serve.bat / serve.ps1 ← ligar o controle no dia a dia
config/apps.yaml ← o arquivo que você edita (serviços + macros)
config/presets.yaml ← escrito pelo gravador; não edite à mão
src/lgremote/
setup_wizard.py o assistente de configuração
autostart.py iniciar junto com o Windows
tv/discovery.py acha a TV varrendo a porta SSAP na rede
tv/session.py conexão SSAP persistente, com reconexão e IP novo
tv/pairing.py registro na TV, com prazo humano e diagnóstico do "não"
tv/macros.py interpretador dos passos do YAML
tv/recorder.py grava seus toques e escreve a macro no apps.yaml
tv/presets.py presets de legenda/áudio por serviço
tv/opener.py camadas: deep link → macro → episódio
tv/apps.py provedor TMDb ↔ app da TV; descoberta
tv/buttons.py whitelist de botões
tv/wol.py Wake-on-LAN
catalog/tmdb.py busca, disponibilidade BR, temporadas/episódios
api/ FastAPI + executor de ação longa
web/ o PWA (HTML/CSS/JS, sem build)
Dados de disponibilidade por JustWatch, via TMDb. Este produto usa a API do TMDb, mas não é endossado nem certificado pelo TMDb.
O controle agora existe como app nativo de iPhone, que fala direto com a TV — sem PC ligado, sem servidor na rede e sem PIN. É o que resolve, na raiz, as três formas de "perder o pareamento" que este PWA tinha.
Veja app/README.md: como desenvolver no Windows, como gerar o
.ipa pelo GitHub Actions e como instalar pelo SideStore.
Este projeto Python continua funcionando e é a referência de comportamento — os 164 testes do app foram portados dos daqui.