Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor
### What's new
- Update notice in Portuguese: the new version's highlight shows in the app's language when there is a changelog in it (today, Portuguese); other languages still see it in English. Applies to updates published after this version. (#158)
- Diagnostics in Settings: a new card shows, read-only, the app's version and channel, Windows, the screens (resolution, HDR, which one is the game screen and which ones get hidden), the audio outputs, the connected controllers, whether Steam, Playnite, RTSS and ControlFS were found, and the end of the log. **Copy report** copies it all as text to paste into an issue, without your user name in paths and without keys. (#159)
- Controller battery: the session menu header and the Console interface top bar (next to the clock) show the controller's battery level, with the charging icon when it is on the cable. Works with wireless Xbox controllers, DualSense and DualShock 4 (USB and Bluetooth); when Windows only gives a rough level, the number gets a "~". With several controllers it shows the emptiest one, and wired controllers with no battery show nothing. During a session, a notice in the corner of the TV warns when the battery reaches 15 % and again at 5 %, without repeating. (#160)

## [1.6.2]
### What's new
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH
### Novidades
- Aviso de atualização em português: o destaque da nova versão aparece no idioma do app quando há um changelog nele (hoje, português); os outros idiomas continuam vendo o destaque em inglês. Vale a partir das atualizações publicadas depois desta versão. (#158)
- Diagnóstico em Ajustes: um card novo mostra, só para leitura, a versão e o canal do app, o Windows, as telas (resolução, HDR, qual é a de jogo e quais são ocultadas), as saídas de áudio, os controles conectados, se Steam, Playnite, RTSS e ControlFS foram encontrados e o fim do log. **Copiar relatório** copia tudo em texto para colar numa issue, sem o seu nome de usuário nos caminhos e sem chaves. (#159)
- Bateria do controle: o cabeçalho do menu da sessão e a barra de cima da interface Console (ao lado do relógio) mostram o nível da bateria do controle, com o ícone de carregando quando ele está no cabo. Funciona com controles Xbox sem fio, DualSense e DualShock 4 (por USB e Bluetooth); quando o Windows só dá um nível aproximado, o número vem com "~". Com vários controles, aparece o mais descarregado, e controles com fio sem bateria não mostram nada. Durante a sessão, um aviso no canto da TV avisa quando a bateria chega a 15 % e de novo a 5 %, sem repetir. (#160)

## [1.6.2]
### Novidades
Expand Down
13 changes: 13 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,19 @@ Pré-condição: um emulador ou jogo que deixa o cursor visível (ex.: um emulad
- [ ] Voltar ao PC e iniciar outra sessão: a opção começa Desativada de novo. Resultado: ______
- [ ] Limitação esperada: o jogo também recebe o A e os analógicos. Em programas abertos como administrador o cursor não mexe (o Windows bloqueia a entrada vinda de um app comum). Resultado: ______

## 1f. Bateria do controle (issue #70)

Onde aparece: no cabeçalho do menu da sessão (depois de "controle Xbox detectado") e na barra de cima da interface Console, ao lado do relógio. Atualiza a cada 30 s, ao conectar um controle e ao abrir o menu. Com vários controles, mostra o mais descarregado.

- [ ] Controle Xbox sem fio (Bluetooth ou adaptador): ícone de bateria + porcentagem. Se só o XInput responder, o número vem com "~" (5/30/65/100 %). Compare com Configurações → Bluetooth do Windows. Resultado: ______
- [ ] DualSense por USB: porcentagem (de 10 em 10, ex.: 65 %) com o ícone de carregando. Resultado: ______
- [ ] DualSense por Bluetooth: porcentagem sem o ícone de carregando; desconecte e reconecte e o valor volta em até ~30 s. Resultado: ______
- [ ] DualShock 4 por USB e por Bluetooth: igual ao DualSense (por Bluetooth, só depois que o controle passa a mandar o relatório completo; com a Steam aberta isso é imediato). Resultado: ______
- [ ] Controle com fio sem bateria (Xbox com fio, genérico): nenhum indicador, nem no menu nem na barra. Resultado: ______
- [ ] Carregando: ligue o cabo num controle sem fio; em até ~30 s o ícone muda para o de carregando. Resultado: ______
- [ ] Bateria fraca na sessão: com um controle em 15 % ou menos, aparece um aviso no canto da TV ("Bateria do controle fraca"), sem tirar o foco do jogo; não repete a cada 30 s. Aparece de novo só em 5 % ou menos, ou depois de carregar. Fora da sessão não há aviso. Log: `Controle: bateria fraca (...)`. Resultado: ______
- [ ] Leitor de tela (Narrador) no indicador: lê "Bateria do controle: 65%" (e ", carregando"). Resultado: ______

## 2. Links `consolemode://`

- [ ] `start consolemode://start` no `cmd` com o app **fechado**: abre e entra no modo console. Resultado: ______
Expand Down
6 changes: 5 additions & 1 deletion src/ConsoleMode/Resources/Strings.de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,5 +381,9 @@
"SessionTourExitPreviewBody": "Schließt Console Mode vollständig: Die App wird aus dem Infobereich beendet und die Controller-Tastenkombinationen funktionieren erst wieder, wenn du sie erneut öffnest.",
"SessionTourCard": "Führung durch das Sitzungsmenü",
"SessionTourDescription": "Erklärt die einzelnen Optionen des Menüs über dem Spiel. Öffnet eine Vorschau des Menüs, ohne eine Sitzung zu starten.",
"ShowTour": "Führung anzeigen"
"ShowTour": "Führung anzeigen",
"ControllerBattery": "Controller-Akku: {0}",
"ControllerBatteryCharging": "Controller-Akku: {0}, wird geladen",
"LowBatteryTitle": "Controller-Akku schwach",
"LowBatteryBody": "Noch {0} Akku. Schließe das Kabel an oder lade den Controller auf."
}
6 changes: 5 additions & 1 deletion src/ConsoleMode/Resources/Strings.en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,5 +381,9 @@
"SessionTourExitPreviewBody": "Closes Console Mode completely: it leaves the tray and the controller shortcuts stop working until you open it again.",
"SessionTourCard": "Session menu tour",
"SessionTourDescription": "Explains each option of the menu over the game. Opens the menu as a preview, without starting a session.",
"ShowTour": "Show the tour"
"ShowTour": "Show the tour",
"ControllerBattery": "Controller battery: {0}",
"ControllerBatteryCharging": "Controller battery: {0}, charging",
"LowBatteryTitle": "Controller battery low",
"LowBatteryBody": "{0} left. Plug in the cable or charge the controller."
}
6 changes: 5 additions & 1 deletion src/ConsoleMode/Resources/Strings.es-ES.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,5 +381,9 @@
"SessionTourExitPreviewBody": "Cierra Console Mode por completo: sale de la bandeja y los atajos del mando dejan de funcionar hasta que lo abras de nuevo.",
"SessionTourCard": "Recorrido del menú de sesión",
"SessionTourDescription": "Explica cada opción del menú sobre el juego. Abre el menú como vista previa, sin iniciar una sesión.",
"ShowTour": "Ver el recorrido"
"ShowTour": "Ver el recorrido",
"ControllerBattery": "Batería del mando: {0}",
"ControllerBatteryCharging": "Batería del mando: {0}, cargando",
"LowBatteryTitle": "Batería del mando baja",
"LowBatteryBody": "Queda {0} de batería. Conecta el cable o carga el mando."
}
6 changes: 5 additions & 1 deletion src/ConsoleMode/Resources/Strings.fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,5 +381,9 @@
"SessionTourExitPreviewBody": "Ferme complètement Console Mode : il quitte la zone de notification et les raccourcis manette ne fonctionnent plus jusqu'à ce que vous le rouvriez.",
"SessionTourCard": "Visite du menu de session",
"SessionTourDescription": "Explique chaque option du menu affiché par-dessus le jeu. Ouvre le menu en aperçu, sans lancer de session.",
"ShowTour": "Voir la visite"
"ShowTour": "Voir la visite",
"ControllerBattery": "Batterie de la manette : {0}",
"ControllerBatteryCharging": "Batterie de la manette : {0}, en charge",
"LowBatteryTitle": "Batterie de la manette faible",
"LowBatteryBody": "Il reste {0} de batterie. Branchez le câble ou rechargez la manette."
}
6 changes: 5 additions & 1 deletion src/ConsoleMode/Resources/Strings.pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,5 +381,9 @@
"SessionTourExitPreviewBody": "Fecha o Console Mode por completo: ele sai da bandeja e os atalhos do controle param de funcionar até você abri-lo de novo.",
"SessionTourCard": "Tour do menu da sessão",
"SessionTourDescription": "Explica cada opção do menu sobre o jogo. Abre o menu numa prévia, sem iniciar uma sessão.",
"ShowTour": "Ver o tour"
"ShowTour": "Ver o tour",
"ControllerBattery": "Bateria do controle: {0}",
"ControllerBatteryCharging": "Bateria do controle: {0}, carregando",
"LowBatteryTitle": "Bateria do controle fraca",
"LowBatteryBody": "Restam {0} de bateria. Conecte o cabo ou carregue o controle."
}
6 changes: 5 additions & 1 deletion src/ConsoleMode/Resources/Strings.ru-RU.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,5 +381,9 @@
"SessionTourExitPreviewBody": "Полностью закрывает Console Mode: он исчезает из области уведомлений, а сочетания на геймпаде перестают работать, пока вы снова его не откроете.",
"SessionTourCard": "Обзор меню сеанса",
"SessionTourDescription": "Объясняет каждый пункт меню поверх игры. Открывает меню в режиме предпросмотра, не начиная сеанс.",
"ShowTour": "Показать обзор"
"ShowTour": "Показать обзор",
"ControllerBattery": "Заряд геймпада: {0}",
"ControllerBatteryCharging": "Заряд геймпада: {0}, заряжается",
"LowBatteryTitle": "Низкий заряд геймпада",
"LowBatteryBody": "Осталось {0} заряда. Подключите кабель или зарядите геймпад."
}
173 changes: 173 additions & 0 deletions src/ConsoleMode/Services/ControllerBattery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
namespace ConsoleMode.Services;

/// <summary>One pad's battery. <paramref name="Approximate"/>: XInput only gives four steps (shown with "~").</summary>
public sealed record BatteryReading(string Id, string Name, int Percent, bool Charging, bool Approximate, string Source);

/// <summary>
/// Pure battery logic (issue #70): turns what Windows.Gaming.Input, XInput and the Sony HID
/// reports say into a percentage, picks what to show and decides when to warn.
/// Read on Windows by ControllerBatteryMonitor.
/// </summary>
public static class ControllerBattery
{
public const string SourceSony = "hid";
public const string SourceWgi = "wgi";
public const string SourceXInput = "xinput";

// XInput BATTERY_TYPE_* and BATTERY_LEVEL_*.
public const byte XInputTypeDisconnected = 0x00;
public const byte XInputTypeWired = 0x01;
public const byte XInputTypeAlkaline = 0x02;
public const byte XInputTypeNimh = 0x03;

/// <summary>
/// Percentage for an XInput battery (type, level 0 empty .. 3 full); null when the slot is
/// empty, wired or unknown, which have no battery to show.
/// </summary>
public static int? FromXInput(byte type, byte level)
{
if (type is not (XInputTypeAlkaline or XInputTypeNimh)) return null;
return level switch
{
0 => 5,
1 => 30,
2 => 65,
_ => 100
};
}

/// <summary>
/// Windows.Gaming.Input BatteryReport (status: 0 NotPresent, 1 Discharging, 2 Idle, 3 Charging).
/// Null without a battery or without the two capacities.
/// </summary>
public static (int Percent, bool Charging)? FromWgi(int? remainingMwh, int? fullMwh, int status)
{
if (status == 0 || remainingMwh is not { } remaining || fullMwh is not { } full || full <= 0) return null;
var percent = (int)Math.Round(Math.Clamp(remaining * 100.0 / full, 0, 100));
return (percent, status == 3);
}

/// <summary>
/// The battery byte of a DS4 / DualSense input report (first byte = report ID), as the Linux
/// hid-playstation driver reads it. Null for reports that carry no battery (the short
/// Bluetooth report 0x01) or an error state.
/// </summary>
/// <param name="inputReportLength">HIDP_CAPS.InputReportByteLength, 0 when unknown (see ControllerMapping.SonyButtons).</param>
public static (int Percent, bool Charging)? FromSonyReport(ReadOnlySpan<byte> report, bool dualSense, int inputReportLength = 0)
{
if (report.Length == 0) return null;
// USB reports are 64 bytes; the basic Bluetooth 0x01 report has the same ID but no battery.
var usb = report[0] == 0x01 && report.Length >= 64 && inputReportLength is 0 or 64;
var offset = (report[0], dualSense) switch
{
(0x01, true) when usb => 53, // DualSense USB: status byte
(0x31, true) => 54, // DualSense Bluetooth
(0x01, false) when usb => 30, // DS4 USB: status[0]
(0x11, false) => 32, // DS4 Bluetooth
_ => -1
};
if (offset < 0 || report.Length <= offset) return null;
return dualSense ? FromDualSenseStatus(report[offset]) : FromDs4Status(report[offset]);
}

/// <summary>DualSense status: low nibble level 0..10 (0 = 0-9 %), high nibble 0 discharging, 1 charging, 2 full.</summary>
public static (int Percent, bool Charging)? FromDualSenseStatus(byte status)
{
var level = status & 0x0F;
return (status >> 4) switch
{
0 => (Math.Min(level * 10 + 5, 100), false),
1 => (Math.Min(level * 10 + 5, 100), true),
2 => (100, true),
_ => null
};
}

/// <summary>DS4 status[0]: low nibble level (0..10 on battery, 0..11 on the cable, 11 = full), bit 4 = cable.</summary>
public static (int Percent, bool Charging)? FromDs4Status(byte status)
{
var level = status & 0x0F;
var cable = (status & 0x10) != 0;
if (!cable) return (Math.Min(level * 10 + 5, 100), false);
return level switch
{
< 10 => (level * 10 + 5, true),
10 or 11 => (100, true),
_ => null
};
}

/// <summary>
/// One list out of the three sources: the Sony HID value wins for PlayStation pads (Windows
/// rarely knows their battery), Windows.Gaming.Input covers the rest, and XInput fills in only
/// when Windows.Gaming.Input reported no non-Sony pad (they would be the same Xbox pads twice).
/// </summary>
/// <param name="wgi">Readings with whether each comes from a Sony pad.</param>
public static IReadOnlyList<BatteryReading> Combine(
IEnumerable<BatteryReading> sonyHid,
IEnumerable<(BatteryReading Reading, bool Sony)> wgi,
IEnumerable<BatteryReading> xinput)
{
var result = sonyHid.ToList();
var hasSony = result.Count > 0;
var fromWgi = wgi.Where(w => !(w.Sony && hasSony)).ToList();
result.AddRange(fromWgi.Select(w => w.Reading));
if (!fromWgi.Any(w => !w.Sony)) result.AddRange(xinput);
return result;
}

/// <summary>What the header shows when several pads report: the emptiest one.</summary>
public static BatteryReading? Lowest(IEnumerable<BatteryReading> readings) =>
readings.OrderBy(r => r.Percent).ThenBy(r => r.Charging).FirstOrDefault();

/// <summary>"65%", or "~65%" when approximate.</summary>
public static string Format(BatteryReading reading) => $"{(reading.Approximate ? "~" : "")}{reading.Percent}%";

/// <summary>For the diagnostics report (English on purpose): "battery 80%", "battery ~65%, charging".</summary>
public static string Describe(BatteryReading reading) => $"battery {Format(reading)}{(reading.Charging ? ", charging" : "")}";

/// <summary>Segoe Fluent Icons battery glyph in ten steps, with the charging variant.</summary>
public static string Glyph(int percent, bool charging)
{
var step = Math.Clamp((int)Math.Round(percent / 10.0), 0, 10);
if (charging)
// BatteryCharging0..8 = E85A..E862, BatteryCharging9 = E83E (also used for a full pad on the cable).
return step >= 9 ? "" : ((char)(0xE85A + step)).ToString();
// Battery0..9 = E850..E859, Battery10 = E83F.
return step == 10 ? "" : ((char)(0xE850 + step)).ToString();
}
}

/// <summary>
/// When to warn about a low battery, for one pad: once at 15 % or less, once more at 5 % or less,
/// and again only after the pad charged (or reads well above the threshold, new batteries).
/// </summary>
public sealed class LowBatteryAlarm
{
public const int Low = 15;
public const int Critical = 5;
private const int ResetAbove = 25;

private int _warned; // 0, Low or Critical

/// <summary>The threshold just crossed (15 or 5) when a warning is due, else null.</summary>
public int? Update(int percent, bool charging)
{
if (charging || percent > ResetAbove)
{
_warned = 0;
return null;
}
if (percent <= Critical && _warned != Critical)
{
_warned = Critical;
return Critical;
}
if (percent <= Low && _warned == 0)
{
_warned = Low;
return Low;
}
return null;
}
}
75 changes: 75 additions & 0 deletions src/ConsoleMode/Services/ControllerBatteryMonitor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using System.Runtime.InteropServices;
using Windows.Gaming.Input;

namespace ConsoleMode.Services;

/// <summary>
/// Reads the battery of the connected pads (issue #70) from Windows.Gaming.Input, XInput and
/// SonyHidReader. Polled every half a minute by the view model, never on the input path.
/// <see cref="Snapshot"/> is the last result, for any screen that wants to list it.
/// </summary>
public static class ControllerBatteryMonitor
{
private static readonly object Gate = new();
private static IReadOnlyList<BatteryReading> _snapshot = [];
private static bool _loggedError;

/// <summary>The batteries read by the last <see cref="Refresh"/> (empty before the first, or with no battery pad).</summary>
public static IReadOnlyList<BatteryReading> Snapshot
{
get { lock (Gate) return _snapshot; }
}

/// <summary>Reads every source now. Blocks for a moment (XInput, WinRT): call it off the UI thread.</summary>
public static IReadOnlyList<BatteryReading> Refresh()
{
var wgi = new List<(BatteryReading, bool)>();
try
{
foreach (var raw in RawGameController.RawGameControllers)
{
var report = raw.TryGetBatteryReport();
if (report is null) continue;
if (ControllerBattery.FromWgi(report.RemainingCapacityInMilliwattHours, report.FullChargeCapacityInMilliwattHours, (int)report.Status) is not { } b) continue;
wgi.Add((new BatteryReading(raw.NonRoamableId, raw.DisplayName, b.Percent, b.Charging, false, ControllerBattery.SourceWgi),
raw.HardwareVendorId == ControllerMapping.SonyVendorId));
}
}
catch (Exception ex)
{
if (!_loggedError) AppLog.Write($"Controle: bateria via Windows.Gaming.Input indisponível: {ex.Message}");
_loggedError = true;
}

var xinput = new List<BatteryReading>();
for (uint i = 0; i < 4; i++)
{
try
{
if (XInputGetBatteryInformation(i, BatteryDevTypeGamepad, out var info) != 0) continue;
if (ControllerBattery.FromXInput(info.BatteryType, info.BatteryLevel) is not { } percent) continue;
xinput.Add(new BatteryReading($"xinput{i}", $"XInput {i + 1}", percent, false, true, ControllerBattery.SourceXInput));
}
catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException)
{
break;
}
}

var all = ControllerBattery.Combine(SonyHidReader.ReadBatteries(), wgi, xinput);
lock (Gate) _snapshot = all;
return all;
}

private const byte BatteryDevTypeGamepad = 0x00;

[StructLayout(LayoutKind.Sequential)]
private struct XInputBatteryInformation
{
public byte BatteryType;
public byte BatteryLevel;
}

[DllImport("xinput1_4.dll")]
private static extern uint XInputGetBatteryInformation(uint userIndex, byte devType, out XInputBatteryInformation information);
}
Loading
Loading