Mimir/src/Mimir.PsMoveProbe/Program.cs
2026-06-14 19:40:03 +01:00

1333 lines
46 KiB
C#

using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
const ushort SonyVid = 0x054C;
const ushort PsMovePid = 0x03D5;
var readReports = args.Any(arg => string.Equals(arg, "--read", StringComparison.OrdinalIgnoreCase));
var readCount = TryGetIntOption(args, "--read-count", 1);
var readTimeoutMs = TryGetIntOption(args, "--read-timeout-ms", 350);
var pairHost = TryGetOption(args, "--pair-host");
var rgb = TryGetOption(args, "--rgb");
var pulseMs = TryGetIntOption(args, "--pulse-ms", 0);
var pulseCount = TryGetIntOption(args, "--pulse-count", 1);
var intervalMs = TryGetIntOption(args, "--interval-ms", 500);
var holdSeconds = TryGetDoubleOption(args, "--hold-seconds", -1.0);
var refreshMs = TryGetIntOption(args, "--refresh-ms", 250);
var eventLog = TryGetOption(args, "--event-log");
var breatheLed = args.Any(arg =>
string.Equals(arg, "--breathe-led", StringComparison.OrdinalIgnoreCase) ||
string.Equals(arg, "--breath-led", StringComparison.OrdinalIgnoreCase));
var pairedChirpTrain = args.Any(arg => string.Equals(arg, "--paired-chirp-train", StringComparison.OrdinalIgnoreCase));
var outputAudio = TryGetOption(args, "--output-audio");
var eventCount = TryGetIntOption(args, "--event-count", pulseCount);
var startDelayMs = TryGetIntOption(args, "--start-delay-ms", 150);
var chirpMs = TryGetIntOption(args, "--chirp-ms", Math.Max(1, pulseMs));
var sampleRate = TryGetIntOption(args, "--sample-rate", 48_000);
var startHz = TryGetDoubleOption(args, "--start-hz", 2400.0);
var endHz = TryGetDoubleOption(args, "--end-hz", 7200.0);
var gain = TryGetDoubleOption(args, "--gain", 0.12);
var renderDevice = TryGetOption(args, "--render-device");
var wasapiExe = TryGetOption(args, "--wasapi-exe");
var noAudioEmit = args.Any(arg => string.Equals(arg, "--no-audio-emit", StringComparison.OrdinalIgnoreCase));
var showAll = args.Any(arg => string.Equals(arg, "--all", StringComparison.OrdinalIgnoreCase));
if (showAll)
{
foreach (var path in WindowsHid.EnumeratePaths())
{
Console.WriteLine(path);
}
return 0;
}
var devices = WindowsHid.Enumerate()
.Where(device => device.VendorId == SonyVid && device.ProductId == PsMovePid)
.OrderBy(device => device.Path, StringComparer.OrdinalIgnoreCase)
.ToList();
Console.WriteLine("Mimir PS Move USB probe");
Console.WriteLine($"target vid=0x{SonyVid:X4} pid=0x{PsMovePid:X4}");
Console.WriteLine($"collections={devices.Count}");
Console.WriteLine();
if (devices.Count == 0)
{
if (!breatheLed)
{
Console.WriteLine("No PS Move HID collections are visible. Check the USB cable and Windows Device Manager.");
return 1;
}
}
if (breatheLed)
{
if (string.IsNullOrWhiteSpace(rgb))
{
Console.WriteLine("--breathe-led requires --rgb #rrggbb or --rgb r,g,b.");
return 1;
}
var result = await WindowsHid.TryBreatheLedAsync(
rgb,
TimeSpan.FromSeconds(Math.Max(0.0, holdSeconds)),
TimeSpan.FromMilliseconds(Math.Max(25, refreshMs)),
eventLog,
args.Any(arg => string.Equals(arg, "--turn-off-on-exit", StringComparison.OrdinalIgnoreCase))).ConfigureAwait(false);
Console.WriteLine($"breatheLed={result}");
return result.StartsWith("ok", StringComparison.OrdinalIgnoreCase) ? 0 : 1;
}
if (pairedChirpTrain)
{
var move = devices.FirstOrDefault(static device =>
device.Caps.OutputReportByteLength > 0 &&
device.Path.IndexOf("&col01#", StringComparison.OrdinalIgnoreCase) >= 0);
if (move == null)
{
Console.WriteLine("No PS Move col01 output collection is available for paired chirp train.");
return 1;
}
var audioPath = string.IsNullOrWhiteSpace(outputAudio)
? Path.Combine("artifacts", "runtime", $"starfire-usb-move-chirp-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}.f32")
: outputAudio;
if (string.IsNullOrWhiteSpace(rgb) || !WindowsHid.TryParseRgb(rgb, out var r, out var g, out var b))
{
Console.WriteLine("Paired chirp train requires --rgb #rrggbb or --rgb r,g,b.");
return 1;
}
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(audioPath)) ?? ".");
if (!string.IsNullOrWhiteSpace(eventLog))
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(eventLog)) ?? ".");
}
var events = PairedChirpTrain.RenderAudio(
audioPath,
Math.Max(1, eventCount),
TimeSpan.FromMilliseconds(Math.Max(0, startDelayMs)),
TimeSpan.FromMilliseconds(Math.Max(1, intervalMs)),
TimeSpan.FromMilliseconds(Math.Max(1, chirpMs)),
sampleRate,
startHz,
endHz,
gain);
PairedChirpTrain.AppendScheduleEvents(eventLog, events, audioPath, sampleRate, gain);
var audioTask = noAudioEmit
? Task.FromResult("skipped: --no-audio-emit")
: PairedChirpTrain.EmitAudioAsync(audioPath, sampleRate, gain, renderDevice, wasapiExe, eventLog);
var ledTask = WindowsHid.TryWriteScheduledLedTrainAsync(
move,
events,
r,
g,
b,
TimeSpan.FromMilliseconds(Math.Max(1, pulseMs)),
eventLog);
var result = await ledTask.ConfigureAwait(false);
var audioResult = await audioTask.ConfigureAwait(false);
Console.WriteLine(
$"pairedChirpTrain audio={audioPath} events={events.Count} sampleRate={sampleRate} startDelayMs={startDelayMs} chirpMs={chirpMs} intervalMs={intervalMs} led={result} audio={audioResult}");
return result.StartsWith("ok", StringComparison.OrdinalIgnoreCase) &&
(audioResult.StartsWith("ok", StringComparison.OrdinalIgnoreCase) || audioResult.StartsWith("skipped", StringComparison.OrdinalIgnoreCase))
? 0
: 1;
}
foreach (var device in devices)
{
PrintDevice(device);
if (!string.IsNullOrWhiteSpace(pairHost))
{
var result = WindowsHid.TryPairHost(device, pairHost);
Console.WriteLine($" pairHost={result}");
}
if (readReports)
{
for (var readIndex = 0; readIndex < Math.Max(1, readCount); readIndex++)
{
var result = await WindowsHid.TryReadInputReportAsync(device, TimeSpan.FromMilliseconds(Math.Max(1, readTimeoutMs)));
Console.WriteLine($" read[{readIndex}]={result}");
}
}
if (!string.IsNullOrWhiteSpace(rgb))
{
var result = holdSeconds >= 0.0
? await WindowsHid.TryHoldLedAsync(
device,
rgb,
TimeSpan.FromSeconds(holdSeconds),
TimeSpan.FromMilliseconds(Math.Max(25, refreshMs)),
eventLog,
args.Any(arg => string.Equals(arg, "--turn-off-on-exit", StringComparison.OrdinalIgnoreCase))).ConfigureAwait(false)
: await WindowsHid.TryWriteLedPulseTrainAsync(
device,
rgb,
Math.Max(1, pulseCount),
TimeSpan.FromMilliseconds(Math.Max(0, pulseMs)),
TimeSpan.FromMilliseconds(Math.Max(1, intervalMs)),
eventLog).ConfigureAwait(false);
Console.WriteLine($" led={result}");
}
Console.WriteLine();
}
return 0;
static void PrintDevice(HidDeviceInfo device)
{
Console.WriteLine(device.Path);
Console.WriteLine($" vid=0x{device.VendorId:X4} pid=0x{device.ProductId:X4}");
Console.WriteLine($" manufacturer={device.Manufacturer ?? "(unreported)"}");
Console.WriteLine($" product={device.Product ?? "(unreported)"}");
Console.WriteLine($" serial={device.SerialNumber ?? "(unreported)"}");
Console.WriteLine($" usagePage=0x{device.Caps.UsagePage:X4} usage=0x{device.Caps.Usage:X4}");
Console.WriteLine($" reports input={device.Caps.InputReportByteLength} output={device.Caps.OutputReportByteLength} feature={device.Caps.FeatureReportByteLength}");
Console.WriteLine($" buttons in/out/feature={device.Caps.NumberInputButtonCaps}/{device.Caps.NumberOutputButtonCaps}/{device.Caps.NumberFeatureButtonCaps}");
Console.WriteLine($" values in/out/feature={device.Caps.NumberInputValueCaps}/{device.Caps.NumberOutputValueCaps}/{device.Caps.NumberFeatureValueCaps}");
}
static string? TryGetOption(string[] values, string name)
{
for (var index = 0; index < values.Length; index++)
{
if (string.Equals(values[index], name, StringComparison.OrdinalIgnoreCase) && index + 1 < values.Length)
{
return values[index + 1];
}
if (values[index].StartsWith(name + "=", StringComparison.OrdinalIgnoreCase))
{
return values[index][(name.Length + 1)..];
}
}
return null;
}
static int TryGetIntOption(string[] values, string name, int fallback) =>
int.TryParse(TryGetOption(values, name), out var value) ? value : fallback;
static double TryGetDoubleOption(string[] values, string name, double fallback) =>
double.TryParse(TryGetOption(values, name), out var value) ? value : fallback;
internal sealed record HidDeviceInfo(
string Path,
ushort VendorId,
ushort ProductId,
string? Manufacturer,
string? Product,
string? SerialNumber,
HidpCaps Caps);
internal sealed record PairedChirpEvent(
string EventId,
int Index,
double OffsetSeconds,
int AudioStartSample,
int AudioEndSample,
double StartHz,
double EndHz);
internal static class PairedChirpTrain
{
public static IReadOnlyList<PairedChirpEvent> RenderAudio(
string path,
int count,
TimeSpan startDelay,
TimeSpan interval,
TimeSpan chirpDuration,
int sampleRate,
double startHz,
double endHz,
double gain)
{
var events = new List<PairedChirpEvent>(count);
var intervalSeconds = Math.Max(0.001, interval.TotalSeconds);
var startDelaySeconds = Math.Max(0.0, startDelay.TotalSeconds);
var chirpSeconds = Math.Max(0.001, chirpDuration.TotalSeconds);
var chirpSamples = Math.Max(1, (int)Math.Round(chirpSeconds * sampleRate));
var totalSamples = Math.Max(
chirpSamples,
(int)Math.Ceiling((startDelaySeconds + (count - 1) * intervalSeconds + chirpSeconds + 0.05) * sampleRate));
var samples = new float[totalSamples];
var runId = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (var index = 0; index < count; index++)
{
var offsetSeconds = startDelaySeconds + index * intervalSeconds;
var startSample = Math.Min(samples.Length - 1, (int)Math.Round(offsetSeconds * sampleRate));
var endSample = Math.Min(samples.Length, startSample + chirpSamples);
events.Add(new PairedChirpEvent(
$"starfire-usb-move-chirp:{runId}:{index}",
index,
offsetSeconds,
startSample,
endSample,
startHz,
endHz));
for (var sample = startSample; sample < endSample; sample++)
{
var local = sample - startSample;
var normalized = chirpSamples <= 1 ? 0.0 : local / (double)(chirpSamples - 1);
var frequency = startHz + (endHz - startHz) * normalized;
var phase = 2.0 * Math.PI * (startHz * local / sampleRate +
0.5 * (endHz - startHz) * local * local / Math.Max(1.0, chirpSamples) / sampleRate);
var envelope = 0.5 - 0.5 * Math.Cos(2.0 * Math.PI * normalized);
samples[sample] += (float)Math.Clamp(Math.Sin(phase) * envelope * gain, -1.0, 1.0);
_ = frequency; // keep the linear-frequency intent explicit for receipts.
}
}
using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read);
var bytes = new byte[samples.Length * sizeof(float)];
Buffer.BlockCopy(samples, 0, bytes, 0, bytes.Length);
stream.Write(bytes);
return events;
}
public static void AppendScheduleEvents(
string? eventLogPath,
IReadOnlyList<PairedChirpEvent> events,
string audioPath,
int sampleRate,
double gain)
{
if (string.IsNullOrWhiteSpace(eventLogPath))
{
return;
}
foreach (var item in events)
{
var line = System.Text.Json.JsonSerializer.Serialize(new
{
document = "mimir.psmove_usb_audio_visual_pulse_event.v1",
invariant = "visual pulse and audio chirp share one event id and one planned offset",
item.EventId,
item.Index,
item.OffsetSeconds,
item.AudioStartSample,
item.AudioEndSample,
item.StartHz,
item.EndHz,
phase = "schedule",
audioPath,
sampleRate,
gain,
result = "planned"
});
File.AppendAllText(eventLogPath, line + Environment.NewLine);
}
}
public static async Task<string> EmitAudioAsync(
string audioPath,
int sampleRate,
double gain,
string? renderDevice,
string? wasapiExe,
string? eventLogPath)
{
var resolvedExe = ResolveWasapiExecutable(wasapiExe);
var args = new List<string>
{
"--play-f32-mono",
"--input",
Path.GetFullPath(audioPath),
"--sample-rate",
sampleRate.ToString(System.Globalization.CultureInfo.InvariantCulture),
"--gain",
gain.ToString(System.Globalization.CultureInfo.InvariantCulture),
"--render-buffer-seconds",
"0.02",
"--render-drain-seconds",
"0.08"
};
if (!string.IsNullOrWhiteSpace(renderDevice))
{
args.Add("--device");
args.Add(renderDevice);
}
var startedNs = WindowsHid.NowNs();
AppendRenderEvent(eventLogPath, "render-start", audioPath, sampleRate, gain, startedNs, startedNs, "starting");
using var process = new Process();
process.StartInfo.FileName = resolvedExe;
foreach (var arg in args)
{
process.StartInfo.ArgumentList.Add(arg);
}
process.StartInfo.UseShellExecute = false;
process.StartInfo.RedirectStandardError = true;
process.StartInfo.RedirectStandardOutput = true;
try
{
process.Start();
var stderrTask = process.StandardError.ReadToEndAsync();
var stdoutTask = process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync().ConfigureAwait(false);
var completedNs = WindowsHid.NowNs();
var stderr = await stderrTask.ConfigureAwait(false);
var stdout = await stdoutTask.ConfigureAwait(false);
var result = process.ExitCode == 0
? $"ok renderExit=0 pid={process.Id}"
: $"render failed exit={process.ExitCode} pid={process.Id}";
AppendRenderEvent(eventLogPath, "render-complete", audioPath, sampleRate, gain, startedNs, completedNs, result, stdout, stderr);
return result;
}
catch (Exception ex)
{
var completedNs = WindowsHid.NowNs();
var result = "render failed: " + ex.Message;
AppendRenderEvent(eventLogPath, "render-complete", audioPath, sampleRate, gain, startedNs, completedNs, result);
return result;
}
}
private static string ResolveWasapiExecutable(string? explicitPath)
{
if (!string.IsNullOrWhiteSpace(explicitPath))
{
return explicitPath;
}
var current = new DirectoryInfo(Environment.CurrentDirectory);
while (current != null)
{
var candidate = Path.Combine(
current.FullName,
"src",
"Mimir.WasapiLoopback",
"bin",
"Debug",
"net10.0-windows",
"Mimir.WasapiLoopback.exe");
if (File.Exists(candidate))
{
return candidate;
}
current = current.Parent;
}
return "Mimir.WasapiLoopback.exe";
}
private static void AppendRenderEvent(
string? eventLogPath,
string phase,
string audioPath,
int sampleRate,
double gain,
long startedNs,
long completedNs,
string result,
string? stdout = null,
string? stderr = null)
{
if (string.IsNullOrWhiteSpace(eventLogPath))
{
return;
}
var line = System.Text.Json.JsonSerializer.Serialize(new
{
document = "mimir.psmove_usb_audio_visual_pulse_event.v1",
invariant = "visual pulse and audio chirp share one event id and one planned offset",
phase,
audioPath,
sampleRate,
gain,
startedNs,
completedNs,
result,
stdout,
stderr
});
File.AppendAllText(eventLogPath, line + Environment.NewLine);
}
}
internal static class WindowsHid
{
private const int DigcfPresent = 0x00000002;
private const int DigcfDeviceinterface = 0x00000010;
private const uint GenericRead = 0x80000000;
private const uint GenericWrite = 0x40000000;
private const uint FileShareRead = 0x00000001;
private const uint FileShareWrite = 0x00000002;
private const uint OpenExisting = 3;
private const uint FileFlagOverlapped = 0x40000000;
private const uint ErrorIoPending = 997;
private const uint WaitObject0 = 0;
private const uint WaitTimeout = 258;
private const int HidpStatusSuccess = 0x00110000;
private const byte GetBtAddressReport = 0x04;
private const byte SetBtAddressReport = 0x05;
private const byte LedReport = 0x06;
private const int BtAddressGetSize = 16;
private const int BtAddressSetSize = 23;
public static IEnumerable<HidDeviceInfo> Enumerate()
{
foreach (var path in EnumeratePaths())
{
if (TryProbe(path, out var device))
{
yield return device;
}
}
}
public static IEnumerable<string> EnumeratePaths()
{
HidD_GetHidGuid(out var hidGuid);
var deviceInfoSet = SetupDiGetClassDevs(ref hidGuid, null, IntPtr.Zero, DigcfPresent | DigcfDeviceinterface);
if (deviceInfoSet == IntPtr.Zero || deviceInfoSet.ToInt64() == -1)
{
throw new Win32Exception(Marshal.GetLastWin32Error(), "SetupDiGetClassDevs failed.");
}
try
{
var interfaceData = new SpDeviceInterfaceData
{
CbSize = Marshal.SizeOf<SpDeviceInterfaceData>()
};
for (uint index = 0; SetupDiEnumDeviceInterfaces(deviceInfoSet, IntPtr.Zero, ref hidGuid, index, ref interfaceData); index++)
{
yield return GetDevicePath(deviceInfoSet, interfaceData);
}
}
finally
{
SetupDiDestroyDeviceInfoList(deviceInfoSet);
}
}
public static async Task<string> TryReadInputReportAsync(HidDeviceInfo device, TimeSpan timeout)
{
if (device.Caps.InputReportByteLength == 0)
{
return "skipped: no input report";
}
using var handle = CreateFile(
device.Path,
GenericRead,
FileShareRead | FileShareWrite,
IntPtr.Zero,
OpenExisting,
FileFlagOverlapped,
IntPtr.Zero);
if (handle.IsInvalid)
{
return $"open failed: {new Win32Exception(Marshal.GetLastWin32Error()).Message}";
}
await Task.Yield();
var buffer = new byte[device.Caps.InputReportByteLength];
var readEvent = CreateEvent(IntPtr.Zero, true, false, null);
if (readEvent == IntPtr.Zero)
{
return $"CreateEvent failed: {new Win32Exception(Marshal.GetLastWin32Error()).Message}";
}
var overlapped = new NativeOverlapped
{
EventHandle = readEvent
};
try
{
var started = ReadFile(handle, buffer, (uint)buffer.Length, IntPtr.Zero, ref overlapped);
if (!started)
{
var error = (uint)Marshal.GetLastWin32Error();
if (error != ErrorIoPending)
{
return $"ReadFile failed: {new Win32Exception((int)error).Message}";
}
}
var wait = WaitForSingleObject(readEvent, (uint)Math.Ceiling(timeout.TotalMilliseconds));
if (wait == WaitTimeout)
{
CancelIoEx(handle, ref overlapped);
return $"timeout after {timeout.TotalMilliseconds:0}ms";
}
if (wait != WaitObject0)
{
CancelIoEx(handle, ref overlapped);
return $"wait failed: 0x{wait:X8}";
}
if (!GetOverlappedResult(handle, ref overlapped, out var read, false))
{
return $"GetOverlappedResult failed: {new Win32Exception(Marshal.GetLastWin32Error()).Message}";
}
var shown = Convert.ToHexString(buffer.AsSpan(0, (int)Math.Min(read, 32)));
return $"{read} bytes {shown}{(read > 32 ? "..." : string.Empty)}";
}
finally
{
CloseHandle(readEvent);
}
}
public static string TryPairHost(HidDeviceInfo device, string hostAddress)
{
if (device.Caps.FeatureReportByteLength < BtAddressSetSize || device.Path.IndexOf("&col02#", StringComparison.OrdinalIgnoreCase) < 0)
{
return "skipped: pairing uses the PS Move col02 feature-report collection";
}
if (!TryParseBluetoothAddress(hostAddress, out var host))
{
return $"invalid host address: {hostAddress}";
}
using var handle = CreateFile(
device.Path,
0,
FileShareRead | FileShareWrite,
IntPtr.Zero,
OpenExisting,
0,
IntPtr.Zero);
if (handle.IsInvalid)
{
return $"open failed: {new Win32Exception(Marshal.GetLastWin32Error()).Message}";
}
var before = ReadBluetoothAddresses(handle);
var report = new byte[BtAddressSetSize];
report[0] = SetBtAddressReport;
host.CopyTo(report.AsSpan(1, 6));
if (!HidD_SetFeature(handle, report, report.Length))
{
return $"HidD_SetFeature failed: {new Win32Exception(Marshal.GetLastWin32Error()).Message}";
}
var after = ReadBluetoothAddresses(handle);
var controller = after.Controller ?? before.Controller ?? "(unknown)";
var assigned = after.Host ?? FormatBluetoothAddress(host);
return $"ok controller={controller} host={assigned}";
}
public static async Task<string> TryWriteLedPulseTrainAsync(
HidDeviceInfo device,
string rgb,
int pulseCount,
TimeSpan pulseDuration,
TimeSpan interval,
string? eventLogPath)
{
if (device.Caps.OutputReportByteLength == 0 || device.Path.IndexOf("&col01#", StringComparison.OrdinalIgnoreCase) < 0)
{
return "skipped: LED output uses the PS Move col01 output-report collection";
}
if (!TryParseRgb(rgb, out var r, out var g, out var b))
{
return $"invalid rgb: {rgb}";
}
var written = 0;
for (var index = 0; index < Math.Max(1, pulseCount); index++)
{
var eventId = $"starfire-usb-move:{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}:{index}";
var onStarted = NowNs();
var on = TryWriteLed(device, r, g, b);
var onCompleted = NowNs();
AppendPulseEvent(eventLogPath, eventId, index, "on", r, g, b, onStarted, onCompleted, on);
if (!on.StartsWith("ok", StringComparison.OrdinalIgnoreCase))
{
return on;
}
if (pulseDuration > TimeSpan.Zero)
{
await Task.Delay(pulseDuration).ConfigureAwait(false);
}
var offStarted = NowNs();
var off = TryWriteLed(device, 0, 0, 0);
var offCompleted = NowNs();
AppendPulseEvent(eventLogPath, eventId, index, "off", 0, 0, 0, offStarted, offCompleted, off);
if (!off.StartsWith("ok", StringComparison.OrdinalIgnoreCase))
{
return $"off failed after {written} pulses: {off}";
}
written++;
if (index + 1 < pulseCount)
{
var rest = interval - pulseDuration;
if (rest > TimeSpan.Zero)
{
await Task.Delay(rest).ConfigureAwait(false);
}
}
}
return $"ok pulses={written} rgb=#{r:X2}{g:X2}{b:X2} pulseMs={pulseDuration.TotalMilliseconds:0} intervalMs={interval.TotalMilliseconds:0}";
}
public static async Task<string> TryHoldLedAsync(
HidDeviceInfo device,
string rgb,
TimeSpan duration,
TimeSpan refresh,
string? eventLogPath,
bool turnOffOnExit)
{
if (device.Caps.OutputReportByteLength == 0 || device.Path.IndexOf("&col01#", StringComparison.OrdinalIgnoreCase) < 0)
{
return "skipped: LED output uses the PS Move col01 output-report collection";
}
if (!TryParseRgb(rgb, out var r, out var g, out var b))
{
return $"invalid rgb: {rgb}";
}
Console.CancelKeyPress += (_, eventArgs) =>
{
eventArgs.Cancel = true;
if (turnOffOnExit)
{
_ = TryWriteLed(device, 0, 0, 0);
}
Environment.Exit(0);
};
var eventId = $"starfire-usb-move-hold:{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
var stopwatch = Stopwatch.StartNew();
var writes = 0;
while (duration == TimeSpan.Zero || stopwatch.Elapsed < duration)
{
var started = NowNs();
var result = TryWriteLed(device, r, g, b);
var completed = NowNs();
AppendPulseEvent(eventLogPath, eventId, writes, "hold", r, g, b, started, completed, result);
if (!result.StartsWith("ok", StringComparison.OrdinalIgnoreCase))
{
return result;
}
writes++;
await Task.Delay(refresh).ConfigureAwait(false);
}
if (turnOffOnExit)
{
var started = NowNs();
var result = TryWriteLed(device, 0, 0, 0);
var completed = NowNs();
AppendPulseEvent(eventLogPath, eventId, writes, "off", 0, 0, 0, started, completed, result);
if (!result.StartsWith("ok", StringComparison.OrdinalIgnoreCase))
{
return $"off failed after {writes} writes: {result}";
}
}
return $"ok holdWrites={writes} rgb=#{r:X2}{g:X2}{b:X2} durationSeconds={duration.TotalSeconds:0.###} refreshMs={refresh.TotalMilliseconds:0}";
}
public static async Task<string> TryBreatheLedAsync(
string rgb,
TimeSpan duration,
TimeSpan refresh,
string? eventLogPath,
bool turnOffOnExit)
{
if (!TryParseRgb(rgb, out var baseR, out var baseG, out var baseB))
{
return $"invalid rgb: {rgb}";
}
var stopRequested = false;
Console.CancelKeyPress += (_, eventArgs) =>
{
eventArgs.Cancel = true;
stopRequested = true;
};
var eventId = $"starfire-usb-move-breathe:{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
var stopwatch = Stopwatch.StartNew();
var writes = 0;
while (!stopRequested && (duration == TimeSpan.Zero || stopwatch.Elapsed < duration))
{
var intensity = Math.Abs(Math.Sin(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0)) * 0.5 + 0.5;
var r = ScaleColorChannel(baseR, intensity);
var g = ScaleColorChannel(baseG, intensity);
var b = ScaleColorChannel(baseB, intensity);
var devices = Enumerate()
.Where(static device =>
device.Caps.OutputReportByteLength > 0 &&
device.Path.IndexOf("&col01#", StringComparison.OrdinalIgnoreCase) >= 0)
.OrderBy(static device => device.Path, StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var device in devices)
{
var started = NowNs();
var result = TryWriteLed(device, r, g, b);
var completed = NowNs();
AppendPulseEvent(eventLogPath, eventId, writes, "breathe", r, g, b, started, completed, result);
writes++;
}
await Task.Delay(refresh).ConfigureAwait(false);
}
if (turnOffOnExit)
{
foreach (var device in Enumerate()
.Where(static device =>
device.Caps.OutputReportByteLength > 0 &&
device.Path.IndexOf("&col01#", StringComparison.OrdinalIgnoreCase) >= 0))
{
var started = NowNs();
var result = TryWriteLed(device, 0, 0, 0);
var completed = NowNs();
AppendPulseEvent(eventLogPath, eventId, writes, "off", 0, 0, 0, started, completed, result);
writes++;
}
}
return $"ok breatheWrites={writes} rgb=#{baseR:X2}{baseG:X2}{baseB:X2} durationSeconds={duration.TotalSeconds:0.###} refreshMs={refresh.TotalMilliseconds:0}";
}
private static byte ScaleColorChannel(byte channel, double intensity) =>
(byte)Math.Clamp((int)Math.Round(channel * intensity), 0, 255);
public static async Task<string> TryWriteScheduledLedTrainAsync(
HidDeviceInfo device,
IReadOnlyList<PairedChirpEvent> events,
byte r,
byte g,
byte b,
TimeSpan pulseDuration,
string? eventLogPath)
{
if (device.Caps.OutputReportByteLength == 0 || device.Path.IndexOf("&col01#", StringComparison.OrdinalIgnoreCase) < 0)
{
return "skipped: LED output uses the PS Move col01 output-report collection";
}
TimeBeginPeriod(1);
try
{
var started = NowNs();
var stopwatch = Stopwatch.StartNew();
var written = 0;
foreach (var item in events)
{
WaitUntil(stopwatch, TimeSpan.FromSeconds(item.OffsetSeconds));
var onStarted = NowNs();
var on = TryWriteLed(device, r, g, b);
var onCompleted = NowNs();
AppendPairedPulseEvent(eventLogPath, item, "on", r, g, b, started, onStarted, onCompleted, on);
if (!on.StartsWith("ok", StringComparison.OrdinalIgnoreCase))
{
return on;
}
if (pulseDuration > TimeSpan.Zero)
{
WaitUntil(stopwatch, TimeSpan.FromSeconds(item.OffsetSeconds) + pulseDuration);
}
var offStarted = NowNs();
var off = TryWriteLed(device, 0, 0, 0);
var offCompleted = NowNs();
AppendPairedPulseEvent(eventLogPath, item, "off", 0, 0, 0, started, offStarted, offCompleted, off);
if (!off.StartsWith("ok", StringComparison.OrdinalIgnoreCase))
{
return $"off failed after {written} events: {off}";
}
written++;
}
return $"ok pairedEvents={written}";
}
finally
{
TimeEndPeriod(1);
}
}
private static void WaitUntil(Stopwatch stopwatch, TimeSpan due)
{
while (true)
{
var remaining = due - stopwatch.Elapsed;
if (remaining <= TimeSpan.Zero)
{
return;
}
if (remaining > TimeSpan.FromMilliseconds(8))
{
Thread.Sleep(Math.Max(1, (int)Math.Floor(remaining.TotalMilliseconds) - 4));
continue;
}
if (remaining > TimeSpan.FromMilliseconds(1.5))
{
Thread.Sleep(0);
continue;
}
Thread.SpinWait(256);
}
}
private static string TryWriteLed(HidDeviceInfo device, byte r, byte g, byte b)
{
using var handle = CreateFile(
device.Path,
GenericWrite,
FileShareRead | FileShareWrite,
IntPtr.Zero,
OpenExisting,
0,
IntPtr.Zero);
if (handle.IsInvalid)
{
return $"open failed: {new Win32Exception(Marshal.GetLastWin32Error()).Message}";
}
var report = new byte[Math.Max(9, (int)device.Caps.OutputReportByteLength)];
report[0] = LedReport;
report[1] = 0;
report[2] = r;
report[3] = g;
report[4] = b;
return WriteFile(handle, report, (uint)report.Length, out var written, IntPtr.Zero)
? $"ok rgb=#{r:X2}{g:X2}{b:X2} bytes={written}"
: $"WriteFile failed: {new Win32Exception(Marshal.GetLastWin32Error()).Message}";
}
public static bool TryParseRgb(string value, out byte r, out byte g, out byte b)
{
r = 0;
g = 0;
b = 0;
var normalized = value.Trim().TrimStart('#');
if (normalized.Length == 6)
{
return byte.TryParse(normalized.AsSpan(0, 2), System.Globalization.NumberStyles.HexNumber, null, out r) &&
byte.TryParse(normalized.AsSpan(2, 2), System.Globalization.NumberStyles.HexNumber, null, out g) &&
byte.TryParse(normalized.AsSpan(4, 2), System.Globalization.NumberStyles.HexNumber, null, out b);
}
var parts = normalized.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return parts.Length == 3 &&
byte.TryParse(parts[0], out r) &&
byte.TryParse(parts[1], out g) &&
byte.TryParse(parts[2], out b);
}
public static long NowNs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
private static void AppendPulseEvent(
string? eventLogPath,
string eventId,
int index,
string phase,
byte r,
byte g,
byte b,
long startedNs,
long completedNs,
string result)
{
if (string.IsNullOrWhiteSpace(eventLogPath))
{
return;
}
var line = System.Text.Json.JsonSerializer.Serialize(new
{
document = "mimir.psmove_usb_pulse_event.v1",
eventId,
index,
phase,
rgb = new[] { (int)r, (int)g, (int)b },
startedNs,
completedNs,
result,
});
File.AppendAllText(eventLogPath, line + Environment.NewLine);
}
private static void AppendPairedPulseEvent(
string? eventLogPath,
PairedChirpEvent item,
string phase,
byte r,
byte g,
byte b,
long trainStartNs,
long startedNs,
long completedNs,
string result)
{
if (string.IsNullOrWhiteSpace(eventLogPath))
{
return;
}
var line = System.Text.Json.JsonSerializer.Serialize(new
{
document = "mimir.psmove_usb_audio_visual_pulse_event.v1",
invariant = "visual pulse and audio chirp share one event id and one planned offset",
item.EventId,
item.Index,
item.OffsetSeconds,
item.AudioStartSample,
item.AudioEndSample,
item.StartHz,
item.EndHz,
phase,
rgb = new[] { (int)r, (int)g, (int)b },
trainStartNs,
startedNs,
completedNs,
result,
});
File.AppendAllText(eventLogPath, line + Environment.NewLine);
}
private static (string? Controller, string? Host) ReadBluetoothAddresses(SafeFileHandle handle)
{
var report = new byte[BtAddressGetSize];
report[0] = GetBtAddressReport;
if (!HidD_GetFeature(handle, report, report.Length))
{
return (null, null);
}
return (FormatBluetoothAddress(report.AsSpan(1, 6)), FormatBluetoothAddress(report.AsSpan(10, 6)));
}
private static bool TryParseBluetoothAddress(string value, out byte[] bytes)
{
bytes = new byte[6];
var parts = value.Split(':', '-');
if (parts.Length != 6)
{
return false;
}
for (var index = 0; index < parts.Length; index++)
{
if (!byte.TryParse(parts[index], System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out var parsed))
{
return false;
}
bytes[5 - index] = parsed;
}
return true;
}
private static string FormatBluetoothAddress(ReadOnlySpan<byte> littleEndianAddress)
{
return string.Join(
":",
littleEndianAddress.ToArray().Reverse().Select(value => value.ToString("x2", System.Globalization.CultureInfo.InvariantCulture)));
}
private static string GetDevicePath(IntPtr deviceInfoSet, SpDeviceInterfaceData interfaceData)
{
SetupDiGetDeviceInterfaceDetail(deviceInfoSet, ref interfaceData, IntPtr.Zero, 0, out var requiredSize, IntPtr.Zero);
if (requiredSize == 0)
{
throw new Win32Exception(Marshal.GetLastWin32Error(), "SetupDiGetDeviceInterfaceDetail size query failed.");
}
var detailData = Marshal.AllocHGlobal((int)requiredSize);
try
{
Marshal.WriteInt32(detailData, IntPtr.Size == 8 ? 8 : 6);
if (!SetupDiGetDeviceInterfaceDetail(deviceInfoSet, ref interfaceData, detailData, requiredSize, out _, IntPtr.Zero))
{
throw new Win32Exception(Marshal.GetLastWin32Error(), "SetupDiGetDeviceInterfaceDetail failed.");
}
return Marshal.PtrToStringUni(IntPtr.Add(detailData, 4))
?? throw new InvalidOperationException("HID device path was empty.");
}
finally
{
Marshal.FreeHGlobal(detailData);
}
}
private static bool TryProbe(string path, out HidDeviceInfo device)
{
device = default!;
if (!TryExtractVidPid(path, out var vid, out var pid))
{
return false;
}
using var handle = CreateFile(
path,
0,
FileShareRead | FileShareWrite,
IntPtr.Zero,
OpenExisting,
0,
IntPtr.Zero);
if (handle.IsInvalid)
{
return false;
}
if (!HidD_GetPreparsedData(handle, out var preparsedData))
{
return false;
}
try
{
var status = HidP_GetCaps(preparsedData, out var caps);
if (status != HidpStatusSuccess)
{
return false;
}
device = new HidDeviceInfo(
path,
vid,
pid,
GetHidString(handle, HidD_GetManufacturerString),
GetHidString(handle, HidD_GetProductString),
GetHidString(handle, HidD_GetSerialNumberString),
caps);
return true;
}
finally
{
HidD_FreePreparsedData(preparsedData);
}
}
private static bool TryExtractVidPid(string path, out ushort vid, out ushort pid)
{
vid = 0;
pid = 0;
var lower = path.ToLowerInvariant();
return TryExtractHexAfter(lower, "vid_", out vid) && TryExtractHexAfter(lower, "pid_", out pid);
}
private static bool TryExtractHexAfter(string value, string marker, out ushort result)
{
result = 0;
var index = value.IndexOf(marker, StringComparison.Ordinal);
if (index < 0 || index + marker.Length + 4 > value.Length)
{
return false;
}
return ushort.TryParse(
value.AsSpan(index + marker.Length, 4),
System.Globalization.NumberStyles.HexNumber,
System.Globalization.CultureInfo.InvariantCulture,
out result);
}
private delegate bool HidStringGetter(SafeFileHandle hidDeviceObject, byte[] buffer, int bufferLength);
private static string? GetHidString(SafeFileHandle handle, HidStringGetter getter)
{
var buffer = new byte[256];
return getter(handle, buffer, buffer.Length)
? Marshal.PtrToStringUni(Marshal.UnsafeAddrOfPinnedArrayElement(buffer, 0))?.TrimEnd('\0')
: null;
}
[DllImport("hid.dll")]
private static extern void HidD_GetHidGuid(out Guid hidGuid);
[DllImport("hid.dll", SetLastError = true)]
private static extern bool HidD_GetPreparsedData(SafeFileHandle hidDeviceObject, out IntPtr preparsedData);
[DllImport("hid.dll", SetLastError = true)]
private static extern bool HidD_FreePreparsedData(IntPtr preparsedData);
[DllImport("hid.dll")]
private static extern int HidP_GetCaps(IntPtr preparsedData, out HidpCaps capabilities);
[DllImport("hid.dll", SetLastError = true)]
private static extern bool HidD_GetManufacturerString(SafeFileHandle hidDeviceObject, byte[] buffer, int bufferLength);
[DllImport("hid.dll", SetLastError = true)]
private static extern bool HidD_GetProductString(SafeFileHandle hidDeviceObject, byte[] buffer, int bufferLength);
[DllImport("hid.dll", SetLastError = true)]
private static extern bool HidD_GetSerialNumberString(SafeFileHandle hidDeviceObject, byte[] buffer, int bufferLength);
[DllImport("hid.dll", SetLastError = true)]
private static extern bool HidD_GetFeature(SafeFileHandle hidDeviceObject, byte[] reportBuffer, int reportBufferLength);
[DllImport("hid.dll", SetLastError = true)]
private static extern bool HidD_SetFeature(SafeFileHandle hidDeviceObject, byte[] reportBuffer, int reportBufferLength);
[DllImport("setupapi.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr SetupDiGetClassDevs(
ref Guid classGuid,
string? enumerator,
IntPtr hwndParent,
int flags);
[DllImport("setupapi.dll", SetLastError = true)]
private static extern bool SetupDiEnumDeviceInterfaces(
IntPtr deviceInfoSet,
IntPtr deviceInfoData,
ref Guid interfaceClassGuid,
uint memberIndex,
ref SpDeviceInterfaceData deviceInterfaceData);
[DllImport("setupapi.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool SetupDiGetDeviceInterfaceDetail(
IntPtr deviceInfoSet,
ref SpDeviceInterfaceData deviceInterfaceData,
IntPtr deviceInterfaceDetailData,
uint deviceInterfaceDetailDataSize,
out uint requiredSize,
IntPtr deviceInfoData);
[DllImport("setupapi.dll", SetLastError = true)]
private static extern bool SetupDiDestroyDeviceInfoList(IntPtr deviceInfoSet);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFile(
string fileName,
uint desiredAccess,
uint shareMode,
IntPtr securityAttributes,
uint creationDisposition,
uint flagsAndAttributes,
IntPtr templateFile);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr CreateEvent(
IntPtr eventAttributes,
bool manualReset,
bool initialState,
string? name);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool ReadFile(
SafeFileHandle file,
byte[] buffer,
uint numberOfBytesToRead,
IntPtr numberOfBytesRead,
ref NativeOverlapped overlapped);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool WriteFile(
SafeFileHandle file,
byte[] buffer,
uint numberOfBytesToWrite,
out uint numberOfBytesWritten,
IntPtr overlapped);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetOverlappedResult(
SafeFileHandle file,
ref NativeOverlapped overlapped,
out uint numberOfBytesTransferred,
bool wait);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CancelIoEx(
SafeFileHandle file,
ref NativeOverlapped overlapped);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle(IntPtr handle);
[DllImport("winmm.dll", EntryPoint = "timeBeginPeriod")]
private static extern uint TimeBeginPeriod(uint period);
[DllImport("winmm.dll", EntryPoint = "timeEndPeriod")]
private static extern uint TimeEndPeriod(uint period);
[StructLayout(LayoutKind.Sequential)]
private struct SpDeviceInterfaceData
{
public int CbSize;
public Guid InterfaceClassGuid;
public int Flags;
public IntPtr Reserved;
}
[StructLayout(LayoutKind.Sequential)]
private struct NativeOverlapped
{
public IntPtr InternalLow;
public IntPtr InternalHigh;
public uint OffsetLow;
public uint OffsetHigh;
public IntPtr EventHandle;
}
}
[StructLayout(LayoutKind.Sequential)]
internal struct HidpCaps
{
public ushort Usage;
public ushort UsagePage;
public ushort InputReportByteLength;
public ushort OutputReportByteLength;
public ushort FeatureReportByteLength;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 17)]
public ushort[] Reserved;
public ushort NumberLinkCollectionNodes;
public ushort NumberInputButtonCaps;
public ushort NumberInputValueCaps;
public ushort NumberInputDataIndices;
public ushort NumberOutputButtonCaps;
public ushort NumberOutputValueCaps;
public ushort NumberOutputDataIndices;
public ushort NumberFeatureButtonCaps;
public ushort NumberFeatureValueCaps;
public ushort NumberFeatureDataIndices;
}