From cea63325a56e10485fb7ea6cf3e140db303b5c73 Mon Sep 17 00:00:00 2001 From: poedgirl Date: Sat, 1 Nov 2025 19:43:40 +0800 Subject: [PATCH] Add project files. --- PowerPlayToggle.sln | 25 ++++ PowerPlayToggle/Models/GHubMessage.cs | 23 +++ PowerPlayToggle/PowerPlayToggle.csproj | 17 +++ PowerPlayToggle/Program.cs | 40 ++++++ PowerPlayToggle/Services/GHubService.cs | 132 ++++++++++++++++++ PowerPlayToggle/Services/MonitoringService.cs | 45 ++++++ PowerPlayToggle/Services/WMIService.cs | 52 +++++++ .../Startup/ContainerExtensions.cs | 16 +++ README.md | 15 ++ 9 files changed, 365 insertions(+) create mode 100644 PowerPlayToggle.sln create mode 100644 PowerPlayToggle/Models/GHubMessage.cs create mode 100644 PowerPlayToggle/PowerPlayToggle.csproj create mode 100644 PowerPlayToggle/Program.cs create mode 100644 PowerPlayToggle/Services/GHubService.cs create mode 100644 PowerPlayToggle/Services/MonitoringService.cs create mode 100644 PowerPlayToggle/Services/WMIService.cs create mode 100644 PowerPlayToggle/Startup/ContainerExtensions.cs create mode 100644 README.md diff --git a/PowerPlayToggle.sln b/PowerPlayToggle.sln new file mode 100644 index 0000000..f10aed4 --- /dev/null +++ b/PowerPlayToggle.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36310.24 d17.14 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerPlayToggle", "PowerPlayToggle\PowerPlayToggle.csproj", "{CB632A01-826A-451D-9C1B-AA4771D9530F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {CB632A01-826A-451D-9C1B-AA4771D9530F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CB632A01-826A-451D-9C1B-AA4771D9530F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CB632A01-826A-451D-9C1B-AA4771D9530F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CB632A01-826A-451D-9C1B-AA4771D9530F}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {027D70E5-7641-4377-A2EF-B417DEA2C5B8} + EndGlobalSection +EndGlobal diff --git a/PowerPlayToggle/Models/GHubMessage.cs b/PowerPlayToggle/Models/GHubMessage.cs new file mode 100644 index 0000000..0c6ff85 --- /dev/null +++ b/PowerPlayToggle/Models/GHubMessage.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace PowerPlayToggle.Models +{ + public class GHubMessage + { + public string MsgId { get; set; } + public string Verb { get; set; } + public string Path { get; set; } + public string Origin { get; set; } + public GHubResult Result { get; set; } + public JObject Payload { get; set; } + + public static GHubMessage FromJson(string json) => JsonConvert.DeserializeObject(json); + + public class GHubResult + { + public string Code { get; set; } + public string What { get; set; } + } + } +} diff --git a/PowerPlayToggle/PowerPlayToggle.csproj b/PowerPlayToggle/PowerPlayToggle.csproj new file mode 100644 index 0000000..8a7ae39 --- /dev/null +++ b/PowerPlayToggle/PowerPlayToggle.csproj @@ -0,0 +1,17 @@ + + + + WinExe + net9.0 + enable + enable + + + + + + + + + + diff --git a/PowerPlayToggle/Program.cs b/PowerPlayToggle/Program.cs new file mode 100644 index 0000000..0f262dd --- /dev/null +++ b/PowerPlayToggle/Program.cs @@ -0,0 +1,40 @@ +using PowerPlayToggle.Services; +using Microsoft.Extensions.DependencyInjection; +using PowerPlayToggle.Startup; + +namespace PowerPlayToggle; + +class Program +{ + public static ServiceProvider ServiceProvider; + + static async Task Main(string[] args) + { + ServiceProvider = new ServiceCollection() + .AddServices() + .BuildServiceProvider(); + + var monitor = ServiceProvider.GetService(); + monitor.Start(); + + + // Keep running, wait for Ctrl+C + ManualResetEventSlim run = new ManualResetEventSlim(false); + using (CancellationTokenSource cts = new CancellationTokenSource()) + { + Action shutdown = () => + { + monitor.Dispose(); + run.Set(); + }; + + Console.CancelKeyPress += (s, e) => + { + shutdown(); + e.Cancel = true; + }; + + run.Wait(); + } + } +} \ No newline at end of file diff --git a/PowerPlayToggle/Services/GHubService.cs b/PowerPlayToggle/Services/GHubService.cs new file mode 100644 index 0000000..da9d309 --- /dev/null +++ b/PowerPlayToggle/Services/GHubService.cs @@ -0,0 +1,132 @@ +using Newtonsoft.Json; +using PowerPlayToggle.Models; +using System.Net.WebSockets; +using Websocket.Client; +using Websocket.Client.Exceptions; + +namespace PowerPlayToggle.Services +{ + public interface IGHubService : IDisposable + { + void SetChargePadMode(bool mode); + } + + public class GHubService : IGHubService + { + private const string Websocket_Addr = "ws://127.0.0.1:9010"; + private readonly WebsocketClient webSocket; + private string chargePadId = "dev00000002"; + + public GHubService() + { + var wsUrl = new Uri(Websocket_Addr); + + var wsFactory = new Func(() => + { + var wsClient = new ClientWebSocket(); + var opt = wsClient.Options; + + opt.UseDefaultCredentials = false; + opt.SetRequestHeader("Origin", "file://"); + opt.SetRequestHeader("Pragma", "no-cache"); + opt.SetRequestHeader("Cache-Control", "no-cache"); + opt.SetRequestHeader("Sec-WebSocket-Extensions", "permessage-deflate; client_max_window_bits"); + opt.SetRequestHeader("Sec-WebSocket-Protocol", "json"); + opt.AddSubProtocol("json"); + return wsClient; + }); + + webSocket = new WebsocketClient(wsUrl, wsFactory); + webSocket.MessageReceived.Subscribe(ParseMessage); + webSocket.ErrorReconnectTimeout = TimeSpan.FromSeconds(5); + webSocket.ReconnectTimeout = null; + + StartSocket(); + } + + private void StartSocket() + { + Console.WriteLine("Connecting to GHub..."); + try + { + webSocket.Start(); + } + catch (WebsocketException e) + { + throw e; + } + Console.WriteLine("Connection successful, getting devices..."); + + webSocket.Send(JsonConvert.SerializeObject(new + { + msgId = "", + verb = "SUBSCRIBE", + path = "/devices/state/changed" + })); + + webSocket.Send(JsonConvert.SerializeObject(new + { + msgId = "", + verb = "GET", + path = "/devices/list" + })); + + webSocket.Send(JsonConvert.SerializeObject(new + { + msgId = "", + verb = "GET", + path = $"/charge_pad/{chargePadId}/charge_control" + })); + } + + private void ParseMessage(ResponseMessage message) + { + var ghMesssage = GHubMessage.FromJson(message.Text); + + switch(ghMesssage.Path) + { + case "/devices/list": + ProcessDevices(ghMesssage); + break; + default: + if (ghMesssage.Path == $"/charge_pad/{chargePadId}/charge_control" && ghMesssage.Verb == "SET") + { + if (ghMesssage.Result.Code == "SUCCESS") + { + Console.WriteLine($"Charging mode set"); + } + } + break; + } + + //Console.WriteLine(message.Text); + } + + private void ProcessDevices(GHubMessage message) + { + var devices = message.Payload["deviceInfos"]; + var powerPlayDevice = devices.FirstOrDefault(d => d["deviceType"].ToString() == "CHARGE_PAD"); + chargePadId = powerPlayDevice["id"].ToString(); + + Console.WriteLine($"Retrieved PowerPlay mat ID: {chargePadId}"); + } + + public void SetChargePadMode(bool mode) + { + Console.WriteLine($"Setting charging mode to {mode}"); + webSocket.Send(JsonConvert.SerializeObject(new + { + msgId = "", + verb = "SET", + path = $"/charge_pad/{chargePadId}/charge_control", + payload = new { value = mode } + })); + } + + public void Dispose() + { + Console.WriteLine("Disconnecting from GHub"); + webSocket.Dispose(); + } + } +} diff --git a/PowerPlayToggle/Services/MonitoringService.cs b/PowerPlayToggle/Services/MonitoringService.cs new file mode 100644 index 0000000..4666b4e --- /dev/null +++ b/PowerPlayToggle/Services/MonitoringService.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace PowerPlayToggle.Services +{ + public interface IMonitoringService : IDisposable + { + void Start(); + } + + public class MonitoringService( + IServiceProvider provider, + IWMIService wmi + ) : IMonitoringService + { + public void Start() + { + Console.WriteLine("Starting watch process..."); + wmi.WatchForProcess("vrserver.exe", VRStarted, VRStopped); + Console.WriteLine("Watch process started"); + } + + private void VRStarted() + { + Console.WriteLine("Process started"); + using var gHub = provider.GetService(); + Thread.Sleep(1000); // Ensure GHub service is initialised properly before proceeding + gHub.SetChargePadMode(false); + Thread.Sleep(1000); + } + + private void VRStopped() + { + Console.WriteLine("Process ended"); + using var gHub = provider.GetService(); + Thread.Sleep(1000); // Ensure GHub service is initialised properly before proceeding + gHub.SetChargePadMode(true); + Thread.Sleep(1000); + } + + public void Dispose() + { + wmi.Dispose(); + } + } +} diff --git a/PowerPlayToggle/Services/WMIService.cs b/PowerPlayToggle/Services/WMIService.cs new file mode 100644 index 0000000..50ee46e --- /dev/null +++ b/PowerPlayToggle/Services/WMIService.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Management; + +#pragma warning disable CA1416 +namespace PowerPlayToggle.Services +{ + public interface IWMIService : IDisposable + { + void WatchForProcess(string processName, Action processStarted, Action processEnded); + } + + public class WMIService : IWMIService + { + private readonly ManagementEventWatcher startWatcher; + private readonly ManagementEventWatcher endWatcher; + + public WMIService() + { + startWatcher = new ManagementEventWatcher(); + endWatcher = new ManagementEventWatcher(); + } + + public void WatchForProcess(string processName, Action processStarted, Action processEnded) + { + var filter = $"TargetInstance ISA 'Win32_Process' AND TargetInstance.Name = '{processName}'"; + startWatcher.Query = new WqlEventQuery("__InstanceCreationEvent", new TimeSpan(0,0,1), filter); + + startWatcher.EventArrived += (sender, e) => + { + processStarted.Invoke(); + }; + startWatcher.Start(); + + endWatcher.Query = new WqlEventQuery("__InstanceDeletionEvent", new TimeSpan(0, 0, 1), filter); + endWatcher.EventArrived += (sender, e) => + { + processEnded.Invoke(); + }; + endWatcher.Start(); + } + + public void Dispose() + { + startWatcher.Stop(); + endWatcher.Stop(); + } + } +} diff --git a/PowerPlayToggle/Startup/ContainerExtensions.cs b/PowerPlayToggle/Startup/ContainerExtensions.cs new file mode 100644 index 0000000..54f6c53 --- /dev/null +++ b/PowerPlayToggle/Startup/ContainerExtensions.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using PowerPlayToggle.Services; + +namespace PowerPlayToggle.Startup +{ + public static class ContainerExtensions + { + public static IServiceCollection AddServices(this IServiceCollection collection) + { + return collection + .AddTransient() + .AddScoped() + .AddScoped(); + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa2e514 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# PowerPlay Toggle + +A simple solution to toggle a Logitech PowerPlay mat off when SteamVR launches and back on when it closes. + +## Requirements + +* Logitech G-Hub needs to be running. + +## How it works + +This application uses WMI (Windows Management Instrumentation) to monitor when the process `vrserver.exe` starts and stops, +then connects to Logitech G-Hub using a websocket to first get the ID of a PowerPlay mat, then tells G-Hub to disable or +enable charging on that mat. + +As there is no documentation for the websocket protocol, it had to be reverse engineered from the G-Hub electron application.