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}"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ConsoleMode.Tests/CecCommandsTests.cs b/tests/ConsoleMode.Tests/CecCommandsTests.cs
new file mode 100644
index 0000000..26eddcc
--- /dev/null
+++ b/tests/ConsoleMode.Tests/CecCommandsTests.cs
@@ -0,0 +1,56 @@
+using ConsoleMode.Services.Tv;
+
+namespace ConsoleMode.Tests;
+
+public sealed class CecCommandsTests
+{
+ [Theory]
+ [InlineData(1, "1")]
+ [InlineData(3, "3")]
+ [InlineData(0, "1")]
+ [InlineData(8, "4")]
+ public void Arguments_run_one_quiet_command_as_a_playback_device_on_the_pc_port(int hdmi, string port)
+ {
+ Assert.Equal(new[] { "-s", "-d", "1", "-t", "p", "-p", port }, CecCommands.Arguments(hdmi));
+ }
+
+ [Fact]
+ public void Commands_target_the_tv()
+ {
+ Assert.Equal("on 0", CecCommands.PowerOn);
+ Assert.Equal("as", CecCommands.ActiveSource);
+ Assert.Equal("standby 0", CecCommands.Standby);
+ }
+
+ [Theory]
+ [InlineData("autodetect FAILED", true)]
+ [InlineData("ERROR: could not open a connection (try 2)", true)]
+ [InlineData("opening a connection to the CEC adapter...\nwaiting for input", false)]
+ [InlineData("", false)]
+ public void Missing_adapter_is_recognised_in_the_output(string output, bool missing)
+ {
+ Assert.Equal(missing, CecCommands.NoAdapter(output));
+ }
+
+ [Fact]
+ public void Custom_path_wins_and_accepts_a_folder_or_the_exe()
+ {
+ Assert.Equal(new[] { Path.Combine(@"D:\libcec", "cec-client.exe") },
+ CecCommands.Candidates(@"D:\libcec", @"C:\PF86", @"C:\PF", @"C:\bin").ToList());
+ Assert.Equal(new[] { @"D:\libcec\cec-client.exe" },
+ CecCommands.Candidates("\"D:\\libcec\\cec-client.exe\"", @"C:\PF86", @"C:\PF", @"C:\bin").ToList());
+ }
+
+ [Fact]
+ public void Default_search_is_the_libcec_install_folders_then_path()
+ {
+ var candidates = CecCommands.Candidates("", "PF86", "PF", "A; ;B").ToList();
+ Assert.Equal(new[]
+ {
+ Path.Combine("PF86", "Pulse-Eight", "USB-CEC Adapter", "cec-client.exe"),
+ Path.Combine("PF", "Pulse-Eight", "USB-CEC Adapter", "cec-client.exe"),
+ Path.Combine("A", "cec-client.exe"),
+ Path.Combine("B", "cec-client.exe")
+ }, candidates);
+ }
+}
diff --git a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj
index 14e33dd..01aa076 100644
--- a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj
+++ b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj
@@ -30,6 +30,9 @@
+
+
+
diff --git a/tests/ConsoleMode.Tests/TvControlTests.cs b/tests/ConsoleMode.Tests/TvControlTests.cs
new file mode 100644
index 0000000..d3387fa
--- /dev/null
+++ b/tests/ConsoleMode.Tests/TvControlTests.cs
@@ -0,0 +1,123 @@
+using System.Buffers.Binary;
+using System.Numerics;
+using System.Security.Cryptography;
+using System.Text;
+using ConsoleMode.Services.Tv;
+
+namespace ConsoleMode.Tests;
+
+public sealed class TvControlTests
+{
+ [Fact]
+ public void Adb_messages_carry_checksum_and_magic_and_decode_back()
+ {
+ var banner = AdbProtocol.ConnectBanner();
+ var bytes = AdbProtocol.Encode(AdbProtocol.Connect, AdbProtocol.Version, AdbProtocol.MaxPayload, banner);
+
+ Assert.Equal(AdbProtocol.HeaderSize + banner.Length, bytes.Length);
+ Assert.Equal("CNXN", Encoding.ASCII.GetString(bytes, 0, 4));
+ Assert.Equal(AdbProtocol.Checksum(banner), BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(16)));
+ Assert.Equal(AdbProtocol.Connect ^ 0xFFFFFFFF, BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(20)));
+
+ var (command, arg0, arg1, length) = AdbProtocol.DecodeHeader(bytes);
+ Assert.Equal(AdbProtocol.Connect, command);
+ Assert.Equal(AdbProtocol.Version, arg0);
+ Assert.Equal(AdbProtocol.MaxPayload, arg1);
+ Assert.Equal(banner.Length, length);
+ Assert.Equal("host::\0", Encoding.ASCII.GetString(bytes, AdbProtocol.HeaderSize, length));
+ }
+
+ [Fact]
+ public void Adb_header_with_a_wrong_magic_is_rejected()
+ {
+ var bytes = AdbProtocol.Encode(AdbProtocol.Okay, 1, 2);
+ bytes[20] ^= 0xFF;
+ Assert.Throws(() => AdbProtocol.DecodeHeader(bytes));
+ }
+
+ [Fact]
+ public void Adb_token_signature_verifies_as_a_sha1_digest()
+ {
+ using var key = RSA.Create(2048);
+ var token = RandomNumberGenerator.GetBytes(20);
+ var signature = AdbProtocol.SignToken(key, token);
+ Assert.True(key.VerifyHash(token, signature, HashAlgorithmName.SHA1, RSASignaturePadding.Pkcs1));
+ }
+
+ [Fact]
+ public void Adb_public_key_uses_the_android_layout()
+ {
+ using var key = RSA.Create(2048);
+ var parameters = key.ExportParameters(false);
+ var encoded = Encoding.ASCII.GetString(AdbProtocol.EncodePublicKey(parameters, "ConsoleMode@PC"));
+
+ Assert.EndsWith(" ConsoleMode@PC\0", encoded);
+ var raw = Convert.FromBase64String(encoded[..encoded.IndexOf(' ')]);
+ Assert.Equal(524, raw.Length);
+ Assert.Equal(64u, BinaryPrimitives.ReadUInt32LittleEndian(raw));
+
+ var modulus = new BigInteger(raw.AsSpan(8, 256), isUnsigned: true, isBigEndian: false);
+ Assert.Equal(new BigInteger(parameters.Modulus, isUnsigned: true, isBigEndian: true), modulus);
+
+ // n0inv = -1 / n mod 2^32
+ var n0 = (uint)(modulus & uint.MaxValue);
+ var n0Inverse = BinaryPrimitives.ReadUInt32LittleEndian(raw.AsSpan(4));
+ Assert.Equal(uint.MaxValue, unchecked(n0 * n0Inverse));
+
+ var rr = new BigInteger(raw.AsSpan(264, 256), isUnsigned: true, isBigEndian: false);
+ Assert.Equal(BigInteger.ModPow(2, 4096, modulus), rr);
+ Assert.Equal(65537u, BinaryPrimitives.ReadUInt32LittleEndian(raw.AsSpan(520)));
+ }
+
+ [Theory]
+ [InlineData("192.168.0.50", "192.168.0.50", 5555)]
+ [InlineData(" 192.168.0.50:5556 ", "192.168.0.50", 5556)]
+ [InlineData("tv.lan", "tv.lan", 5555)]
+ [InlineData("192.168.0.50:abc", "192.168.0.50:abc", 5555)]
+ public void Adb_endpoint_defaults_to_port_5555(string input, string host, int port)
+ {
+ Assert.Equal((host, port), AdbProtocol.ParseEndpoint(input));
+ }
+
+ [Theory]
+ [InlineData(1, 243)]
+ [InlineData(4, 246)]
+ [InlineData(0, 243)]
+ [InlineData(9, 246)]
+ public void Hdmi_inputs_map_to_the_android_tv_input_keys(int input, int key)
+ {
+ Assert.Equal(key, AdbProtocol.HdmiKey(input));
+ }
+
+ [Theory]
+ [InlineData("AA:BB:CC:DD:EE:FF")]
+ [InlineData("aa-bb-cc-dd-ee-ff")]
+ [InlineData("AABBCCDDEEFF")]
+ public void Mac_addresses_parse_in_the_usual_formats(string text)
+ {
+ Assert.True(WakeOnLan.TryParseMac(text, out var mac));
+ Assert.Equal(new byte[] { 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF }, mac);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("AA:BB:CC:DD:EE")]
+ [InlineData("GG:BB:CC:DD:EE:FF")]
+ public void Invalid_mac_addresses_are_rejected(string? text)
+ {
+ Assert.False(WakeOnLan.TryParseMac(text, out _));
+ }
+
+ [Fact]
+ public void Magic_packet_is_six_ff_then_the_mac_sixteen_times()
+ {
+ var mac = new byte[] { 1, 2, 3, 4, 5, 6 };
+ var packet = WakeOnLan.BuildPacket(mac);
+
+ Assert.Equal(102, packet.Length);
+ Assert.All(packet[..6], b => Assert.Equal(0xFF, b));
+ for (var i = 0; i < 16; i++)
+ Assert.Equal(mac, packet[(6 + i * 6)..(12 + i * 6)]);
+ }
+}