From ce4cf4d7112a3e576bfb7742081ae93b2209b2ae Mon Sep 17 00:00:00 2001 From: nextestudios Date: Sat, 26 Sep 2026 19:57:34 -0300 Subject: [PATCH 1/6] feat(tv): turn the TV on and switch input over the network (Google TV / Android TV) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → TV: console mode wakes the TV and switches it to the PC's HDMI input before the display flow, and can put it in standby after the restore. First provider: Google TV / Android TV through ADB (port 5555), with a small in-process ADB client (auth with this PC's RSA key, shell key events), so nothing has to be installed on the PC. Optional Wake-on-LAN for TVs that drop off the network in standby, and a custom input command for TVs that ignore the HDMI key codes. A TV that doesn't answer is only logged. Refs #75 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.en-US.md | 4 + CHANGELOG.md | 4 + README.md | 1 + README.pt-BR.md | 1 + docs/GUIDE.md | 19 ++ docs/GUIDE.pt-BR.md | 19 ++ docs/TESTING.md | 15 ++ src/ConsoleMode/Models/Models.cs | 32 +++ src/ConsoleMode/Resources/Strings.en-US.json | 26 +++ src/ConsoleMode/Resources/Strings.es-ES.json | 26 +++ src/ConsoleMode/Resources/Strings.pt-BR.json | 26 +++ src/ConsoleMode/Services/ConsoleEngine.cs | 12 +- .../Services/LocalizationService.cs | 14 ++ src/ConsoleMode/Services/Tv/AdbClient.cs | 185 ++++++++++++++++++ src/ConsoleMode/Services/Tv/AdbProtocol.cs | 139 +++++++++++++ .../Services/Tv/AndroidTvController.cs | 76 +++++++ .../Services/Tv/TvControlService.cs | 75 +++++++ src/ConsoleMode/Services/Tv/WakeOnLan.cs | 49 +++++ .../ViewModels/MainViewModel.Tv.cs | 120 ++++++++++++ src/ConsoleMode/ViewModels/MainViewModel.cs | 5 +- src/ConsoleMode/Views/SettingsView.xaml | 47 +++++ .../ConsoleMode.Tests.csproj | 2 + tests/ConsoleMode.Tests/TvControlTests.cs | 123 ++++++++++++ 23 files changed, 1018 insertions(+), 2 deletions(-) create mode 100644 src/ConsoleMode/Services/Tv/AdbClient.cs create mode 100644 src/ConsoleMode/Services/Tv/AdbProtocol.cs create mode 100644 src/ConsoleMode/Services/Tv/AndroidTvController.cs create mode 100644 src/ConsoleMode/Services/Tv/TvControlService.cs create mode 100644 src/ConsoleMode/Services/Tv/WakeOnLan.cs create mode 100644 src/ConsoleMode/ViewModels/MainViewModel.Tv.cs create mode 100644 tests/ConsoleMode.Tests/TvControlTests.cs diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index 726908b..ed98ee4 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -2,6 +2,10 @@ 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. (#75) + ## [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..f39f11f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ 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. (#75) + ## [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..63bd5b5 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 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..6d4fa09 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 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..ce621b9 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -23,6 +23,25 @@ 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. + ## Optional extras ### HDR diff --git a/docs/GUIDE.pt-BR.md b/docs/GUIDE.pt-BR.md index 8ea13ef..7b6cadd 100644 --- a/docs/GUIDE.pt-BR.md +++ b/docs/GUIDE.pt-BR.md @@ -23,6 +23,25 @@ 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. + ## Extras opcionais ### HDR diff --git a/docs/TESTING.md b/docs/TESTING.md index 0488907..f144277 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -105,6 +105,21 @@ 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: ______ + ## 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..ba9153a 100644 --- a/src/ConsoleMode/Models/Models.cs +++ b/src/ConsoleMode/Models/Models.cs @@ -51,11 +51,41 @@ 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"; + + /// "none" | "androidTv". + 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 +261,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..380b606 100644 --- a/src/ConsoleMode/Resources/Strings.en-US.json +++ b/src/ConsoleMode/Resources/Strings.en-US.json @@ -96,6 +96,32 @@ "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)", + "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..b2b939e 100644 --- a/src/ConsoleMode/Resources/Strings.es-ES.json +++ b/src/ConsoleMode/Resources/Strings.es-ES.json @@ -96,6 +96,32 @@ "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)", + "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..a83baf9 100644 --- a/src/ConsoleMode/Resources/Strings.pt-BR.json +++ b/src/ConsoleMode/Resources/Strings.pt-BR.json @@ -96,6 +96,32 @@ "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)", + "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..b33020e --- /dev/null +++ b/src/ConsoleMode/Services/Tv/AndroidTvController.cs @@ -0,0 +1,76 @@ +using System.Net.Sockets; +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 +{ + /// A TV woken by Wake-on-LAN takes a while to bring its network (and adbd) up. + private static readonly TimeSpan WakeWait = TimeSpan.FromSeconds(20); + + 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 async 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); + + try + { + return await AdbClient.ConnectAsync(host, port, approvalTimeout, 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: {host}:{port} sem resposta; enviando Wake-on-LAN"); + await WakeOnLan.SendAsync(mac, ct); + } + catch (Exception ex) when (IsUnreachable(ex)) + { + throw new TvControlException(LocalizationService.Get("TvUnreachable", host)); + } + + var deadline = DateTime.UtcNow + WakeWait; + while (true) + { + await Task.Delay(2000, ct); + try + { + return await AdbClient.ConnectAsync(host, port, approvalTimeout, ct); + } + catch (Exception ex) when (IsUnreachable(ex) && DateTime.UtcNow < deadline) + { + // Still booting; try again. + } + catch (Exception ex) when (IsUnreachable(ex)) + { + throw new TvControlException(LocalizationService.Get("TvUnreachable", host)); + } + } + } + + /// Nothing listening, no route, connect timeout or a TV that hung up mid-handshake. + private static bool IsUnreachable(Exception ex) => ex is SocketException or IOException; +} diff --git a/src/ConsoleMode/Services/Tv/TvControlService.cs b/src/ConsoleMode/Services/Tv/TvControlService.cs new file mode 100644 index 0000000..995df82 --- /dev/null +++ b/src/ConsoleMode/Services/Tv/TvControlService.cs @@ -0,0 +1,75 @@ +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(), + _ => 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/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/ViewModels/MainViewModel.Tv.cs b/src/ConsoleMode/ViewModels/MainViewModel.Tv.cs new file mode 100644 index 0000000..278295b --- /dev/null +++ b/src/ConsoleMode/ViewModels/MainViewModel.Tv.cs @@ -0,0 +1,120 @@ +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 UsesTvHost => IsTvAndroid; + public bool UsesTvHdmiInput => IsTvAndroid; + + public string TvProviderDescription => LocalizationService.Get(TvProvider switch + { + TvControlConfig.AndroidTv => "TvAndroidDescription", + _ => "TvNoneDescription" + }); + + partial void OnSelectedTvProviderChanged(ComboOption? value) + { + OnPropertyChanged(nameof(IsTvEnabled)); + OnPropertyChanged(nameof(IsTvAndroid)); + 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 }); + 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}"/> + + + + + + + + + + + + + + + + + + + + + + + + + + +