diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index 726908b..61d4188 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -2,6 +2,11 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Before publishing a version, add a `## [VERSION]` section to **both** files: the workflow publishes the section matching the tag from each one and fails if either is missing. +## [Unreleased] +### What's new +- Settings → TV: console mode turns the TV on and switches it to the PC's HDMI input, and can put it in standby on restore. First route: Google TV / Android TV over the network (ADB), nothing to install on the PC. (#93) +- TV control for LG webOS too: powers on with Wake-on-LAN and switches input over the network, pairing the first time. (#94) + ## [1.5.0] ### What's new - Console interface: a full-screen, controller-first home and Settings, driven by the D-pad/stick and A/B with Xbox and PlayStation pads. Automatic mode picks it whenever a controller is connected. (#41, #42, #47) diff --git a/CHANGELOG.md b/CHANGELOG.md index e65b71f..e6a32b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CHANGELOG.en-US.md`. Antes de publicar uma versão, adicione uma seção `## [VERSÃO]` com o changelog daquela versão **nos dois arquivos**. O workflow publica as duas seções correspondentes à tag na mesma release e falha se faltar alguma. +## [Unreleased] +### Novidades +- Ajustes → TV: o modo console liga a TV e troca para a entrada HDMI do PC, e pode colocá-la em espera ao restaurar. Primeiro caminho: Google TV / Android TV pela rede (ADB), sem instalar nada no PC. (#93) +- Controle da TV também para LG webOS: liga por Wake-on-LAN e troca a entrada pela rede, com pareamento na primeira vez. (#94) + ## [1.5.0] ### Novidades - Interface Console: tela inicial e Ajustes em tela cheia, pensados para o controle (direcional/analógico, A/B) com controles Xbox e PlayStation. O modo Automático escolhe essa interface sempre que há um controle conectado. (#41, #42, #47) diff --git a/README.md b/README.md index 7df11d2..ab23032 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Prefer the couch? With the app in the tray, hold the controller's Home button an - Hide the other screens by **disconnecting** them, **black overlays** or **DDC/CI** - Per-screen **resolution and refresh rate**, **HDR**, **VRR** and an **FPS limit** (RTSS) while you play - Audio goes to the TV (or any output) and comes back afterwards +- [Turns the TV on and switches it to the PC's input](docs/GUIDE.md#tv-control) (Google TV / Android TV or LG webOS over the network), and can put it in standby afterwards - Launches **Steam Big Picture**, **Playnite fullscreen** or **Xbox** mode - Two interfaces: **Desktop** (mouse) and **Console** (full screen, Xbox and PlayStation pads) - Automation with `consolemode://start` / `stop` / `menu` links (Stream Deck, scripts) and a [local control API](docs/GUIDE.md#local-control-api) diff --git a/README.pt-BR.md b/README.pt-BR.md index 240d815..ceebdf7 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -40,6 +40,7 @@ Prefere o sofá? Com o app na bandeja, segure o botão Home do controle e ele co - Esconde as outras telas **desconectando**, com **cortinas pretas** ou por **DDC/CI** - **Resolução e Hz** por tela, **HDR**, **VRR** e **limite de FPS** (RTSS) enquanto você joga - O áudio vai para a TV (ou qualquer saída) e volta depois +- [Liga a TV e troca para a entrada do PC](docs/GUIDE.pt-BR.md#controle-da-tv) (Google TV / Android TV ou LG webOS pela rede), e pode colocá-la em espera no fim - Abre **Steam Big Picture**, **Playnite em tela cheia** ou o **Modo Xbox** - Duas interfaces: **Desktop** (mouse) e **Console** (tela cheia, controles Xbox e PlayStation) - Automação com os links `consolemode://start` / `stop` / `menu` (Stream Deck, scripts) e uma [API de controle local](docs/GUIDE.pt-BR.md#api-de-controle-local) diff --git a/docs/GUIDE.md b/docs/GUIDE.md index d329fe5..4099344 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -23,6 +23,35 @@ You can also restore anytime from the tray (*Restore setup* / *Show window*). Wi `start`, `stop` and `show` do exactly what the matching `consolemode://` link does, then reply once the app has settled (`ok:false` with an `error` if console mode didn't start or the restore didn't finish). `status` only reads. Only the signed-in user and LocalSystem can connect; nothing is exposed to the network. +## TV control + +Settings → **TV** can turn the TV on and switch it to the PC's HDMI input when console mode starts, and optionally put it back in standby after the desk is restored. A TV that doesn't answer never blocks console mode: the app logs it and waits for the game screen as usual. + +Most PC graphics cards can't send HDMI-CEC, so the app talks to the TV over the network instead: + +### Google TV / Android TV + +TCL, Sony, Hisense, Philips and other TVs running Google TV or Android TV, through ADB (the Android debugging protocol). Nothing to install on the PC. + +1. On the TV: **Settings → System → About**, press **Android TV OS build** 7 times to unlock Developer options. +2. **Settings → System → Developer options**: turn on **USB debugging** (on some TVs, **Network debugging** / **ADB over network**). +3. In Console Mode, pick *Google TV / Android TV*, enter the TV's IP (Settings → Network on the TV; reserve it in your router) and the HDMI input the PC uses. +4. Press **Test now**. The TV asks "Allow debugging from this computer?": tick **Always allow** and press **Allow**. + +Waking uses the Android wake-up key, then the **HDMI 1-4** key. If your TV ignores that key, set **Input command** to any Android shell command that opens the PC's input. If the TV drops off the network in standby, fill in its **MAC address** so the app sends Wake-on-LAN first (the TV's "Wake on network" / "Wake on Wi-Fi" option must be on). + +"Wireless debugging" with a pairing code (Android 11+ phones) is a different, TLS-wrapped protocol and isn't supported: use USB / network debugging. + +### LG webOS + +LG TVs from 2014 on, over the local network (the same API the LG ThinQ app uses). Nothing to install on the PC. + +1. On the TV, turn on **Turn on via Wi-Fi** (older models: **LG Connect Apps** / **Mobile TV On**) so it can be woken from standby. +2. In Console Mode, pick *LG webOS*, enter the TV's IP, its **MAC address** (Settings → Network → Wi-Fi/Wired → Advanced on the TV) and the HDMI input the PC uses. +3. Press **Test now** and accept the request on the TV. The key the TV returns is kept, so it only asks once. + +The TV is powered on with Wake-on-LAN, then switched with `ssap://tv/switchInput` (`HDMI_1`-`HDMI_4`); standby uses `ssap://system/turnOff`. The app tries `ws://` on port 3000 and falls back to `wss://` on 3001 (newer firmware, self-signed certificate). + ## Optional extras ### HDR diff --git a/docs/GUIDE.pt-BR.md b/docs/GUIDE.pt-BR.md index 8ea13ef..0d04f9a 100644 --- a/docs/GUIDE.pt-BR.md +++ b/docs/GUIDE.pt-BR.md @@ -23,6 +23,35 @@ Os links `consolemode://` não dão resposta. Ferramentas que precisam de uma `start`, `stop` e `show` fazem o mesmo que o link `consolemode://` correspondente e respondem quando o app terminou (`ok:false` com `error` se o modo console não entrou ou a restauração não terminou). `status` só consulta. Só o usuário logado e o LocalSystem conseguem conectar; nada fica exposto na rede. +## Controle da TV + +Ajustes → **TV** pode ligar a TV e trocar para a entrada HDMI do PC quando o modo console começa e, se você quiser, colocá-la em espera depois que a mesa volta. Uma TV que não responde nunca trava o modo console: o app registra no log e espera a tela de jogo como sempre. + +A maioria das placas de vídeo de PC não envia HDMI-CEC, então o app fala com a TV pela rede: + +### Google TV / Android TV + +TVs TCL, Sony, Hisense, Philips e outras com Google TV ou Android TV, via ADB (o protocolo de depuração do Android). Nada para instalar no PC. + +1. Na TV: **Configurações → Sistema → Sobre**, aperte **Build do Android TV OS** 7 vezes para liberar as Opções do desenvolvedor. +2. **Configurações → Sistema → Opções do desenvolvedor**: ative **Depuração USB** (em algumas TVs, **Depuração pela rede** / **ADB pela rede**). +3. No Console Mode, escolha *Google TV / Android TV*, informe o IP da TV (Configurações → Rede na TV; reserve esse IP no roteador) e a entrada HDMI do PC. +4. Aperte **Testar agora**. A TV pergunta "Permitir depuração deste computador?": marque **Sempre permitir** e aperte **Permitir**. + +Para acordar, o app usa a tecla de despertar do Android e depois a tecla **HDMI 1-4**. Se a sua TV ignorar essa tecla, preencha **Comando da entrada** com qualquer comando de shell do Android que abra a entrada do PC. Se a TV sai da rede em espera, informe o **endereço MAC** para o app mandar Wake-on-LAN antes (a opção "Ligar pela rede" / "Wake on Wi-Fi" da TV precisa estar ativa). + +A "Depuração sem fio" com código de pareamento (Android 11+ em celulares) é outro protocolo, com TLS, e não é suportada: use a depuração USB / pela rede. + +### LG webOS + +TVs LG de 2014 em diante, pela rede local (a mesma API do app LG ThinQ). Nada para instalar no PC. + +1. Na TV, ative **Ligar via Wi-Fi** (em modelos antigos: **LG Connect Apps** / **Mobile TV On**) para ela poder ser acordada da espera. +2. No Console Mode, escolha *LG webOS*, informe o IP da TV, o **endereço MAC** (Configurações → Rede → Wi-Fi/Cabeada → Avançado na TV) e a entrada HDMI do PC. +3. Aperte **Testar agora** e aceite o pedido na TV. A chave que a TV devolve fica guardada, então ela só pergunta uma vez. + +A TV liga por Wake-on-LAN e troca de entrada com `ssap://tv/switchInput` (`HDMI_1` a `HDMI_4`); a espera usa `ssap://system/turnOff`. O app tenta `ws://` na porta 3000 e, se não der, `wss://` na 3001 (firmwares novos, certificado autoassinado). + ## Extras opcionais ### HDR diff --git a/docs/TESTING.md b/docs/TESTING.md index 0488907..16d4a61 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -105,6 +105,32 @@ Pré-condições: Ajustes → Interface = **Automático** (padrão). - [ ] "Copiar diagnóstico" cola dispositivos + amostra; o `consolemode.log` tem a linha `Controles: …` de abertura com o DualSense listado. Resultado: ______ - [ ] Desconectar e reconectar durante o teste: a lista atualiza e a leitura continua. Resultado: ______ +## 3c. Controle da TV: Google TV / Android TV (issue #75) + +Pré-condições: TV Google TV / Android TV na mesma rede do PC, com **Depuração USB** (ou "Depuração pela rede") ativada nas Opções do desenvolvedor; Ajustes → TV → *Google TV / Android TV* com o IP e a entrada HDMI do PC. + +- [ ] **Testar agora** na primeira vez: a TV pede "Permitir depuração"; com "Sempre permitir" + "Permitir", a TV acorda e vai para a entrada do PC. O status mostra sucesso. Resultado: ______ +- [ ] **Testar agora** de novo: não pede mais permissão; com a TV em espera, ela liga e troca a entrada. Resultado: ______ +- [ ] Recusar o pedido na TV (ou esperar 60 s): o status explica que a TV não autorizou este PC. Resultado: ______ +- [ ] IP errado / TV fora da rede: o status diz que a TV não respondeu, em poucos segundos. Resultado: ______ +- [ ] **Jogar agora** com a TV em espera: ela liga, troca para o PC e o modo console segue normal. O log tem `TV: ligar (androidTv) ok`. Resultado: ______ +- [ ] Com a TV desligada da tomada: o modo console não trava; o log tem `TV: ligar ... falhou` e o fluxo segue (a TV não aparece, o app avisa como antes). Resultado: ______ +- [ ] TV que some da rede em espera + **MAC** preenchido: o log mostra `enviando Wake-on-LAN` e a TV liga (com "Ligar pela rede" ativo na TV). Resultado: ______ +- [ ] **Colocar a TV em espera ao restaurar** ligado: ao sair do Big Picture, a mesa volta e depois a TV entra em espera. Desligado (padrão): a TV continua ligada. Resultado: ______ +- [ ] TV que ignora a tecla HDMI: preencher **Comando da entrada** faz a troca funcionar. Resultado: ______ +- [ ] Escolher "Não controlar": os campos somem e nada é enviado à TV. Resultado: ______ + +## 3d. Controle da TV: LG webOS (issue #75) + +Pré-condições: TV LG webOS na mesma rede, com "Ligar via Wi-Fi" (ou "LG Connect Apps") ativo; Ajustes → TV → *LG webOS* com IP, MAC e a entrada HDMI do PC. + +- [ ] **Testar agora** com a TV ligada: aparece o pedido de autorização; ao aceitar, a TV troca para a entrada do PC. Um arquivo `webos-.key` aparece na pasta de dados. Resultado: ______ +- [ ] **Testar agora** de novo: não pede mais autorização. Resultado: ______ +- [ ] Com a TV em espera: o log mostra `enviando Wake-on-LAN`, a TV liga e troca a entrada. Resultado: ______ +- [ ] Recusar o pedido na TV: o status explica que a TV não autorizou. Resultado: ______ +- [ ] Firmware novo (só `wss://3001`): a conexão cai para a porta segura e funciona. Resultado: ______ +- [ ] "Colocar a TV em espera ao restaurar": a TV desliga depois que a mesa volta. Resultado: ______ + ## 4. Regressões - [ ] Interface Desktop: mapa de telas, `Segmented`, chips, tour de 3 passos e Ajustes continuam como antes. Resultado: ______ diff --git a/src/ConsoleMode/Models/Models.cs b/src/ConsoleMode/Models/Models.cs index ab0cb09..19ca9f6 100644 --- a/src/ConsoleMode/Models/Models.cs +++ b/src/ConsoleMode/Models/Models.cs @@ -51,11 +51,42 @@ public sealed class AppConfig /// A version the user chose to skip; newer ones are still announced. public string SkippedUpdateVersion { get; set; } = ""; + /// Turning the TV on / to the PC's input when console mode starts (issue #75). + public TvControlConfig Tv { get; set; } = new(); + [JsonIgnore] public string SetupKey => $"{FocusMonitor}|{(MonitorModes.TryGetValue(FocusMonitor, out var mode) ? mode.Key : "current")}"; } +public sealed class TvControlConfig +{ + public const string None = "none"; + public const string AndroidTv = "androidTv"; + public const string WebOs = "webos"; + + /// "none" | "androidTv" | "webos". + public string Provider { get; set; } = None; + + /// The TV's IP address or host name, optionally with ":port". + public string Host { get; set; } = ""; + + /// For Wake-on-LAN when the TV is in deep standby; empty = don't send it. + public string MacAddress { get; set; } = ""; + + /// HDMI input the PC is plugged into (1-4). + public int HdmiInput { get; set; } = 1; + + /// Android TV: shell command that switches to the PC's input, for TVs that ignore the HDMI key codes. + public string InputCommand { get; set; } = ""; + + /// Put the TV in standby after the desk is restored. Off by default. + public bool TurnOffOnRestore { get; set; } + + [JsonIgnore] + public bool IsEnabled => !string.IsNullOrWhiteSpace(Provider) && Provider != None; +} + public sealed class SavedDisplayMode { public int Width { get; set; } @@ -231,6 +262,8 @@ public sealed class ScreenRect public sealed class ConsoleRuntimeState { public bool IsActive { get; set; } + /// TV control used for this session, for the optional standby on restore. + public TvControlConfig? Tv { get; set; } public bool ShouldExit { get; set; } public bool RestoreInProgress { get; set; } public bool SteamMoved { get; set; } diff --git a/src/ConsoleMode/Resources/Strings.en-US.json b/src/ConsoleMode/Resources/Strings.en-US.json index 2174c40..a9ecc72 100644 --- a/src/ConsoleMode/Resources/Strings.en-US.json +++ b/src/ConsoleMode/Resources/Strings.en-US.json @@ -96,6 +96,36 @@ "TvConfirmationCard": "TV confirmation", "TvConfirmationDescription": "The first time you use a new gaming display, the TV asks you to confirm that you can see the image. Respond with a mouse, keyboard, or controller (Xbox: A/B · PlayStation: ✕/○). Test it here without changing your displays.", "TestNow": "Test now", + "TvSection": "TV", + "TvControlCard": "Turn the TV on and switch input", + "TvProviderNone": "Don't control it", + "TvProviderAndroid": "Google TV / Android TV (network)", + "TvProviderWebOs": "LG webOS (network)", + "TvWebOsDescription": "Over the network, nothing to install. To turn the TV on from standby, enter its MAC and turn on \"Turn on via Wi-Fi\" (or \"LG Connect Apps\") on the TV. The first time, accept the request on the TV.", + "TvWebOsNotAllowed": "The LG TV didn't allow Console Mode. Press Test again and accept the request on the TV.", + "TvWebOsRequestFailed": "The LG TV refused the command: {0}", + "TvNoneDescription": "When console mode starts, the app turns the TV on and switches it to the PC's input; on restore it can put it in standby.", + "TvAndroidDescription": "Over the network, nothing to install. On the TV: Settings → System → Developer options → turn on \"USB debugging\" (or \"Network debugging\"). The first time, accept \"Allow debugging\" on the TV.", + "TvHostCard": "TV address", + "TvHostDescription": "The TV's IP on your network (Settings → Network). Tip: reserve it in your router so it doesn't change.", + "TvMacCard": "MAC address (optional)", + "TvMacDescription": "To wake the TV from deep standby (Wake-on-LAN). Leave it empty if the TV answers while off.", + "TvHdmiCard": "PC input", + "TvHdmiDescription": "The TV's HDMI port the PC is plugged into.", + "TvInputCommandCard": "Input command (advanced)", + "TvInputCommandDescription": "Only if the TV ignores the HDMI key: an Android shell command that switches to the PC's input. Empty = the HDMI key chosen above.", + "TvTurnOffCard": "Put the TV in standby on restore", + "TvTurnOffDescription": "After the desk screens are back.", + "TvTestCard": "Test the TV", + "TvTestDescription": "Turns the TV on and switches to the PC's input now, without touching the screens. The first time, it pairs with the TV.", + "TvTesting": "Talking to the TV… If it asks, accept on the TV screen.", + "TvTestSuccess": "The TV answered: it should now be on, on the PC's input.", + "TvTestFailure": "Could not talk to the TV: {0}", + "TvNotConfigured": "Choose how to control the TV.", + "TvHostMissing": "Enter the TV's IP address.", + "TvUnreachable": "The TV at {0} didn't answer. Check the IP, that the TV is on the same network and that debugging is on.", + "TvAdbNotAllowed": "The TV didn't allow this PC. Press Test again and choose \"Always allow\" + \"Allow\" on the TV.", + "TvAdbTlsUnsupported": "The TV only accepts \"Wireless debugging\" (pairing with a code). Turn on \"USB debugging\" / \"Network debugging\" in Developer options.", "ControllerTestCard": "Test controller", "ControllerTestDescription": "Shows live what Windows delivers from each controller: source, buttons by index, D-pad and sticks. Use it when a pad shows up but does nothing.", "ControllerTestSteamHint": "Steam running with PlayStation support captures the pad and the app gets nothing. Close Steam (or turn off PlayStation support in Steam Input) and test again.", diff --git a/src/ConsoleMode/Resources/Strings.es-ES.json b/src/ConsoleMode/Resources/Strings.es-ES.json index c3d835b..912d322 100644 --- a/src/ConsoleMode/Resources/Strings.es-ES.json +++ b/src/ConsoleMode/Resources/Strings.es-ES.json @@ -96,6 +96,36 @@ "TvConfirmationCard": "Confirmación en la TV", "TvConfirmationDescription": "La primera vez con una pantalla de juego nueva, la TV pide que confirmes que ves la imagen. Responde con ratón, teclado o mando (Xbox: A/B · PlayStation: ✕/○). Pruébalo aquí sin cambiar tus pantallas.", "TestNow": "Probar ahora", + "TvSection": "TV", + "TvControlCard": "Encender la TV y cambiar la entrada", + "TvProviderNone": "No controlarla", + "TvProviderAndroid": "Google TV / Android TV (red)", + "TvProviderWebOs": "LG webOS (red)", + "TvWebOsDescription": "Por la red, sin instalar nada. Para encender la TV desde el reposo, indica su MAC y activa en la TV \"Encender mediante Wi-Fi\" (o \"LG Connect Apps\"). La primera vez, acepta la solicitud en la TV.", + "TvWebOsNotAllowed": "La TV LG no autorizó Console Mode. Pulsa Probar de nuevo y acepta la solicitud en la TV.", + "TvWebOsRequestFailed": "La TV LG rechazó el comando: {0}", + "TvNoneDescription": "Al entrar en el modo consola, la app enciende la TV y cambia a la entrada del PC; al restaurar, puede ponerla en reposo.", + "TvAndroidDescription": "Por la red, sin instalar nada. En la TV: Ajustes → Sistema → Opciones de desarrollador → activa \"Depuración USB\" (o \"Depuración por red\"). La primera vez, acepta \"Permitir depuración\" en la TV.", + "TvHostCard": "Dirección de la TV", + "TvHostDescription": "IP de la TV en tu red (Ajustes → Red). Consejo: resérvala en el router para que no cambie.", + "TvMacCard": "Dirección MAC (opcional)", + "TvMacDescription": "Para despertar la TV del reposo profundo (Wake-on-LAN). Déjalo vacío si la TV responde apagada.", + "TvHdmiCard": "Entrada del PC", + "TvHdmiDescription": "Puerto HDMI de la TV donde está conectado el PC.", + "TvInputCommandCard": "Comando de la entrada (avanzado)", + "TvInputCommandDescription": "Solo si la TV ignora la tecla HDMI: un comando de shell de Android que cambia a la entrada del PC. Vacío = la tecla HDMI elegida arriba.", + "TvTurnOffCard": "Poner la TV en reposo al restaurar", + "TvTurnOffDescription": "Después de que vuelvan las pantallas del escritorio.", + "TvTestCard": "Probar la TV", + "TvTestDescription": "Enciende la TV y cambia a la entrada del PC ahora, sin tocar las pantallas. La primera vez, se empareja con la TV.", + "TvTesting": "Hablando con la TV… Si lo pide, acepta en la pantalla de la TV.", + "TvTestSuccess": "La TV respondió: debería estar encendida en la entrada del PC.", + "TvTestFailure": "No se pudo hablar con la TV: {0}", + "TvNotConfigured": "Elige cómo controlar la TV.", + "TvHostMissing": "Indica la dirección IP de la TV.", + "TvUnreachable": "La TV en {0} no respondió. Revisa la IP, que la TV esté en la misma red y que la depuración esté activada.", + "TvAdbNotAllowed": "La TV no autorizó este PC. Pulsa Probar de nuevo y elige \"Permitir siempre\" + \"Permitir\" en la TV.", + "TvAdbTlsUnsupported": "La TV solo acepta \"Depuración inalámbrica\" (emparejamiento con código). Activa \"Depuración USB\" / \"Depuración por red\" en Opciones de desarrollador.", "ControllerTestCard": "Probar mando", "ControllerTestDescription": "Muestra en vivo lo que Windows entrega de cada mando: origen, botones por índice, cruceta y sticks. Úsalo si el mando aparece pero no responde.", "ControllerTestSteamHint": "Steam abierto con soporte para PlayStation captura el mando y la aplicación no recibe nada. Cierra Steam (o desactiva el soporte para PlayStation en Steam Input) y prueba de nuevo.", diff --git a/src/ConsoleMode/Resources/Strings.pt-BR.json b/src/ConsoleMode/Resources/Strings.pt-BR.json index 8b74215..ed7fc0c 100644 --- a/src/ConsoleMode/Resources/Strings.pt-BR.json +++ b/src/ConsoleMode/Resources/Strings.pt-BR.json @@ -96,6 +96,36 @@ "TvConfirmationCard": "Confirmação na TV", "TvConfirmationDescription": "Na primeira vez com uma tela de jogo nova, a TV pergunta se você está vendo a imagem. Responda com mouse, teclado ou controle (Xbox: A/B · PlayStation: ✕/○). Teste aqui sem mexer nas telas.", "TestNow": "Testar agora", + "TvSection": "TV", + "TvControlCard": "Ligar a TV e trocar a entrada", + "TvProviderNone": "Não controlar", + "TvProviderAndroid": "Google TV / Android TV (rede)", + "TvProviderWebOs": "LG webOS (rede)", + "TvWebOsDescription": "Pela rede, sem instalar nada. Para ligar a TV em espera, informe o MAC e ative na TV \"Ligar via Wi-Fi\" (ou \"LG Connect Apps\"). Na primeira vez, aceite o pedido na TV.", + "TvWebOsNotAllowed": "A TV LG não autorizou o Console Mode. Toque em Testar de novo e aceite o pedido na TV.", + "TvWebOsRequestFailed": "A TV LG recusou o comando: {0}", + "TvNoneDescription": "Ao entrar no modo console, o app liga a TV e troca para a entrada do PC; ao restaurar, pode colocá-la em espera.", + "TvAndroidDescription": "Pela rede, sem instalar nada. Na TV: Configurações → Sistema → Opções do desenvolvedor → ative \"Depuração USB\" (ou \"Depuração pela rede\"). Na primeira vez, aceite \"Permitir depuração\" na TV.", + "TvHostCard": "Endereço da TV", + "TvHostDescription": "IP da TV na sua rede (Configurações → Rede). Dica: reserve esse IP no roteador para ele não mudar.", + "TvMacCard": "Endereço MAC (opcional)", + "TvMacDescription": "Para acordar a TV em espera profunda (Wake-on-LAN). Deixe vazio se a TV responde mesmo desligada.", + "TvHdmiCard": "Entrada do PC", + "TvHdmiDescription": "Porta HDMI da TV onde o PC está ligado.", + "TvInputCommandCard": "Comando da entrada (avançado)", + "TvInputCommandDescription": "Só se a TV ignorar a tecla HDMI: um comando de shell do Android que troca para a entrada do PC. Vazio = tecla HDMI escolhida acima.", + "TvTurnOffCard": "Colocar a TV em espera ao restaurar", + "TvTurnOffDescription": "Depois que as telas da mesa voltam.", + "TvTestCard": "Testar a TV", + "TvTestDescription": "Liga a TV e troca para a entrada do PC agora, sem mexer nas telas. Na primeira vez, pareia com a TV.", + "TvTesting": "Falando com a TV… Se ela pedir, aceite na tela da TV.", + "TvTestSuccess": "A TV respondeu: ela deve estar ligada na entrada do PC.", + "TvTestFailure": "Não foi possível falar com a TV: {0}", + "TvNotConfigured": "Escolha como controlar a TV.", + "TvHostMissing": "Informe o endereço IP da TV.", + "TvUnreachable": "A TV em {0} não respondeu. Confira o IP, se a TV está na mesma rede e se a depuração está ativada.", + "TvAdbNotAllowed": "A TV não autorizou este PC. Toque em Testar de novo e escolha \"Sempre permitir\" + \"Permitir\" na TV.", + "TvAdbTlsUnsupported": "A TV só aceita \"Depuração sem fio\" (com pareamento por código). Ative \"Depuração USB\" / \"Depuração pela rede\" nas Opções do desenvolvedor.", "ControllerTestCard": "Testar controle", "ControllerTestDescription": "Mostra ao vivo o que o Windows entrega de cada controle: origem, botões por índice, D-pad e analógicos. Use se o controle aparece mas não responde.", "ControllerTestSteamHint": "Steam aberto com suporte a PlayStation captura o controle e o app não recebe nada. Feche o Steam (ou desligue o suporte a PlayStation no Steam Input) e teste de novo.", diff --git a/src/ConsoleMode/Services/ConsoleEngine.cs b/src/ConsoleMode/Services/ConsoleEngine.cs index e247252..1de312b 100644 --- a/src/ConsoleMode/Services/ConsoleEngine.cs +++ b/src/ConsoleMode/Services/ConsoleEngine.cs @@ -1,5 +1,6 @@ using ConsoleMode.Models; using ConsoleMode.Native; +using ConsoleMode.Services.Tv; namespace ConsoleMode.Services; @@ -11,6 +12,7 @@ public sealed class ConsoleEngine public RtssService Rtss { get; } = new(); public LaunchService Launch { get; } = new(); public VideoFeaturesService Video { get; } = new(); + public TvControlService Tv { get; } = new(); public const string AudioOnConnectId = "__on_connect__"; @@ -63,6 +65,11 @@ public void Start(AppConfig config, MonitorInfo? focusInfo, Func m.Name == config.FocusMonitor) ?? focusInfo; @@ -300,6 +307,7 @@ public void Stop() Rtss.Restore(State); Monitors.ClearCache(); Audio.ClearCache(); + if (State.Tv is { IsEnabled: true, TurnOffOnRestore: true } tv) Tv.TurnOff(tv); AppLog.Write("Stop-ConsoleMode: restauração concluída"); } catch (Exception ex) @@ -312,6 +320,7 @@ public void Stop() State.RestoreInProgress = false; State.IsActive = false; State.ShouldExit = false; + State.Tv = null; State.SteamMoved = false; State.MoveCount = 0; State.HasAppeared = false; @@ -391,7 +400,8 @@ private AppConfig ResolveMonitorNames(AppConfig config) TourDone = config.TourDone, ConfirmedSetup = config.ConfirmedSetup, CheckUpdates = config.CheckUpdates, - SkippedUpdateVersion = config.SkippedUpdateVersion + SkippedUpdateVersion = config.SkippedUpdateVersion, + Tv = config.Tv }; } diff --git a/src/ConsoleMode/Services/LocalizationService.cs b/src/ConsoleMode/Services/LocalizationService.cs index 9114be3..3ef554e 100644 --- a/src/ConsoleMode/Services/LocalizationService.cs +++ b/src/ConsoleMode/Services/LocalizationService.cs @@ -198,6 +198,20 @@ internal void NotifyAll() public string TvConfirmationCard => LocalizationService.Get(nameof(TvConfirmationCard)); public string TvConfirmationDescription => LocalizationService.Get(nameof(TvConfirmationDescription)); public string TestNow => LocalizationService.Get(nameof(TestNow)); + public string TvSection => LocalizationService.Get(nameof(TvSection)); + public string TvControlCard => LocalizationService.Get(nameof(TvControlCard)); + public string TvHostCard => LocalizationService.Get(nameof(TvHostCard)); + public string TvHostDescription => LocalizationService.Get(nameof(TvHostDescription)); + public string TvMacCard => LocalizationService.Get(nameof(TvMacCard)); + public string TvMacDescription => LocalizationService.Get(nameof(TvMacDescription)); + public string TvHdmiCard => LocalizationService.Get(nameof(TvHdmiCard)); + public string TvHdmiDescription => LocalizationService.Get(nameof(TvHdmiDescription)); + public string TvInputCommandCard => LocalizationService.Get(nameof(TvInputCommandCard)); + public string TvInputCommandDescription => LocalizationService.Get(nameof(TvInputCommandDescription)); + public string TvTurnOffCard => LocalizationService.Get(nameof(TvTurnOffCard)); + public string TvTurnOffDescription => LocalizationService.Get(nameof(TvTurnOffDescription)); + public string TvTestCard => LocalizationService.Get(nameof(TvTestCard)); + public string TvTestDescription => LocalizationService.Get(nameof(TvTestDescription)); public string TutorialCard => LocalizationService.Get(nameof(TutorialCard)); public string TutorialDescription => LocalizationService.Get(nameof(TutorialDescription)); public string SeeAgain => LocalizationService.Get(nameof(SeeAgain)); diff --git a/src/ConsoleMode/Services/Tv/AdbClient.cs b/src/ConsoleMode/Services/Tv/AdbClient.cs new file mode 100644 index 0000000..69ab23e --- /dev/null +++ b/src/ConsoleMode/Services/Tv/AdbClient.cs @@ -0,0 +1,185 @@ +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; + +namespace ConsoleMode.Services.Tv; + +/// +/// Minimal ADB client over TCP ("ADB debugging" / "network debugging" on the TV, port 5555): +/// connect, authenticate with this PC's key and run shell commands. +/// The first time, the TV asks "Allow debugging from this computer?"; with "Always allow" +/// ticked the key is remembered and later connections go straight through. +/// +public sealed class AdbClient : IAsyncDisposable +{ + private readonly TcpClient _tcp; + private readonly NetworkStream _stream; + private uint _nextLocalId = 1; + + private AdbClient(TcpClient tcp) + { + _tcp = tcp; + _stream = tcp.GetStream(); + } + + /// How long to wait for the user to accept the prompt on the TV. + public static async Task ConnectAsync(string host, int port, TimeSpan approvalTimeout, CancellationToken ct) + { + var tcp = new TcpClient { NoDelay = true }; + try + { + using (var connect = CancellationTokenSource.CreateLinkedTokenSource(ct)) + { + connect.CancelAfter(TimeSpan.FromSeconds(3)); + try + { + await tcp.ConnectAsync(host, port, connect.Token); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + throw new SocketException((int)SocketError.TimedOut); + } + } + var client = new AdbClient(tcp); + await client.HandshakeAsync(approvalTimeout, ct); + return client; + } + catch + { + tcp.Dispose(); + throw; + } + } + + public async Task ShellAsync(string command, CancellationToken ct) + { + var localId = _nextLocalId++; + uint remoteId = 0; + var output = new StringBuilder(); + await SendAsync(AdbProtocol.Open, localId, 0, AdbProtocol.ShellService(command), ct); + + while (true) + { + var message = await ReadAsync(ct); + if (message.Arg1 != localId) continue; + switch (message.Command) + { + case AdbProtocol.Okay: + remoteId = message.Arg0; + break; + case AdbProtocol.Write: + output.Append(Encoding.UTF8.GetString(message.Data)); + await SendAsync(AdbProtocol.Okay, localId, message.Arg0, null, ct); + break; + case AdbProtocol.Close: + if (remoteId != 0) await SendAsync(AdbProtocol.Close, localId, remoteId, null, ct); + return output.ToString(); + } + } + } + + public ValueTask DisposeAsync() + { + _tcp.Dispose(); + return ValueTask.CompletedTask; + } + + private async Task HandshakeAsync(TimeSpan approvalTimeout, CancellationToken ct) + { + using var key = AdbKeyStore.LoadOrCreate(); + await SendAsync(AdbProtocol.Connect, AdbProtocol.Version, AdbProtocol.MaxPayload, AdbProtocol.ConnectBanner(), ct); + + var sentSignature = false; + var sentPublicKey = false; + while (true) + { + // After sending our public key the TV waits for someone to press "Allow". + using var wait = CancellationTokenSource.CreateLinkedTokenSource(ct); + wait.CancelAfter(sentPublicKey ? approvalTimeout : TimeSpan.FromSeconds(5)); + AdbProtocol.Message message; + try + { + message = await ReadAsync(wait.Token); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + if (sentPublicKey) throw new TvControlException(LocalizationService.Get("TvAdbNotAllowed")); + throw new IOException("ADB: a TV não respondeu ao handshake"); + } + + switch (message.Command) + { + case AdbProtocol.Connect: + return; + case AdbProtocol.StartTls: + // "Wireless debugging" (Android 11+ pairing) wraps ADB in TLS; the classic + // "ADB debugging"/"network debugging" on port 5555 doesn't. + throw new TvControlException(LocalizationService.Get("TvAdbTlsUnsupported")); + case AdbProtocol.Auth when message.Arg0 == AdbProtocol.AuthToken: + if (!sentSignature) + { + await SendAsync(AdbProtocol.Auth, AdbProtocol.AuthSignature, 0, AdbProtocol.SignToken(key, message.Data), ct); + sentSignature = true; + } + else if (!sentPublicKey) + { + AppLog.Write("TV: chave deste PC ainda não autorizada; aguardando \"Permitir\" na TV"); + var publicKey = AdbProtocol.EncodePublicKey(key.ExportParameters(false), $"ConsoleMode@{Environment.MachineName}"); + await SendAsync(AdbProtocol.Auth, AdbProtocol.AuthRsaPublicKey, 0, publicKey, ct); + sentPublicKey = true; + } + else + { + throw new TvControlException(LocalizationService.Get("TvAdbNotAllowed")); + } + break; + } + } + } + + private async Task SendAsync(uint command, uint arg0, uint arg1, byte[]? data, CancellationToken ct) => + await _stream.WriteAsync(AdbProtocol.Encode(command, arg0, arg1, data), ct); + + private async Task ReadAsync(CancellationToken ct) + { + var header = new byte[AdbProtocol.HeaderSize]; + await _stream.ReadExactlyAsync(header, ct); + var (command, arg0, arg1, length) = AdbProtocol.DecodeHeader(header); + var data = new byte[length]; + if (length > 0) await _stream.ReadExactlyAsync(data, ct); + return new AdbProtocol.Message(command, arg0, arg1, data); + } +} + +/// This PC's ADB key (RSA-2048), kept next to the config so the TV only asks once. +internal static class AdbKeyStore +{ + private static readonly object Gate = new(); + + public static RSA LoadOrCreate() + { + lock (Gate) + { + var path = Path.Combine(AppPaths.DataDir, "adbkey.pem"); + if (File.Exists(path)) + { + var saved = RSA.Create(); + try + { + saved.ImportFromPem(File.ReadAllText(path)); + if (saved.KeySize == 2048) return saved; + } + catch (Exception ex) + { + AppLog.Write($"TV: chave ADB ilegível, criando outra: {ex.Message}"); + } + saved.Dispose(); + } + + var created = RSA.Create(2048); + File.WriteAllText(path, created.ExportPkcs8PrivateKeyPem()); + AppLog.Write("TV: chave ADB criada"); + return created; + } + } +} diff --git a/src/ConsoleMode/Services/Tv/AdbProtocol.cs b/src/ConsoleMode/Services/Tv/AdbProtocol.cs new file mode 100644 index 0000000..c055c67 --- /dev/null +++ b/src/ConsoleMode/Services/Tv/AdbProtocol.cs @@ -0,0 +1,139 @@ +using System.Buffers.Binary; +using System.Numerics; +using System.Security.Cryptography; +using System.Text; + +namespace ConsoleMode.Services.Tv; + +/// +/// The pieces of the ADB wire protocol (the one adb connect speaks to port 5555) that +/// Console Mode needs to send key presses to an Android TV / Google TV, without shipping adb.exe. +/// Pure logic: framing, the auth signature and Android's public key format. +/// +public static class AdbProtocol +{ + public const uint Connect = 0x4e584e43; // CNXN + public const uint Auth = 0x48545541; // AUTH + public const uint Open = 0x4e45504f; // OPEN + public const uint Okay = 0x59414b4f; // OKAY + public const uint Close = 0x45534c43; // CLSE + public const uint Write = 0x45545257; // WRTE + public const uint StartTls = 0x534c5453; // STLS + + public const uint AuthToken = 1; + public const uint AuthSignature = 2; + public const uint AuthRsaPublicKey = 3; + + /// Version with the payload checksum, which every device still accepts. + public const uint Version = 0x01000000; + public const uint MaxPayload = 256 * 1024; + public const int HeaderSize = 24; + public const int DefaultPort = 5555; + + // android.view.KeyEvent codes. + public const int KeyWakeUp = 224; + public const int KeySleep = 223; + public const int KeyTvInputHdmi1 = 243; + + public readonly record struct Message(uint Command, uint Arg0, uint Arg1, byte[] Data); + + public static byte[] Encode(uint command, uint arg0, uint arg1, byte[]? data = null) + { + data ??= []; + var buffer = new byte[HeaderSize + data.Length]; + var span = buffer.AsSpan(); + BinaryPrimitives.WriteUInt32LittleEndian(span[0..], command); + BinaryPrimitives.WriteUInt32LittleEndian(span[4..], arg0); + BinaryPrimitives.WriteUInt32LittleEndian(span[8..], arg1); + BinaryPrimitives.WriteUInt32LittleEndian(span[12..], (uint)data.Length); + BinaryPrimitives.WriteUInt32LittleEndian(span[16..], Checksum(data)); + BinaryPrimitives.WriteUInt32LittleEndian(span[20..], command ^ 0xFFFFFFFF); + data.CopyTo(buffer, HeaderSize); + return buffer; + } + + /// Reads a header; returns the command, args and payload length. + public static (uint Command, uint Arg0, uint Arg1, int Length) DecodeHeader(ReadOnlySpan header) + { + if (header.Length < HeaderSize) throw new InvalidDataException("ADB: cabeçalho curto"); + var command = BinaryPrimitives.ReadUInt32LittleEndian(header); + var magic = BinaryPrimitives.ReadUInt32LittleEndian(header[20..]); + if (magic != (command ^ 0xFFFFFFFF)) throw new InvalidDataException("ADB: cabeçalho inválido"); + var length = BinaryPrimitives.ReadUInt32LittleEndian(header[12..]); + if (length > MaxPayload) throw new InvalidDataException("ADB: mensagem grande demais"); + return (command, + BinaryPrimitives.ReadUInt32LittleEndian(header[4..]), + BinaryPrimitives.ReadUInt32LittleEndian(header[8..]), + (int)length); + } + + public static uint Checksum(ReadOnlySpan data) + { + uint sum = 0; + foreach (var b in data) sum += b; + return sum; + } + + /// "host::" banner sent with CNXN. + public static byte[] ConnectBanner() => Encoding.ASCII.GetBytes("host::\0"); + + public static byte[] ShellService(string command) => Encoding.UTF8.GetBytes($"shell:{command}\0"); + + /// + /// adb signs the 20-byte token as if it were a SHA-1 digest (RSA_sign with NID_sha1), + /// i.e. PKCS#1 v1.5 over the SHA-1 DigestInfo of the raw token. + /// + public static byte[] SignToken(RSA key, byte[] token) => + key.SignHash(token, HashAlgorithmName.SHA1, RSASignaturePadding.Pkcs1); + + /// + /// Android's RSAPublicKey struct (android_pubkey_encode), base64 encoded, followed by + /// " comment" and a NUL: what the TV shows in "Allow debugging from this computer?". + /// + public static byte[] EncodePublicKey(RSAParameters key, string comment) + { + var modulus = key.Modulus ?? throw new ArgumentException("modulus"); + var exponent = key.Exponent ?? throw new ArgumentException("exponent"); + if (modulus.Length != 256) throw new ArgumentException("ADB keys are RSA-2048"); + + var n = new BigInteger(modulus, isUnsigned: true, isBigEndian: true); + var n0 = BinaryPrimitives.ReadUInt32BigEndian(modulus.AsSpan(modulus.Length - 4)); + // Newton's iteration for the inverse of an odd number mod 2^32. + var inverse = n0; + for (var i = 0; i < 5; i++) inverse = unchecked(inverse * (2 - n0 * inverse)); + var n0Inverse = unchecked(0u - inverse); + var rr = BigInteger.ModPow(2, 2 * 2048, n); + + var buffer = new byte[4 + 4 + 256 + 256 + 4]; + var span = buffer.AsSpan(); + BinaryPrimitives.WriteUInt32LittleEndian(span, 256 / 4); + BinaryPrimitives.WriteUInt32LittleEndian(span[4..], n0Inverse); + WriteLittleEndian(n, span.Slice(8, 256)); + WriteLittleEndian(rr, span.Slice(264, 256)); + uint e = 0; + foreach (var b in exponent) e = (e << 8) | b; + BinaryPrimitives.WriteUInt32LittleEndian(span[520..], e); + + return Encoding.ASCII.GetBytes($"{Convert.ToBase64String(buffer)} {comment}\0"); + } + + /// "host" or "host:port" (default 5555). + public static (string Host, int Port) ParseEndpoint(string value) + { + var text = value.Trim(); + var colon = text.LastIndexOf(':'); + if (colon > 0 && text.IndexOf(':') == colon && int.TryParse(text[(colon + 1)..], out var port) && port is > 0 and < 65536) + return (text[..colon], port); + return (text, DefaultPort); + } + + /// Key code for "HDMI n" (1-4). + public static int HdmiKey(int input) => KeyTvInputHdmi1 + Math.Clamp(input, 1, 4) - 1; + + private static void WriteLittleEndian(BigInteger value, Span destination) + { + destination.Clear(); + var bytes = value.ToByteArray(isUnsigned: true, isBigEndian: false); + bytes.AsSpan(0, Math.Min(bytes.Length, destination.Length)).CopyTo(destination); + } +} diff --git a/src/ConsoleMode/Services/Tv/AndroidTvController.cs b/src/ConsoleMode/Services/Tv/AndroidTvController.cs new file mode 100644 index 0000000..61d0243 --- /dev/null +++ b/src/ConsoleMode/Services/Tv/AndroidTvController.cs @@ -0,0 +1,37 @@ +using ConsoleMode.Models; + +namespace ConsoleMode.Services.Tv; + +/// +/// Android TV / Google TV (TCL, Sony, Hisense, Philips…) over the network with ADB: +/// wake up, then the "HDMI n" key (or a custom command). Needs Developer options → +/// "USB debugging" / "Network debugging" on the TV; nothing to install on the PC. +/// +public sealed class AndroidTvController : ITvController +{ + public async Task TurnOnAsync(TvControlConfig config, TimeSpan approvalTimeout, CancellationToken ct) + { + await using var adb = await ConnectAsync(config, approvalTimeout, wakeOnLan: true, ct); + await adb.ShellAsync($"input keyevent {AdbProtocol.KeyWakeUp}", ct); + // Right after waking, some TVs drop the input key while the launcher loads. + await Task.Delay(1500, ct); + var inputCommand = string.IsNullOrWhiteSpace(config.InputCommand) + ? $"input keyevent {AdbProtocol.HdmiKey(config.HdmiInput)}" + : config.InputCommand.Trim(); + await adb.ShellAsync(inputCommand, ct); + } + + public async Task TurnOffAsync(TvControlConfig config, CancellationToken ct) + { + await using var adb = await ConnectAsync(config, TimeSpan.FromSeconds(5), wakeOnLan: false, ct); + await adb.ShellAsync($"input keyevent {AdbProtocol.KeySleep}", ct); + } + + private static Task ConnectAsync(TvControlConfig config, TimeSpan approvalTimeout, bool wakeOnLan, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(config.Host)) + throw new TvControlException(LocalizationService.Get("TvHostMissing")); + var (host, port) = AdbProtocol.ParseEndpoint(config.Host); + return TvNetwork.ConnectAsync(config, wakeOnLan, token => AdbClient.ConnectAsync(host, port, approvalTimeout, token), ct); + } +} diff --git a/src/ConsoleMode/Services/Tv/TvControlService.cs b/src/ConsoleMode/Services/Tv/TvControlService.cs new file mode 100644 index 0000000..75300b7 --- /dev/null +++ b/src/ConsoleMode/Services/Tv/TvControlService.cs @@ -0,0 +1,76 @@ +using ConsoleMode.Models; + +namespace ConsoleMode.Services.Tv; + +/// One way of reaching the TV (network API, CEC adapter…). +public interface ITvController +{ + /// Turns the TV on and switches it to the PC's HDMI input. + Task TurnOnAsync(TvControlConfig config, TimeSpan approvalTimeout, CancellationToken ct); + + /// Puts the TV in standby. + Task TurnOffAsync(TvControlConfig config, CancellationToken ct); +} + +/// A failure worth showing to the user as is (already localized). +public sealed class TvControlException(string message) : Exception(message); + +/// +/// Turns the TV on / to the PC's input when console mode starts and, optionally, puts it in +/// standby after the restore. A TV that doesn't answer never blocks or breaks console mode: +/// the engine only logs, and its own "wait for the game screen" check does the rest. +/// +public sealed class TvControlService +{ + /// Enough for a TV waking from deep standby (Wake-on-LAN) to answer. + private static readonly TimeSpan StartTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan StopTimeout = TimeSpan.FromSeconds(15); + + /// Settings → Test: time to press "Allow" on the TV the first time. + public static readonly TimeSpan PairingTimeout = TimeSpan.FromSeconds(60); + + public static ITvController? Create(string provider) => provider switch + { + TvControlConfig.AndroidTv => new AndroidTvController(), + TvControlConfig.WebOs => new WebOsController(), + _ => null + }; + + /// Settings → Test. Throws with a message for the user. + public async Task TestAsync(TvControlConfig config, CancellationToken ct) + { + var controller = Create(config.Provider) ?? throw new TvControlException(LocalizationService.Get("TvNotConfigured")); + AppLog.Write($"TV: teste ({config.Provider})"); + await controller.TurnOnAsync(config, PairingTimeout, ct); + } + + /// Called by the engine on its worker thread; never throws. + public void TurnOn(TvControlConfig config) => + Run(config, "ligar", StartTimeout, (c, tv, ct) => c.TurnOnAsync(tv, TimeSpan.FromSeconds(10), ct)); + + /// Called by the engine after the restore; never throws. + public void TurnOff(TvControlConfig config) => + Run(config, "desligar", StopTimeout, (c, tv, ct) => c.TurnOffAsync(tv, ct)); + + private static void Run(TvControlConfig config, string action, TimeSpan timeout, + Func work) + { + var controller = Create(config.Provider); + if (controller is null) return; + var started = DateTime.UtcNow; + try + { + using var cts = new CancellationTokenSource(timeout); + Task.Run(() => work(controller, config, cts.Token), cts.Token).GetAwaiter().GetResult(); + AppLog.Write($"TV: {action} ({config.Provider}) ok em {(DateTime.UtcNow - started).TotalSeconds:0.0}s"); + } + catch (OperationCanceledException) + { + AppLog.Write($"TV: {action} ({config.Provider}) sem resposta em {timeout.TotalSeconds:0}s"); + } + catch (Exception ex) + { + AppLog.Write($"TV: {action} ({config.Provider}) falhou: {ex.Message}"); + } + } +} diff --git a/src/ConsoleMode/Services/Tv/TvNetwork.cs b/src/ConsoleMode/Services/Tv/TvNetwork.cs new file mode 100644 index 0000000..42fea23 --- /dev/null +++ b/src/ConsoleMode/Services/Tv/TvNetwork.cs @@ -0,0 +1,56 @@ +using System.Net.Sockets; +using ConsoleMode.Models; + +namespace ConsoleMode.Services.Tv; + +/// Connecting to a TV on the network, waking it with Wake-on-LAN when it's in deep standby. +public static class TvNetwork +{ + /// A TV woken by Wake-on-LAN takes a while to bring its network (and services) up. + private static readonly TimeSpan WakeWait = TimeSpan.FromSeconds(20); + + /// One connection attempt; throws a network error when nobody answers. + /// Send the magic packet (when a MAC is set) if the first attempt fails. + public static async Task ConnectAsync(TvControlConfig config, bool wakeOnLan, + Func> connect, CancellationToken ct) + { + try + { + return await connect(ct); + } + catch (Exception ex) when (IsUnreachable(ex) && wakeOnLan && WakeOnLan.TryParseMac(config.MacAddress, out var mac)) + { + // Deep standby: no network until the magic packet wakes the TV. + AppLog.Write($"TV: {config.Host} sem resposta; enviando Wake-on-LAN"); + await WakeOnLan.SendAsync(mac, ct); + } + catch (Exception ex) when (IsUnreachable(ex)) + { + throw Unreachable(config); + } + + var deadline = DateTime.UtcNow + WakeWait; + while (true) + { + await Task.Delay(2000, ct); + try + { + return await connect(ct); + } + catch (Exception ex) when (IsUnreachable(ex) && DateTime.UtcNow < deadline) + { + // Still booting; try again. + } + catch (Exception ex) when (IsUnreachable(ex)) + { + throw Unreachable(config); + } + } + } + + /// Nothing listening, no route, connect timeout or a TV that hung up mid-handshake. + public static bool IsUnreachable(Exception ex) => ex is SocketException or IOException; + + private static TvControlException Unreachable(TvControlConfig config) => + new(LocalizationService.Get("TvUnreachable", config.Host)); +} diff --git a/src/ConsoleMode/Services/Tv/WakeOnLan.cs b/src/ConsoleMode/Services/Tv/WakeOnLan.cs new file mode 100644 index 0000000..0a6857a --- /dev/null +++ b/src/ConsoleMode/Services/Tv/WakeOnLan.cs @@ -0,0 +1,49 @@ +using System.Globalization; +using System.Net; +using System.Net.Sockets; + +namespace ConsoleMode.Services.Tv; + +/// Wake-on-LAN "magic packet": 6 × 0xFF followed by the MAC address 16 times. +public static class WakeOnLan +{ + /// Accepts AA:BB:CC:DD:EE:FF, AA-BB-CC-DD-EE-FF or AABBCCDDEEFF. + public static bool TryParseMac(string? text, out byte[] mac) + { + mac = []; + if (string.IsNullOrWhiteSpace(text)) return false; + var hex = text.Trim().Replace(":", "").Replace("-", "").Replace(".", ""); + if (hex.Length != 12) return false; + + var bytes = new byte[6]; + for (var i = 0; i < 6; i++) + { + if (!byte.TryParse(hex.AsSpan(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out bytes[i])) + return false; + } + mac = bytes; + return true; + } + + public static byte[] BuildPacket(byte[] mac) + { + if (mac.Length != 6) throw new ArgumentException("MAC must have 6 bytes", nameof(mac)); + var packet = new byte[6 + 16 * 6]; + Array.Fill(packet, (byte)0xFF, 0, 6); + for (var i = 0; i < 16; i++) mac.CopyTo(packet, 6 + i * 6); + return packet; + } + + /// Broadcasts the packet on the usual ports (9 and 7); a few sends in case one is lost. + public static async Task SendAsync(byte[] mac, CancellationToken ct) + { + var packet = BuildPacket(mac); + using var udp = new UdpClient { EnableBroadcast = true }; + for (var attempt = 0; attempt < 3; attempt++) + { + foreach (var port in new[] { 9, 7 }) + await udp.SendAsync(packet, new IPEndPoint(IPAddress.Broadcast, port), ct); + await Task.Delay(100, ct); + } + } +} diff --git a/src/ConsoleMode/Services/Tv/WebOsController.cs b/src/ConsoleMode/Services/Tv/WebOsController.cs new file mode 100644 index 0000000..6ff4ec9 --- /dev/null +++ b/src/ConsoleMode/Services/Tv/WebOsController.cs @@ -0,0 +1,161 @@ +using System.Net.WebSockets; +using System.Text; +using ConsoleMode.Models; + +namespace ConsoleMode.Services.Tv; + +/// +/// LG webOS TVs over the network: Wake-on-LAN to power on (the TV's "Turn on via Wi-Fi" / +/// "LG Connect Apps" option), then SSAP over WebSocket to switch input and to power off. +/// The first time, the TV asks to allow Console Mode; the key it returns is kept. +/// +public sealed class WebOsController : ITvController +{ + public async Task TurnOnAsync(TvControlConfig config, TimeSpan approvalTimeout, CancellationToken ct) + { + using var socket = await ConnectAsync(config, wakeOnLan: true, ct); + await RegisterAsync(socket, config, approvalTimeout, ct); + await RequestAsync(socket, WebOsProtocol.SwitchInput("input_0", config.HdmiInput), "input_0", ct); + } + + public async Task TurnOffAsync(TvControlConfig config, CancellationToken ct) + { + using var socket = await ConnectAsync(config, wakeOnLan: false, ct); + await RegisterAsync(socket, config, TimeSpan.FromSeconds(5), ct); + await RequestAsync(socket, WebOsProtocol.Request("off_0", WebOsProtocol.TurnOffUri), "off_0", ct); + } + + private static Task ConnectAsync(TvControlConfig config, bool wakeOnLan, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(config.Host)) + throw new TvControlException(LocalizationService.Get("TvHostMissing")); + return TvNetwork.ConnectAsync(config, wakeOnLan, token => OpenAsync(config.Host.Trim(), token), ct); + } + + /// Plain ws://:3000 first; newer firmware only answers wss://:3001 (self-signed). + private static async Task OpenAsync(string host, CancellationToken ct) + { + try + { + return await OpenAsync(new Uri($"ws://{host}:{WebOsProtocol.Port}"), ct); + } + catch (Exception ex) when (TvNetwork.IsUnreachable(ex)) + { + return await OpenAsync(new Uri($"wss://{host}:{WebOsProtocol.SecurePort}"), ct); + } + } + + private static async Task OpenAsync(Uri uri, CancellationToken ct) + { + var socket = new ClientWebSocket(); + // The TV's certificate is self-signed and only reachable on the local network. + socket.Options.RemoteCertificateValidationCallback = (_, _, _, _) => true; + using var connect = CancellationTokenSource.CreateLinkedTokenSource(ct); + connect.CancelAfter(TimeSpan.FromSeconds(4)); + try + { + await socket.ConnectAsync(uri, connect.Token); + return socket; + } + catch (Exception ex) + { + socket.Dispose(); + if (ex is OperationCanceledException && !ct.IsCancellationRequested) + throw new IOException($"webOS: {uri} não respondeu"); + if (ex is WebSocketException) throw new IOException($"webOS: {ex.Message}", ex); + throw; + } + } + + private static async Task RegisterAsync(ClientWebSocket socket, TvControlConfig config, TimeSpan approvalTimeout, CancellationToken ct) + { + var host = config.Host.Trim(); + await SendAsync(socket, WebOsProtocol.Register(WebOsKeyStore.Load(host)), ct); + + using var wait = CancellationTokenSource.CreateLinkedTokenSource(ct); + wait.CancelAfter(approvalTimeout); + try + { + while (true) + { + var reply = WebOsProtocol.Parse(await ReceiveAsync(socket, wait.Token)); + switch (reply.Kind) + { + case WebOsProtocol.ReplyKind.Prompt: + AppLog.Write("TV: webOS pediu autorização; aguardando \"Permitir\" na TV"); + break; + case WebOsProtocol.ReplyKind.Registered: + if (!string.IsNullOrWhiteSpace(reply.ClientKey)) WebOsKeyStore.Save(host, reply.ClientKey); + return; + case WebOsProtocol.ReplyKind.Error when reply.Id == "register_0": + AppLog.Write($"TV: webOS recusou o registro: {reply.Error}"); + throw new TvControlException(LocalizationService.Get("TvWebOsNotAllowed")); + } + } + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + throw new TvControlException(LocalizationService.Get("TvWebOsNotAllowed")); + } + } + + private static async Task RequestAsync(ClientWebSocket socket, string message, string id, CancellationToken ct) + { + await SendAsync(socket, message, ct); + while (true) + { + var reply = WebOsProtocol.Parse(await ReceiveAsync(socket, ct)); + if (reply.Id != id) continue; + if (reply.Kind == WebOsProtocol.ReplyKind.Error) + throw new TvControlException(LocalizationService.Get("TvWebOsRequestFailed", reply.Error ?? "")); + if (reply.Kind == WebOsProtocol.ReplyKind.Response) return; + } + } + + private static Task SendAsync(ClientWebSocket socket, string message, CancellationToken ct) => + socket.SendAsync(new ArraySegment(Encoding.UTF8.GetBytes(message)), WebSocketMessageType.Text, endOfMessage: true, ct); + + private static async Task ReceiveAsync(ClientWebSocket socket, CancellationToken ct) + { + var buffer = new byte[8192]; + using var message = new MemoryStream(); + while (true) + { + var result = await socket.ReceiveAsync(new ArraySegment(buffer), ct); + if (result.MessageType == WebSocketMessageType.Close) + throw new IOException("webOS: a TV fechou a conexão"); + message.Write(buffer, 0, result.Count); + if (result.EndOfMessage) return Encoding.UTF8.GetString(message.ToArray()); + } + } +} + +/// The key each LG TV hands out after "Allow", kept per TV address in the data folder. +internal static class WebOsKeyStore +{ + private static string PathFor(string host) + { + var safe = string.Concat(host.Select(c => char.IsLetterOrDigit(c) ? c : '_')); + return Path.Combine(AppPaths.DataDir, $"webos-{safe}.key"); + } + + public static string? Load(string host) + { + try + { + var path = PathFor(host); + return File.Exists(path) ? File.ReadAllText(path).Trim() : null; + } + catch (Exception ex) + { + AppLog.Write($"TV: chave webOS ilegível: {ex.Message}"); + return null; + } + } + + public static void Save(string host, string key) + { + File.WriteAllText(PathFor(host), key); + AppLog.Write("TV: webOS pareada"); + } +} diff --git a/src/ConsoleMode/Services/Tv/WebOsProtocol.cs b/src/ConsoleMode/Services/Tv/WebOsProtocol.cs new file mode 100644 index 0000000..5a3390a --- /dev/null +++ b/src/ConsoleMode/Services/Tv/WebOsProtocol.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ConsoleMode.Services.Tv; + +/// +/// LG webOS "SSAP" messages (JSON over WebSocket, port 3000 / 3001): pairing and the two +/// requests Console Mode needs. Pure logic, so it's unit tested. +/// +public static class WebOsProtocol +{ + public const int Port = 3000; + public const int SecurePort = 3001; + + public const string SwitchInputUri = "ssap://tv/switchInput"; + public const string TurnOffUri = "ssap://system/turnOff"; + + /// Only what switching input and power need; the TV lists them in its pairing prompt. + public static readonly string[] Permissions = ["CONTROL_POWER", "CONTROL_INPUT_TV", "READ_INPUT_DEVICE_LIST", "READ_POWER_STATE"]; + + public enum ReplyKind + { + /// The TV shows "Allow this device?" and waits. + Prompt, + Registered, + Response, + Error, + Other + } + + public readonly record struct Reply(ReplyKind Kind, string? Id, string? ClientKey, string? Error); + + /// Key from a previous pairing; without it the TV prompts. + public static string Register(string? clientKey) + { + var payload = new JsonObject + { + ["forcePairing"] = false, + ["pairingType"] = "PROMPT", + ["manifest"] = new JsonObject + { + ["manifestVersion"] = 1, + ["appVersion"] = "1.1", + ["permissions"] = new JsonArray(Permissions.Select(p => (JsonNode?)JsonValue.Create(p)).ToArray()) + } + }; + if (!string.IsNullOrWhiteSpace(clientKey)) payload["client-key"] = clientKey; + return new JsonObject { ["type"] = "register", ["id"] = "register_0", ["payload"] = payload }.ToJsonString(); + } + + public static string Request(string id, string uri, JsonObject? payload = null) + { + var message = new JsonObject { ["type"] = "request", ["id"] = id, ["uri"] = uri }; + if (payload is not null) message["payload"] = payload; + return message.ToJsonString(); + } + + public static string SwitchInput(string id, int hdmi) => + Request(id, SwitchInputUri, new JsonObject { ["inputId"] = InputId(hdmi) }); + + /// webOS names the HDMI ports "HDMI_1".."HDMI_4". + public static string InputId(int hdmi) => $"HDMI_{Math.Clamp(hdmi, 1, 4)}"; + + public static Reply Parse(string json) + { + try + { + var root = JsonNode.Parse(json) as JsonObject; + if (root is null) return new Reply(ReplyKind.Other, null, null, null); + var type = root["type"]?.GetValue(); + var id = root["id"]?.GetValue(); + var payload = root["payload"] as JsonObject; + + return type switch + { + "registered" => new Reply(ReplyKind.Registered, id, payload?["client-key"]?.GetValue(), null), + "error" => new Reply(ReplyKind.Error, id, null, root["error"]?.GetValue() ?? "error"), + "response" when payload?["pairingType"]?.GetValue() == "PROMPT" => new Reply(ReplyKind.Prompt, id, null, null), + "response" when payload?["returnValue"]?.GetValue() == false => + new Reply(ReplyKind.Error, id, null, payload["errorText"]?.GetValue() ?? "returnValue false"), + "response" => new Reply(ReplyKind.Response, id, null, null), + _ => new Reply(ReplyKind.Other, id, null, null) + }; + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException) + { + return new Reply(ReplyKind.Other, null, null, null); + } + } +} diff --git a/src/ConsoleMode/ViewModels/MainViewModel.Tv.cs b/src/ConsoleMode/ViewModels/MainViewModel.Tv.cs new file mode 100644 index 0000000..152d1dc --- /dev/null +++ b/src/ConsoleMode/ViewModels/MainViewModel.Tv.cs @@ -0,0 +1,124 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using ConsoleMode.Models; +using ConsoleMode.Services; +using ConsoleMode.Services.Tv; +using Microsoft.UI.Xaml.Controls; + +namespace ConsoleMode.ViewModels; + +// Settings → TV: turn the TV on and switch it to the PC's input (issue #75). +public partial class MainViewModel +{ + public ObservableCollection TvProviders { get; } = []; + public ObservableCollection TvHdmiInputs { get; } = []; + + [ObservableProperty] private ComboOption? _selectedTvProvider; + [ObservableProperty] private ComboOption? _selectedTvHdmiInput; + [ObservableProperty] private string _tvHost = ""; + [ObservableProperty] private string _tvMacAddress = ""; + [ObservableProperty] private string _tvInputCommand = ""; + [ObservableProperty] private bool _tvTurnOffOnRestore; + [ObservableProperty] private bool _isTestingTv; + + private string TvProvider => SelectedTvProvider?.Value ?? TvControlConfig.None; + + public bool IsTvEnabled => TvProvider != TvControlConfig.None; + public bool IsTvAndroid => TvProvider == TvControlConfig.AndroidTv; + public bool IsTvWebOs => TvProvider == TvControlConfig.WebOs; + public bool UsesTvHost => IsTvAndroid || IsTvWebOs; + public bool UsesTvHdmiInput => IsTvAndroid || IsTvWebOs; + + public string TvProviderDescription => LocalizationService.Get(TvProvider switch + { + TvControlConfig.AndroidTv => "TvAndroidDescription", + TvControlConfig.WebOs => "TvWebOsDescription", + _ => "TvNoneDescription" + }); + + partial void OnSelectedTvProviderChanged(ComboOption? value) + { + OnPropertyChanged(nameof(IsTvEnabled)); + OnPropertyChanged(nameof(IsTvAndroid)); + OnPropertyChanged(nameof(IsTvWebOs)); + OnPropertyChanged(nameof(UsesTvHost)); + OnPropertyChanged(nameof(UsesTvHdmiInput)); + OnPropertyChanged(nameof(TvProviderDescription)); + SaveQuietly(); + } + + partial void OnSelectedTvHdmiInputChanged(ComboOption? value) => SaveQuietly(); + partial void OnTvHostChanged(string value) => SaveQuietly(); + partial void OnTvMacAddressChanged(string value) => SaveQuietly(); + partial void OnTvInputCommandChanged(string value) => SaveQuietly(); + partial void OnTvTurnOffOnRestoreChanged(bool value) => SaveQuietly(); + + /// Part of : the names follow the interface language. + private void BuildTvOptions() + { + var provider = SelectedTvProvider?.Value ?? _loadedConfig.Tv?.Provider ?? TvControlConfig.None; + var hdmi = SelectedTvHdmiInput?.Value ?? (_loadedConfig.Tv?.HdmiInput ?? 1).ToString(); + + TvProviders.Clear(); + TvProviders.Add(new ComboOption { Text = LocalizationService.Get("TvProviderNone"), Value = TvControlConfig.None }); + TvProviders.Add(new ComboOption { Text = LocalizationService.Get("TvProviderAndroid"), Value = TvControlConfig.AndroidTv }); + TvProviders.Add(new ComboOption { Text = LocalizationService.Get("TvProviderWebOs"), Value = TvControlConfig.WebOs }); + SelectedTvProvider = TvProviders.FirstOrDefault(o => o.Value == provider) ?? TvProviders[0]; + + TvHdmiInputs.Clear(); + for (var i = 1; i <= 4; i++) + TvHdmiInputs.Add(new ComboOption { Text = $"HDMI {i}", Value = i.ToString() }); + SelectedTvHdmiInput = TvHdmiInputs.FirstOrDefault(o => o.Value == hdmi) ?? TvHdmiInputs[0]; + } + + private void ApplyTv(TvControlConfig? tv) + { + tv ??= new TvControlConfig(); + SelectedTvProvider = TvProviders.FirstOrDefault(o => o.Value == tv.Provider) ?? TvProviders.FirstOrDefault(); + SelectedTvHdmiInput = TvHdmiInputs.FirstOrDefault(o => o.Value == tv.HdmiInput.ToString()) ?? TvHdmiInputs.FirstOrDefault(); + TvHost = tv.Host ?? ""; + TvMacAddress = tv.MacAddress ?? ""; + TvInputCommand = tv.InputCommand ?? ""; + TvTurnOffOnRestore = tv.TurnOffOnRestore; + } + + private TvControlConfig BuildTvConfig() => new() + { + Provider = TvProvider, + Host = TvHost.Trim(), + MacAddress = TvMacAddress.Trim(), + HdmiInput = int.TryParse(SelectedTvHdmiInput?.Value, out var hdmi) ? hdmi : 1, + InputCommand = TvInputCommand.Trim(), + TurnOffOnRestore = TvTurnOffOnRestore + }; + + /// Turns the TV on and switches the input now; the first time, pairs with the TV. + [RelayCommand] + private async Task TestTvAsync() + { + if (IsTestingTv) return; + IsTestingTv = true; + SetStatus(LocalizationService.Get("TvTesting"), InfoBarSeverity.Informational); + try + { + var config = BuildTvConfig(); + await Task.Run(() => Engine.Tv.TestAsync(config, CancellationToken.None)); + SetStatus(LocalizationService.Get("TvTestSuccess"), InfoBarSeverity.Success); + } + catch (TvControlException ex) + { + AppLog.Write($"TV: teste falhou: {ex.Message}"); + SetStatus(ex.Message, InfoBarSeverity.Warning); + } + catch (Exception ex) + { + AppLog.Write($"TV: teste falhou: {ex}"); + SetStatus(LocalizationService.Get("TvTestFailure", ex.Message), InfoBarSeverity.Error); + } + finally + { + IsTestingTv = false; + } + } +} diff --git a/src/ConsoleMode/ViewModels/MainViewModel.cs b/src/ConsoleMode/ViewModels/MainViewModel.cs index f27ed4e..a1d12ab 100644 --- a/src/ConsoleMode/ViewModels/MainViewModel.cs +++ b/src/ConsoleMode/ViewModels/MainViewModel.cs @@ -191,6 +191,7 @@ private void BuildLocalizedOptions() BuildAudioOptions(audioValue); BuildUiModeOptions(); + BuildTvOptions(); FpsStatusText = LocalizationService.Get(IsFpsAvailable ? "FpsAvailable" : "FpsUnavailable"); } finally @@ -307,6 +308,7 @@ private void Apply(LoadResult data) IsPlayniteAvailable = Engine.Launch.IsPlayniteAvailable(); BuildLocalizedOptions(); SelectedLaunch = LaunchOptions.FirstOrDefault(o => o.Value == config.FullscreenMode) ?? LaunchOptions[0]; + ApplyTv(config.Tv); HdrEnable = config.HdrEnable; VrrEnable = config.VrrEnable; CheckUpdates = config.CheckUpdates; @@ -900,7 +902,8 @@ private AppConfig BuildConfig() HomeButtonShortPress = HomeButtonShortPress, AutoStartOnController = AutoStartOnController, UiMode = SelectedUiMode?.Value ?? _loadedConfig.UiMode, - SkippedUpdateVersion = _loadedConfig.SkippedUpdateVersion + SkippedUpdateVersion = _loadedConfig.SkippedUpdateVersion, + Tv = BuildTvConfig() }; if (Monitors.Count == 0) return config; diff --git a/src/ConsoleMode/Views/SettingsView.xaml b/src/ConsoleMode/Views/SettingsView.xaml index c7c3d26..ee53bd0 100644 --- a/src/ConsoleMode/Views/SettingsView.xaml +++ b/src/ConsoleMode/Views/SettingsView.xaml @@ -97,6 +97,53 @@ OnContent="{x:Bind ViewModel.Texts.ToggleOn, Mode=OneWay}" OffContent="{x:Bind ViewModel.Texts.ToggleOff, Mode=OneWay}"/> + + + + + + + + + + + + + + + + + + + + + + + + + + +