diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index 726908b..ee2fa64 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 through a Pulse-Eight USB-CEC adapter (libCEC): powers on, switches input and puts any HDMI-CEC TV in standby. (#96) + ## [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..a5713fc 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 por adaptador USB-CEC da Pulse-Eight (libCEC): liga, troca a entrada e coloca em espera qualquer TV com HDMI-CEC. (#96) + ## [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..58c2350 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, or any TV through a USB-CEC adapter), 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..4fc1846 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, ou qualquer TV por um adaptador USB-CEC), 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..e148f06 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. + +### USB-CEC adapter (Pulse-Eight) + +Works with **any TV with HDMI-CEC** (Samsung Anynet+, Sony Bravia Sync, LG SimpLink…), with no network setup, through a [Pulse-Eight USB-CEC adapter](https://www.pulse-eight.com/p/104/usb-hdmi-cec-adapter) placed on the HDMI cable between the PC and the TV. + +1. Install **libCEC** from Pulse-Eight; it brings `cec-client.exe` (found automatically in `Program Files (x86)\Pulse-Eight\USB-CEC Adapter`, or on the PATH). +2. Turn CEC on in the TV's settings. +3. In Console Mode, pick *USB-CEC adapter* and the TV's HDMI input the PC is on. Press **Test now**. + +On start, the app runs `cec-client -s -t p -p ` with `on 0` (power on) and then `as` (Active Source, so the TV switches to that input); on restore, `standby 0`. Each command takes a few seconds while the adapter opens. + ## Optional extras ### HDR diff --git a/docs/GUIDE.pt-BR.md b/docs/GUIDE.pt-BR.md index 8ea13ef..4281487 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. + +### Adaptador USB-CEC (Pulse-Eight) + +Funciona com **qualquer TV com HDMI-CEC** (Samsung Anynet+, Sony Bravia Sync, LG SimpLink…), sem configurar rede, por um [adaptador USB-CEC da Pulse-Eight](https://www.pulse-eight.com/p/104/usb-hdmi-cec-adapter) colocado no cabo HDMI entre o PC e a TV. + +1. Instale o **libCEC** da Pulse-Eight; ele traz o `cec-client.exe` (encontrado sozinho em `Program Files (x86)\Pulse-Eight\USB-CEC Adapter` ou no PATH). +2. Ative o CEC nas configurações da TV. +3. No Console Mode, escolha *Adaptador USB-CEC* e a entrada HDMI da TV onde o PC está. Aperte **Testar agora**. + +Ao começar, o app roda `cec-client -s -t p -p ` com `on 0` (liga) e depois `as` (Active Source, para a TV trocar para essa entrada); ao restaurar, `standby 0`. Cada comando leva alguns segundos enquanto o adaptador abre. + ## Extras opcionais ### HDR diff --git a/docs/TESTING.md b/docs/TESTING.md index 0488907..146c501 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -105,6 +105,31 @@ 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: ______ + +## 3f. Controle da TV: adaptador USB-CEC (issue #75) + +Pré-condições: adaptador Pulse-Eight USB-CEC entre o PC e a TV, libCEC instalado, CEC ativo na TV; Ajustes → TV → *Adaptador USB-CEC* com a entrada HDMI do PC. + +- [ ] O card "cec-client" mostra o caminho encontrado; sem libCEC, mostra que não encontrou. Resultado: ______ +- [ ] **Testar agora** com a TV em espera: ela liga e troca para a entrada do PC. Resultado: ______ +- [ ] Adaptador desconectado do USB: o status diz que o adaptador não foi encontrado. Resultado: ______ +- [ ] **Jogar agora**: a TV liga antes das telas mudarem; o log tem `TV: cec-client "on 0" → 0` e `"as" → 0`. Resultado: ______ +- [ ] "Colocar a TV em espera ao restaurar": `standby 0` 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..59b7af7 100644 --- a/src/ConsoleMode/Models/Models.cs +++ b/src/ConsoleMode/Models/Models.cs @@ -51,11 +51,45 @@ 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 Cec = "cec"; + + /// "none" | "androidTv" | "cec". + 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; } = ""; + + /// USB-CEC: cec-client.exe (or its folder); empty = libCEC's install folder, then PATH. + public string CecClientPath { 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 +265,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..f1e7527 100644 --- a/src/ConsoleMode/Resources/Strings.en-US.json +++ b/src/ConsoleMode/Resources/Strings.en-US.json @@ -96,6 +96,40 @@ "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)", + "TvProviderCec": "USB-CEC adapter (Pulse-Eight)", + "TvCecDescription": "HDMI-CEC through a Pulse-Eight adapter on the HDMI cable: works with any CEC TV (Anynet+, Bravia Sync, SimpLink…). Install Pulse-Eight's libCEC.", + "TvCecClientCard": "cec-client", + "TvCecClientFound": "Using {0}. Enter another path only if you have more than one install.", + "TvCecClientMissing": "cec-client.exe not found. Install libCEC (pulse-eight.com) or enter its path.", + "TvCecNoAdapter": "cec-client couldn't find the USB-CEC adapter. Check its USB cable and that it sits between the PC and the TV.", + "TvCecNoAnswer": "cec-client didn't answer in time.", + "TvCecFailed": "cec-client exited with error {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..6a88ad9 100644 --- a/src/ConsoleMode/Resources/Strings.es-ES.json +++ b/src/ConsoleMode/Resources/Strings.es-ES.json @@ -96,6 +96,40 @@ "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)", + "TvProviderCec": "Adaptador USB-CEC (Pulse-Eight)", + "TvCecDescription": "HDMI-CEC con un adaptador Pulse-Eight en el cable HDMI: funciona con cualquier TV con CEC (Anynet+, Bravia Sync, SimpLink…). Instala libCEC de Pulse-Eight.", + "TvCecClientCard": "cec-client", + "TvCecClientFound": "Usando {0}. Indica otra ruta solo si tienes más de una instalación.", + "TvCecClientMissing": "No se encontró cec-client.exe. Instala libCEC (pulse-eight.com) o indica la ruta.", + "TvCecNoAdapter": "cec-client no encontró el adaptador USB-CEC. Revisa el cable USB y que esté entre el PC y la TV.", + "TvCecNoAnswer": "cec-client no respondió a tiempo.", + "TvCecFailed": "cec-client terminó con el error {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..eef6698 100644 --- a/src/ConsoleMode/Resources/Strings.pt-BR.json +++ b/src/ConsoleMode/Resources/Strings.pt-BR.json @@ -96,6 +96,40 @@ "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)", + "TvProviderCec": "Adaptador USB-CEC (Pulse-Eight)", + "TvCecDescription": "HDMI-CEC por um adaptador Pulse-Eight no cabo HDMI: funciona com qualquer TV com CEC (Anynet+, Bravia Sync, SimpLink…). Instale o libCEC da Pulse-Eight.", + "TvCecClientCard": "cec-client", + "TvCecClientFound": "Usando {0}. Informe outro caminho só se tiver mais de uma instalação.", + "TvCecClientMissing": "cec-client.exe não encontrado. Instale o libCEC (pulse-eight.com) ou informe o caminho.", + "TvCecNoAdapter": "O cec-client não encontrou o adaptador USB-CEC. Confira o cabo USB e se ele está entre o PC e a TV.", + "TvCecNoAnswer": "O cec-client não respondeu a tempo.", + "TvCecFailed": "O cec-client terminou com erro {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..33d5940 100644 --- a/src/ConsoleMode/Services/LocalizationService.cs +++ b/src/ConsoleMode/Services/LocalizationService.cs @@ -198,6 +198,21 @@ 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 TvCecClientCard => LocalizationService.Get(nameof(TvCecClientCard)); 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/CecCommands.cs b/src/ConsoleMode/Services/Tv/CecCommands.cs new file mode 100644 index 0000000..f67854d --- /dev/null +++ b/src/ConsoleMode/Services/Tv/CecCommands.cs @@ -0,0 +1,51 @@ +namespace ConsoleMode.Services.Tv; + +/// +/// HDMI-CEC through a Pulse-Eight USB-CEC adapter and libCEC's cec-client: arguments, +/// commands and how to read its output. Pure logic, so it's unit tested. +/// +public static class CecCommands +{ + public const string ExeName = "cec-client.exe"; + + /// Wake the TV (logical address 0). + public const string PowerOn = "on 0"; + + /// "Active Source": the TV switches to the HDMI port the adapter sits on. + public const string ActiveSource = "as"; + + public const string Standby = "standby 0"; + + /// + /// One command per run (-s), quiet log (-d 1), as a playback device (-t p) on the TV's + /// HDMI port the PC is plugged into (-p n), so "as" points the TV at the right input. + /// + public static string[] Arguments(int hdmiInput) => + ["-s", "-d", "1", "-t", "p", "-p", Math.Clamp(hdmiInput, 1, 4).ToString()]; + + /// cec-client prints this (and exits non-zero) when no adapter answers. + public static bool NoAdapter(string output) => + output.Contains("autodetect FAILED", StringComparison.OrdinalIgnoreCase) || + output.Contains("could not open a connection", StringComparison.OrdinalIgnoreCase) || + output.Contains("no serial port given", StringComparison.OrdinalIgnoreCase); + + /// Where libCEC's installer puts cec-client, then the PATH. + public static IEnumerable Candidates(string? customPath, string? programFilesX86, string? programFiles, string? pathVariable) + { + if (!string.IsNullOrWhiteSpace(customPath)) + { + var custom = customPath.Trim().Trim('"'); + yield return custom.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ? custom : Path.Combine(custom, ExeName); + yield break; + } + + foreach (var root in new[] { programFilesX86, programFiles }) + { + if (string.IsNullOrWhiteSpace(root)) continue; + yield return Path.Combine(root, "Pulse-Eight", "USB-CEC Adapter", ExeName); + } + + foreach (var dir in (pathVariable ?? "").Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + yield return Path.Combine(dir, ExeName); + } +} diff --git a/src/ConsoleMode/Services/Tv/CecController.cs b/src/ConsoleMode/Services/Tv/CecController.cs new file mode 100644 index 0000000..c6e9003 --- /dev/null +++ b/src/ConsoleMode/Services/Tv/CecController.cs @@ -0,0 +1,78 @@ +using System.Diagnostics; +using System.Text; +using ConsoleMode.Models; + +namespace ConsoleMode.Services.Tv; + +/// +/// HDMI-CEC through a Pulse-Eight USB-CEC adapter (inline on the HDMI cable): works with any +/// CEC TV. Needs libCEC installed (it brings cec-client.exe); no network involved. +/// +public sealed class CecController : ITvController +{ + /// Opening the adapter takes a few seconds per cec-client run. + private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(15); + + public async Task TurnOnAsync(TvControlConfig config, TimeSpan approvalTimeout, CancellationToken ct) + { + var exe = FindClient(config); + await RunAsync(exe, config.HdmiInput, CecCommands.PowerOn, ct); + await RunAsync(exe, config.HdmiInput, CecCommands.ActiveSource, ct); + } + + public Task TurnOffAsync(TvControlConfig config, CancellationToken ct) => + RunAsync(FindClient(config), config.HdmiInput, CecCommands.Standby, ct); + + /// The cec-client that will be used, or null (for the Settings description). + public static string? Locate(string? customPath) => + CecCommands.Candidates(customPath, + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + Environment.GetEnvironmentVariable("PATH")) + .FirstOrDefault(File.Exists); + + private static string FindClient(TvControlConfig config) => + Locate(config.CecClientPath) ?? throw new TvControlException(LocalizationService.Get("TvCecClientMissing")); + + private static async Task RunAsync(string exe, int hdmiInput, string command, CancellationToken ct) + { + var psi = new ProcessStartInfo + { + FileName = exe, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = Encoding.UTF8 + }; + foreach (var arg in CecCommands.Arguments(hdmiInput)) psi.ArgumentList.Add(arg); + + using var process = new Process { StartInfo = psi }; + process.Start(); + var stdout = process.StandardOutput.ReadToEndAsync(ct); + var stderr = process.StandardError.ReadToEndAsync(ct); + await process.StandardInput.WriteLineAsync(command.AsMemory(), ct); + process.StandardInput.Close(); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(CommandTimeout); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) + { + try { process.Kill(true); } catch { /* already gone */ } + if (ct.IsCancellationRequested) throw; + throw new TvControlException(LocalizationService.Get("TvCecNoAnswer")); + } + + var output = await stdout + await stderr; + AppLog.Write($"TV: cec-client \"{command}\" → {process.ExitCode}"); + if (CecCommands.NoAdapter(output)) + throw new TvControlException(LocalizationService.Get("TvCecNoAdapter")); + if (process.ExitCode != 0) + throw new TvControlException(LocalizationService.Get("TvCecFailed", process.ExitCode)); + } +} diff --git a/src/ConsoleMode/Services/Tv/TvControlService.cs b/src/ConsoleMode/Services/Tv/TvControlService.cs new file mode 100644 index 0000000..9054f83 --- /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.Cec => new CecController(), + _ => 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/ViewModels/MainViewModel.Tv.cs b/src/ConsoleMode/ViewModels/MainViewModel.Tv.cs new file mode 100644 index 0000000..9876cbc --- /dev/null +++ b/src/ConsoleMode/ViewModels/MainViewModel.Tv.cs @@ -0,0 +1,138 @@ +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 string _cecClientPath = ""; + [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 IsTvCec => TvProvider == TvControlConfig.Cec; + public bool UsesTvHost => IsTvAndroid; + public bool UsesTvHdmiInput => IsTvAndroid || IsTvCec; + + public string CecClientStatus => CecController.Locate(CecClientPath) is { } found + ? LocalizationService.Get("TvCecClientFound", found) + : LocalizationService.Get("TvCecClientMissing"); + + public string TvProviderDescription => LocalizationService.Get(TvProvider switch + { + TvControlConfig.AndroidTv => "TvAndroidDescription", + TvControlConfig.Cec => "TvCecDescription", + _ => "TvNoneDescription" + }); + + partial void OnSelectedTvProviderChanged(ComboOption? value) + { + OnPropertyChanged(nameof(IsTvEnabled)); + OnPropertyChanged(nameof(IsTvAndroid)); + OnPropertyChanged(nameof(IsTvCec)); + OnPropertyChanged(nameof(CecClientStatus)); + 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(); + + partial void OnCecClientPathChanged(string value) + { + OnPropertyChanged(nameof(CecClientStatus)); + 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("TvProviderCec"), Value = TvControlConfig.Cec }); + 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; + CecClientPath = tv.CecClientPath ?? ""; + } + + 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, + CecClientPath = CecClientPath.Trim() + }; + + /// 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..4626151 100644 --- a/src/ConsoleMode/Views/SettingsView.xaml +++ b/src/ConsoleMode/Views/SettingsView.xaml @@ -97,6 +97,57 @@ OnContent="{x:Bind ViewModel.Texts.ToggleOn, Mode=OneWay}" OffContent="{x:Bind ViewModel.Texts.ToggleOff, Mode=OneWay}"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +