diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md
index 1a42036..047d59b 100644
--- a/CHANGELOG.en-US.md
+++ b/CHANGELOG.en-US.md
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1166e1e..5e900bb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 9165b7f..f206199 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -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: ______
diff --git a/src/ConsoleMode/Resources/Strings.de-DE.json b/src/ConsoleMode/Resources/Strings.de-DE.json
index 0adf161..230faea 100644
--- a/src/ConsoleMode/Resources/Strings.de-DE.json
+++ b/src/ConsoleMode/Resources/Strings.de-DE.json
@@ -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."
}
diff --git a/src/ConsoleMode/Resources/Strings.en-US.json b/src/ConsoleMode/Resources/Strings.en-US.json
index 158941e..fb859a2 100644
--- a/src/ConsoleMode/Resources/Strings.en-US.json
+++ b/src/ConsoleMode/Resources/Strings.en-US.json
@@ -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."
}
diff --git a/src/ConsoleMode/Resources/Strings.es-ES.json b/src/ConsoleMode/Resources/Strings.es-ES.json
index 7e724c9..51c3052 100644
--- a/src/ConsoleMode/Resources/Strings.es-ES.json
+++ b/src/ConsoleMode/Resources/Strings.es-ES.json
@@ -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."
}
diff --git a/src/ConsoleMode/Resources/Strings.fr-FR.json b/src/ConsoleMode/Resources/Strings.fr-FR.json
index dcf72f1..d6c4511 100644
--- a/src/ConsoleMode/Resources/Strings.fr-FR.json
+++ b/src/ConsoleMode/Resources/Strings.fr-FR.json
@@ -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."
}
diff --git a/src/ConsoleMode/Resources/Strings.pt-BR.json b/src/ConsoleMode/Resources/Strings.pt-BR.json
index 0f5aaeb..e25749a 100644
--- a/src/ConsoleMode/Resources/Strings.pt-BR.json
+++ b/src/ConsoleMode/Resources/Strings.pt-BR.json
@@ -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."
}
diff --git a/src/ConsoleMode/Resources/Strings.ru-RU.json b/src/ConsoleMode/Resources/Strings.ru-RU.json
index 528de42..9eed0cf 100644
--- a/src/ConsoleMode/Resources/Strings.ru-RU.json
+++ b/src/ConsoleMode/Resources/Strings.ru-RU.json
@@ -381,5 +381,9 @@
"SessionTourExitPreviewBody": "Полностью закрывает Console Mode: он исчезает из области уведомлений, а сочетания на геймпаде перестают работать, пока вы снова его не откроете.",
"SessionTourCard": "Обзор меню сеанса",
"SessionTourDescription": "Объясняет каждый пункт меню поверх игры. Открывает меню в режиме предпросмотра, не начиная сеанс.",
- "ShowTour": "Показать обзор"
+ "ShowTour": "Показать обзор",
+ "ControllerBattery": "Заряд геймпада: {0}",
+ "ControllerBatteryCharging": "Заряд геймпада: {0}, заряжается",
+ "LowBatteryTitle": "Низкий заряд геймпада",
+ "LowBatteryBody": "Осталось {0} заряда. Подключите кабель или зарядите геймпад."
}
diff --git a/src/ConsoleMode/Services/ControllerBattery.cs b/src/ConsoleMode/Services/ControllerBattery.cs
new file mode 100644
index 0000000..97db5ca
--- /dev/null
+++ b/src/ConsoleMode/Services/ControllerBattery.cs
@@ -0,0 +1,173 @@
+namespace ConsoleMode.Services;
+
+/// One pad's battery. : XInput only gives four steps (shown with "~").
+public sealed record BatteryReading(string Id, string Name, int Percent, bool Charging, bool Approximate, string Source);
+
+///
+/// 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.
+///
+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;
+
+ ///
+ /// 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.
+ ///
+ 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
+ };
+ }
+
+ ///
+ /// Windows.Gaming.Input BatteryReport (status: 0 NotPresent, 1 Discharging, 2 Idle, 3 Charging).
+ /// Null without a battery or without the two capacities.
+ ///
+ 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ /// HIDP_CAPS.InputReportByteLength, 0 when unknown (see ControllerMapping.SonyButtons).
+ public static (int Percent, bool Charging)? FromSonyReport(ReadOnlySpan 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]);
+ }
+
+ /// DualSense status: low nibble level 0..10 (0 = 0-9 %), high nibble 0 discharging, 1 charging, 2 full.
+ 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
+ };
+ }
+
+ /// DS4 status[0]: low nibble level (0..10 on battery, 0..11 on the cable, 11 = full), bit 4 = cable.
+ 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
+ };
+ }
+
+ ///
+ /// 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).
+ ///
+ /// Readings with whether each comes from a Sony pad.
+ public static IReadOnlyList Combine(
+ IEnumerable sonyHid,
+ IEnumerable<(BatteryReading Reading, bool Sony)> wgi,
+ IEnumerable 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;
+ }
+
+ /// What the header shows when several pads report: the emptiest one.
+ public static BatteryReading? Lowest(IEnumerable readings) =>
+ readings.OrderBy(r => r.Percent).ThenBy(r => r.Charging).FirstOrDefault();
+
+ /// "65%", or "~65%" when approximate.
+ public static string Format(BatteryReading reading) => $"{(reading.Approximate ? "~" : "")}{reading.Percent}%";
+
+ /// For the diagnostics report (English on purpose): "battery 80%", "battery ~65%, charging".
+ public static string Describe(BatteryReading reading) => $"battery {Format(reading)}{(reading.Charging ? ", charging" : "")}";
+
+ /// Segoe Fluent Icons battery glyph in ten steps, with the charging variant.
+ 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();
+ }
+}
+
+///
+/// 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).
+///
+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
+
+ /// The threshold just crossed (15 or 5) when a warning is due, else null.
+ 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;
+ }
+}
diff --git a/src/ConsoleMode/Services/ControllerBatteryMonitor.cs b/src/ConsoleMode/Services/ControllerBatteryMonitor.cs
new file mode 100644
index 0000000..9c1ab38
--- /dev/null
+++ b/src/ConsoleMode/Services/ControllerBatteryMonitor.cs
@@ -0,0 +1,75 @@
+using System.Runtime.InteropServices;
+using Windows.Gaming.Input;
+
+namespace ConsoleMode.Services;
+
+///
+/// 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.
+/// is the last result, for any screen that wants to list it.
+///
+public static class ControllerBatteryMonitor
+{
+ private static readonly object Gate = new();
+ private static IReadOnlyList _snapshot = [];
+ private static bool _loggedError;
+
+ /// The batteries read by the last (empty before the first, or with no battery pad).
+ public static IReadOnlyList Snapshot
+ {
+ get { lock (Gate) return _snapshot; }
+ }
+
+ /// Reads every source now. Blocks for a moment (XInput, WinRT): call it off the UI thread.
+ public static IReadOnlyList 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();
+ 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);
+}
diff --git a/src/ConsoleMode/Services/SonyHidReader.cs b/src/ConsoleMode/Services/SonyHidReader.cs
index 624b5df..3a7ee83 100644
--- a/src/ConsoleMode/Services/SonyHidReader.cs
+++ b/src/ConsoleMode/Services/SonyHidReader.cs
@@ -14,6 +14,7 @@ public static class SonyHidReader
private static readonly object Gate = new();
private static readonly Dictionary Buttons = new(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary Sticks = new(StringComparer.OrdinalIgnoreCase);
+ private static readonly Dictionary Batteries = new(StringComparer.OrdinalIgnoreCase);
private static CancellationTokenSource? _cts;
private static int _users;
@@ -43,6 +44,12 @@ public static IReadOnlyDictionary ReadStates()
lock (Gate) return [.. Sticks.Values];
}
+ /// Battery of each Sony pad being read (refreshed every few seconds), for ControllerBatteryMonitor.
+ public static IReadOnlyList ReadBatteries()
+ {
+ lock (Gate) return [.. Batteries.Values];
+ }
+
public static void Acquire()
{
lock (Gate)
@@ -63,6 +70,7 @@ public static void Release()
_cts = null;
Buttons.Clear();
Sticks.Clear();
+ Batteries.Clear();
}
}
@@ -83,7 +91,7 @@ private static async Task ScanLoopAsync(CancellationToken ct)
_ = Task.Run(async () =>
{
await ReadLoopAsync(path, pad, ct);
- lock (Gate) { Buttons.Remove(path); Sticks.Remove(path); }
+ lock (Gate) { Buttons.Remove(path); Sticks.Remove(path); Batteries.Remove(path); }
lock (reading) { reading.Remove(path); }
});
}
@@ -106,6 +114,7 @@ private static async Task ReadLoopAsync(string path, Pad pad, CancellationToken
var buffer = new byte[Math.Max(pad.ReportLength, 64)];
var loggedFirst = false;
var loggedUnknown = false;
+ long nextBattery = 0;
while (!ct.IsCancellationRequested)
{
var read = await stream.ReadAsync(buffer.AsMemory(0, pad.ReportLength), ct);
@@ -123,10 +132,16 @@ private static async Task ReadLoopAsync(string path, Pad pad, CancellationToken
}
var bits = ControllerMapping.SonyButtons(buffer.AsSpan(0, read), pad.DualSense, pad.ReportLength);
var sticks = ControllerMapping.SonySticks(buffer.AsSpan(0, read), pad.DualSense, pad.ReportLength);
+ // The battery barely moves: parse it every few seconds, not on every report.
+ var now = Environment.TickCount64;
+ var battery = now >= nextBattery ? ControllerBattery.FromSonyReport(buffer.AsSpan(0, read), pad.DualSense, pad.ReportLength) : null;
+ if (now >= nextBattery) nextBattery = now + 5000;
lock (Gate)
{
Buttons[path] = bits;
if (sticks is { } s) Sticks[path] = s;
+ if (battery is { } b)
+ Batteries[path] = new BatteryReading(path, pad.DualSense ? "DualSense" : "DualShock 4", b.Percent, b.Charging, false, ControllerBattery.SourceSony);
}
}
}
diff --git a/src/ConsoleMode/SessionHintWindow.cs b/src/ConsoleMode/SessionHintWindow.cs
index 6a0991b..6083286 100644
--- a/src/ConsoleMode/SessionHintWindow.cs
+++ b/src/ConsoleMode/SessionHintWindow.cs
@@ -14,7 +14,7 @@ namespace ConsoleMode;
///
/// A toast in the top-right corner of the game screen, shown once when the session starts:
/// "hold Select + Y to open the panel". It never takes the focus from the game (shown without
-/// activation, WS_EX_NOACTIVATE) and closes by itself.
+/// activation, WS_EX_NOACTIVATE) and closes by itself. Also used for the low-battery notice.
///
public sealed class SessionHintWindow : Window
{
@@ -28,9 +28,9 @@ public sealed class SessionHintWindow : Window
private readonly Microsoft.UI.Dispatching.DispatcherQueueTimer _timer;
- public SessionHintWindow(string title, string body, ScreenRect? target, TimeSpan duration)
+ public SessionHintWindow(string title, string body, ScreenRect? target, TimeSpan duration, string? glyph = null)
{
- var icon = new FontIcon { Glyph = "", FontSize = 30, VerticalAlignment = VerticalAlignment.Center };
+ var icon = new FontIcon { Glyph = glyph ?? "\uE7FC", FontSize = 30, VerticalAlignment = VerticalAlignment.Center };
var text = new StackPanel { Spacing = 2, VerticalAlignment = VerticalAlignment.Center };
text.Children.Add(new TextBlock { Text = title, FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });
text.Children.Add(new TextBlock { Text = body, FontSize = 15, TextWrapping = TextWrapping.Wrap, Opacity = 0.85 });
diff --git a/src/ConsoleMode/SessionMenuWindow.xaml b/src/ConsoleMode/SessionMenuWindow.xaml
index 742a635..467f186 100644
--- a/src/ConsoleMode/SessionMenuWindow.xaml
+++ b/src/ConsoleMode/SessionMenuWindow.xaml
@@ -130,6 +130,12 @@
Visibility="{x:Bind ViewModel.IsSessionMenuLive, Mode=OneWay}"/>
+
+
+
+
+
diff --git a/src/ConsoleMode/ViewModels/MainViewModel.Battery.cs b/src/ConsoleMode/ViewModels/MainViewModel.Battery.cs
new file mode 100644
index 0000000..2f438d4
--- /dev/null
+++ b/src/ConsoleMode/ViewModels/MainViewModel.Battery.cs
@@ -0,0 +1,82 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using ConsoleMode.Services;
+using Microsoft.UI.Dispatching;
+
+namespace ConsoleMode.ViewModels;
+
+// Controller battery (issue #70): the emptiest pad's level in the session menu header and in the
+// console interface's top bar, and a corner notice during a session when a pad runs low.
+public partial class MainViewModel
+{
+ private static readonly TimeSpan BatteryInterval = TimeSpan.FromSeconds(30);
+
+ private DispatcherQueueTimer? _batteryTimer;
+ private readonly Dictionary _batteryAlarms = new(StringComparer.OrdinalIgnoreCase);
+ private bool _batteryReading;
+
+ [ObservableProperty] private bool _hasControllerBattery;
+ [ObservableProperty] private string _controllerBatteryGlyph = "";
+ [ObservableProperty] private string _controllerBatteryText = "";
+ [ObservableProperty] private string _controllerBatteryLabel = "";
+
+ private void StartBatteryPolling()
+ {
+ if (_batteryTimer is not null) return;
+ _batteryTimer = _dispatcher.CreateTimer();
+ _batteryTimer.Interval = BatteryInterval;
+ _batteryTimer.Tick += (_, _) => _ = RefreshBatteryAsync();
+ _batteryTimer.Start();
+ _ = RefreshBatteryAsync();
+ }
+
+ /// Reads the batteries off the UI thread, then updates the indicator and the warnings.
+ private async Task RefreshBatteryAsync()
+ {
+ if (_batteryReading) return;
+ _batteryReading = true;
+ try
+ {
+ var readings = await Task.Run(() => ControllerBatteryMonitor.Refresh());
+ ShowBattery(ControllerBattery.Lowest(readings));
+ if (IsConsoleActive) CheckLowBattery(readings);
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"Controle: bateria: {ex.Message}");
+ }
+ finally
+ {
+ _batteryReading = false;
+ }
+ }
+
+ private void ShowBattery(BatteryReading? reading)
+ {
+ HasControllerBattery = reading is not null;
+ if (reading is null) return;
+ ControllerBatteryGlyph = ControllerBattery.Glyph(reading.Percent, reading.Charging);
+ ControllerBatteryText = ControllerBattery.Format(reading);
+ ControllerBatteryLabel = LocalizationService.Get(reading.Charging ? "ControllerBatteryCharging" : "ControllerBattery", ControllerBatteryText);
+ }
+
+ private void CheckLowBattery(IReadOnlyList readings)
+ {
+ foreach (var reading in readings)
+ {
+ if (!_batteryAlarms.TryGetValue(reading.Id, out var alarm)) _batteryAlarms[reading.Id] = alarm = new LowBatteryAlarm();
+ if (alarm.Update(reading.Percent, reading.Charging) is null) continue;
+ AppLog.Write($"Controle: bateria fraca ({reading.Name}, {ControllerBattery.Format(reading)})");
+ try
+ {
+ _ = new SessionHintWindow(LocalizationService.Get("LowBatteryTitle"),
+ LocalizationService.Get("LowBatteryBody", ControllerBattery.Format(reading)),
+ Engine.State.FocusMonitorRect, TimeSpan.FromSeconds(7),
+ ControllerBattery.Glyph(reading.Percent, false));
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"Controle: aviso de bateria: {ex.Message}");
+ }
+ }
+ }
+}
diff --git a/src/ConsoleMode/ViewModels/MainViewModel.ConsoleUi.cs b/src/ConsoleMode/ViewModels/MainViewModel.ConsoleUi.cs
index 44e51a4..4037589 100644
--- a/src/ConsoleMode/ViewModels/MainViewModel.ConsoleUi.cs
+++ b/src/ConsoleMode/ViewModels/MainViewModel.ConsoleUi.cs
@@ -144,6 +144,7 @@ private void RefreshControllerHints()
///
public void OnControllerPresenceChanged()
{
+ _ = RefreshBatteryAsync();
if (IsConsoleActive) return;
ResolveUi();
}
diff --git a/src/ConsoleMode/ViewModels/MainViewModel.Diagnostics.cs b/src/ConsoleMode/ViewModels/MainViewModel.Diagnostics.cs
index 1d9dfe8..037ff06 100644
--- a/src/ConsoleMode/ViewModels/MainViewModel.Diagnostics.cs
+++ b/src/ConsoleMode/ViewModels/MainViewModel.Diagnostics.cs
@@ -99,12 +99,14 @@ private async Task CopyDiagnosticsReportAsync()
};
///
- /// One entry per controller Windows lists. Detail is left empty here: it's where per-pad
- /// extras (e.g. battery) go.
+ /// One entry per controller Windows lists, with its battery from the last
+ /// ControllerBatteryMonitor reading in Detail (the Sony HID or XInput value for a pad
+ /// Windows itself has no battery for).
///
private static List CollectControllers()
{
var list = new List();
+ var batteries = ControllerBatteryMonitor.Snapshot.ToList();
foreach (var raw in RawGameController.RawGameControllers)
{
var type = raw.HardwareVendorId switch
@@ -115,7 +117,13 @@ private static List CollectControllers()
_ => "Other"
};
if (Gamepad.FromGameController(raw) is not null) type += " (gamepad)";
- list.Add(new DiagnosticsController($"{raw.DisplayName} ({raw.HardwareVendorId:X4}:{raw.HardwareProductId:X4})", type));
+ var battery = batteries.FirstOrDefault(b => b.Id == raw.NonRoamableId)
+ ?? batteries.FirstOrDefault(b => b.Source == (raw.HardwareVendorId == ControllerMapping.SonyVendorId
+ ? ControllerBattery.SourceSony
+ : ControllerBattery.SourceXInput));
+ if (battery is not null) batteries.Remove(battery);
+ list.Add(new DiagnosticsController($"{raw.DisplayName} ({raw.HardwareVendorId:X4}:{raw.HardwareProductId:X4})", type,
+ battery is null ? null : ControllerBattery.Describe(battery)));
}
return list;
}
diff --git a/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs b/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs
index b5247f7..ffd7c5b 100644
--- a/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs
+++ b/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs
@@ -98,6 +98,7 @@ public void ToggleSessionMenu()
ControlFsJustInstalled = false;
RefreshSessionHeader();
StartSessionClock();
+ _ = RefreshBatteryAsync();
_sessionMenu.Activate();
_sessionMenu.BringToFront();
_ = LoadSessionValuesAsync();
diff --git a/src/ConsoleMode/ViewModels/MainViewModel.cs b/src/ConsoleMode/ViewModels/MainViewModel.cs
index 4e2f267..8ae5f49 100644
--- a/src/ConsoleMode/ViewModels/MainViewModel.cs
+++ b/src/ConsoleMode/ViewModels/MainViewModel.cs
@@ -131,6 +131,7 @@ public async Task InitializeAsync(bool interactive = true)
InitializeAppSettings();
ResolveUi();
_ = RecheckControllerAsync();
+ StartBatteryPolling();
_ = RefreshBackgroundAsync();
// The tour points at desktop controls; the console interface explains itself.
if (interactive && HasMonitors && !_loadedConfig.TourDone && !IsConsoleUi) StartTour();
diff --git a/src/ConsoleMode/Views/ConsoleHomeView.xaml b/src/ConsoleMode/Views/ConsoleHomeView.xaml
index f4dc119..4d0a05b 100644
--- a/src/ConsoleMode/Views/ConsoleHomeView.xaml
+++ b/src/ConsoleMode/Views/ConsoleHomeView.xaml
@@ -113,6 +113,11 @@
+
+
+
+
diff --git a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj
index 59521be..9d14668 100644
--- a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj
+++ b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj
@@ -38,6 +38,7 @@
+
diff --git a/tests/ConsoleMode.Tests/ControllerBatteryTests.cs b/tests/ConsoleMode.Tests/ControllerBatteryTests.cs
new file mode 100644
index 0000000..42432d0
--- /dev/null
+++ b/tests/ConsoleMode.Tests/ControllerBatteryTests.cs
@@ -0,0 +1,200 @@
+using ConsoleMode.Services;
+
+namespace ConsoleMode.Tests;
+
+public class ControllerBatteryTests
+{
+ [Theory]
+ [InlineData(ControllerBattery.XInputTypeAlkaline, 0, 5)]
+ [InlineData(ControllerBattery.XInputTypeAlkaline, 1, 30)]
+ [InlineData(ControllerBattery.XInputTypeNimh, 2, 65)]
+ [InlineData(ControllerBattery.XInputTypeNimh, 3, 100)]
+ public void Xinput_levels_map_to_rough_percentages(byte type, byte level, int expected) =>
+ Assert.Equal(expected, ControllerBattery.FromXInput(type, level));
+
+ [Theory]
+ [InlineData(ControllerBattery.XInputTypeDisconnected)]
+ [InlineData(ControllerBattery.XInputTypeWired)]
+ [InlineData(0xFF)] // BATTERY_TYPE_UNKNOWN
+ public void Xinput_without_a_battery_shows_nothing(byte type) =>
+ Assert.Null(ControllerBattery.FromXInput(type, 3));
+
+ [Fact]
+ public void Wgi_report_becomes_a_percentage()
+ {
+ Assert.Equal((50, false), ControllerBattery.FromWgi(1000, 2000, 1));
+ Assert.Equal((33, true), ControllerBattery.FromWgi(1000, 3000, 3));
+ Assert.Equal((100, false), ControllerBattery.FromWgi(2500, 2000, 2)); // clamped
+ }
+
+ [Fact]
+ public void Wgi_without_battery_or_capacity_shows_nothing()
+ {
+ Assert.Null(ControllerBattery.FromWgi(1000, 2000, 0)); // NotPresent: wired pad
+ Assert.Null(ControllerBattery.FromWgi(null, 2000, 1));
+ Assert.Null(ControllerBattery.FromWgi(1000, null, 1));
+ Assert.Null(ControllerBattery.FromWgi(1000, 0, 1));
+ }
+
+ [Theory]
+ [InlineData(0x00, 5, false)]
+ [InlineData(0x07, 75, false)]
+ [InlineData(0x0A, 100, false)]
+ [InlineData(0x13, 35, true)] // charging, level 3
+ [InlineData(0x22, 100, true)] // full
+ public void DualSense_status_byte(byte status, int percent, bool charging) =>
+ Assert.Equal((percent, charging), ControllerBattery.FromDualSenseStatus(status));
+
+ [Theory]
+ [InlineData(0xA5)]
+ [InlineData(0xB0)]
+ [InlineData(0xF0)]
+ public void DualSense_error_states_show_nothing(byte status) =>
+ Assert.Null(ControllerBattery.FromDualSenseStatus(status));
+
+ [Theory]
+ [InlineData(0x00, 5, false)]
+ [InlineData(0x08, 85, false)]
+ [InlineData(0x0A, 100, false)]
+ [InlineData(0x14, 45, true)] // cable, level 4
+ [InlineData(0x1A, 100, true)]
+ [InlineData(0x1B, 100, true)] // cable, full
+ public void Ds4_status_byte(byte status, int percent, bool charging) =>
+ Assert.Equal((percent, charging), ControllerBattery.FromDs4Status(status));
+
+ [Fact]
+ public void Ds4_cable_error_shows_nothing() => Assert.Null(ControllerBattery.FromDs4Status(0x1E));
+
+ [Fact]
+ public void Reads_the_battery_byte_at_each_report_layout()
+ {
+ var dsUsb = Report(0x01, 64, 53, 0x06);
+ Assert.Equal((65, false), ControllerBattery.FromSonyReport(dsUsb, dualSense: true, 64));
+ var dsBt = Report(0x31, 78, 54, 0x12);
+ Assert.Equal((25, true), ControllerBattery.FromSonyReport(dsBt, dualSense: true, 78));
+ var ds4Usb = Report(0x01, 64, 30, 0x09);
+ Assert.Equal((95, false), ControllerBattery.FromSonyReport(ds4Usb, dualSense: false, 64));
+ var ds4Bt = Report(0x11, 78, 32, 0x15);
+ Assert.Equal((55, true), ControllerBattery.FromSonyReport(ds4Bt, dualSense: false, 547));
+ }
+
+ [Fact]
+ public void Basic_bluetooth_report_has_no_battery()
+ {
+ // Short report 0x01, and the same report padded to the Bluetooth descriptor size.
+ Assert.Null(ControllerBattery.FromSonyReport(Report(0x01, 10, 5, 0x08), dualSense: false, 547));
+ Assert.Null(ControllerBattery.FromSonyReport(Report(0x01, 78, 53, 0x08), dualSense: true, 78));
+ Assert.Null(ControllerBattery.FromSonyReport(Report(0x01, 547, 30, 0x08), dualSense: false, 547));
+ Assert.Null(ControllerBattery.FromSonyReport(Report(0x05, 64, 30, 0x08), dualSense: false, 64));
+ Assert.Null(ControllerBattery.FromSonyReport([], dualSense: true));
+ }
+
+ [Fact]
+ public void Sony_hid_wins_over_windows_for_playstation_pads()
+ {
+ var hid = Reading("ds", 40, ControllerBattery.SourceSony);
+ var wgiSony = (Reading("ds-wgi", 90, ControllerBattery.SourceWgi), true);
+ var wgiXbox = (Reading("xbox", 70, ControllerBattery.SourceWgi), false);
+ var xinput = Reading("xinput0", 65, ControllerBattery.SourceXInput, approximate: true);
+
+ var all = ControllerBattery.Combine([hid], [wgiSony, wgiXbox], [xinput]);
+ Assert.Equal(["ds", "xbox"], all.Select(r => r.Id));
+ }
+
+ [Fact]
+ public void Xinput_fills_in_only_when_windows_reported_no_xbox_pad()
+ {
+ var xinput = Reading("xinput0", 65, ControllerBattery.SourceXInput, approximate: true);
+ Assert.Equal(["xinput0"], ControllerBattery.Combine([], [], [xinput]).Select(r => r.Id));
+ // A Sony pad seen by Windows doesn't hide the Xbox pad XInput reports.
+ var wgiSony = (Reading("ds-wgi", 90, ControllerBattery.SourceWgi), true);
+ Assert.Equal(["ds-wgi", "xinput0"], ControllerBattery.Combine([], [wgiSony], [xinput]).Select(r => r.Id));
+ Assert.Empty(ControllerBattery.Combine([], [], []));
+ }
+
+ [Fact]
+ public void Header_shows_the_emptiest_pad()
+ {
+ Assert.Null(ControllerBattery.Lowest([]));
+ var lowest = ControllerBattery.Lowest([Reading("a", 80, "wgi"), Reading("b", 20, "wgi"), Reading("c", 55, "wgi")]);
+ Assert.Equal("b", lowest!.Id);
+ }
+
+ [Fact]
+ public void Approximate_levels_get_a_tilde()
+ {
+ Assert.Equal("65%", ControllerBattery.Format(Reading("a", 65, "wgi")));
+ Assert.Equal("~65%", ControllerBattery.Format(Reading("a", 65, "xinput", approximate: true)));
+ }
+
+ [Fact]
+ public void Diagnostics_text_is_plain_english()
+ {
+ Assert.Equal("battery 80%", ControllerBattery.Describe(Reading("a", 80, "wgi")));
+ Assert.Equal("battery ~65%, charging", ControllerBattery.Describe(new BatteryReading("x", "x", 65, true, true, "xinput")));
+ }
+
+ [Theory]
+ [InlineData(0, false, "")]
+ [InlineData(4, false, "")]
+ [InlineData(54, false, "")]
+ [InlineData(95, false, "")]
+ [InlineData(100, false, "")]
+ [InlineData(0, true, "")]
+ [InlineData(80, true, "")]
+ [InlineData(100, true, "")]
+ public void Glyph_follows_the_level(int percent, bool charging, string glyph) =>
+ Assert.Equal(glyph, ControllerBattery.Glyph(percent, charging));
+
+ [Fact]
+ public void Low_battery_warns_once_at_15_and_once_at_5()
+ {
+ var alarm = new LowBatteryAlarm();
+ Assert.Null(alarm.Update(40, false));
+ Assert.Equal(15, alarm.Update(15, false));
+ Assert.Null(alarm.Update(12, false));
+ Assert.Null(alarm.Update(16, false)); // a reading that bounces back up doesn't re-arm it
+ Assert.Null(alarm.Update(14, false));
+ Assert.Equal(5, alarm.Update(5, false));
+ Assert.Null(alarm.Update(3, false));
+ Assert.Null(alarm.Update(0, false));
+ }
+
+ [Fact]
+ public void Low_battery_goes_straight_to_critical_when_first_seen_very_low()
+ {
+ var alarm = new LowBatteryAlarm();
+ Assert.Equal(5, alarm.Update(4, false));
+ Assert.Null(alarm.Update(10, false));
+ Assert.Null(alarm.Update(2, false));
+ }
+
+ [Fact]
+ public void Charging_rearms_the_warning()
+ {
+ var alarm = new LowBatteryAlarm();
+ Assert.Equal(15, alarm.Update(10, false));
+ Assert.Null(alarm.Update(10, true));
+ Assert.Equal(15, alarm.Update(10, false));
+ }
+
+ [Fact]
+ public void Fresh_batteries_rearm_the_warning()
+ {
+ var alarm = new LowBatteryAlarm();
+ Assert.Equal(15, alarm.Update(15, false));
+ Assert.Null(alarm.Update(100, false));
+ Assert.Equal(15, alarm.Update(15, false));
+ }
+
+ private static byte[] Report(byte id, int length, int offset, byte value)
+ {
+ var report = new byte[length];
+ report[0] = id;
+ if (offset < length) report[offset] = value;
+ return report;
+ }
+
+ private static BatteryReading Reading(string id, int percent, string source, bool approximate = false) =>
+ new(id, id, percent, false, approximate, source);
+}