Add project files.

This commit is contained in:
2025-11-01 19:43:40 +08:00
parent c116eefd59
commit cea63325a5
9 changed files with 365 additions and 0 deletions

25
PowerPlayToggle.sln Normal file
View File

@@ -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

View File

@@ -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<GHubMessage>(json);
public class GHubResult
{
public string Code { get; set; }
public string What { get; set; }
}
}
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.10" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="System.Management" Version="9.0.10" />
<PackageReference Include="Websocket.Client" Version="5.3.0" />
</ItemGroup>
</Project>

View File

@@ -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<IMonitoringService>();
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();
}
}
}

View File

@@ -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<ClientWebSocket>(() =>
{
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();
}
}
}

View File

@@ -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<IGHubService>();
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<IGHubService>();
Thread.Sleep(1000); // Ensure GHub service is initialised properly before proceeding
gHub.SetChargePadMode(true);
Thread.Sleep(1000);
}
public void Dispose()
{
wmi.Dispose();
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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<IGHubService, GHubService>()
.AddScoped<IWMIService, WMIService>()
.AddScoped<IMonitoringService, MonitoringService>();
}
}
}

15
README.md Normal file
View File

@@ -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.