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