From a79bb1aa9fcfdc74fcccfc14745f8bbde3e1631b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 31 May 2026 17:22:54 +0100 Subject: [PATCH] Add PS Move HID probe --- Mimir.slnx | 1 + docs/implementation-plan.md | 10 + docs/sensor-fusion-architecture.md | 4 + .../Mimir.PsMoveProbe.csproj | 10 + src/Mimir.PsMoveProbe/Program.cs | 444 ++++++++++++++++++ state/evidence.jsonl | 1 + state/map.yaml | 8 + 7 files changed, 478 insertions(+) create mode 100644 src/Mimir.PsMoveProbe/Mimir.PsMoveProbe.csproj create mode 100644 src/Mimir.PsMoveProbe/Program.cs diff --git a/Mimir.slnx b/Mimir.slnx index 4d03909..cc200fc 100644 --- a/Mimir.slnx +++ b/Mimir.slnx @@ -6,6 +6,7 @@ + diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 8c0c659..33a9569 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -426,6 +426,16 @@ a named invariant that the native runtime cannot protect yet. generic KS exposure/gain writes and only recovered two LED components; both PS3 Eye feeds currently score fixed-control frames because their native wrapper does not yet expose gain/exposure. + PS Move is now a concrete active-marker input candidate rather than a lore + object on a cable. `Mimir.PsMoveProbe` enumerates Sony `VID_054C` / + `PID_03D5` over Windows HID with no third-party dependency. The connected + controller exposes three HID collections: one generic desktop gamepad + collection with 49-byte input/output/feature reports, plus two vendor-defined + feature-report collections. The safe `--read` path uses overlapped `ReadFile` + plus `CancelIoEx` so idle input reads time out instead of hanging the probe. + Next useful cut is report decoding and controlled LED/sphere command + provenance, then optical sphere observations from PS3 Eyes/Kiyos can enter + the same calibration-evidence path as LED spline constraints. PS3 Eyes now have the first sparse tracking surface: `MimirSparseFeatureTracker` detects grid-suppressed Bayer/luma corner features, links them frame-to-frame by bounded nearest-neighbor motion, and diff --git a/docs/sensor-fusion-architecture.md b/docs/sensor-fusion-architecture.md index b1bb8cd..986aece 100644 --- a/docs/sensor-fusion-architecture.md +++ b/docs/sensor-fusion-architecture.md @@ -33,6 +33,10 @@ flowchart TD - Stable LED index identity requires temporal/color/address coding or another correspondence source; uncoded identical lights are spline constraints, not metric depth authority. +- PS Move controllers are active tracked-marker witnesses. USB HID owns + identity, buttons, IMU/report topology, and later LED/rumble control; the + glowing sphere is visual calibration evidence only when observed by cameras + and fused by the global residual owner. - Exposure/gain sweeps optimize recovered spline quality: detected LED count, curve coverage, spacing coherence, smoothness, exposure fitness, and saturation penalty. Bright clipped blobs are not good evidence. diff --git a/src/Mimir.PsMoveProbe/Mimir.PsMoveProbe.csproj b/src/Mimir.PsMoveProbe/Mimir.PsMoveProbe.csproj new file mode 100644 index 0000000..ed9781c --- /dev/null +++ b/src/Mimir.PsMoveProbe/Mimir.PsMoveProbe.csproj @@ -0,0 +1,10 @@ + + + + Exe + net10.0 + enable + enable + + + diff --git a/src/Mimir.PsMoveProbe/Program.cs b/src/Mimir.PsMoveProbe/Program.cs new file mode 100644 index 0000000..4d4d510 --- /dev/null +++ b/src/Mimir.PsMoveProbe/Program.cs @@ -0,0 +1,444 @@ +using System.ComponentModel; +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 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) +{ + Console.WriteLine("No PS Move HID collections are visible. Check the USB cable and Windows Device Manager."); + return 1; +} + +foreach (var device in devices) +{ + PrintDevice(device); + + if (readReports) + { + var result = await WindowsHid.TryReadInputReportAsync(device, TimeSpan.FromMilliseconds(350)); + Console.WriteLine($" read={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}"); +} + +internal sealed record HidDeviceInfo( + string Path, + ushort VendorId, + ushort ProductId, + string? Manufacturer, + string? Product, + string? SerialNumber, + HidpCaps Caps); + +internal static class WindowsHid +{ + private const int DigcfPresent = 0x00000002; + private const int DigcfDeviceinterface = 0x00000010; + private const uint GenericRead = 0x80000000; + 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; + + public static IEnumerable Enumerate() + { + foreach (var path in EnumeratePaths()) + { + if (TryProbe(path, out var device)) + { + yield return device; + } + } + } + + public static IEnumerable 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() + }; + + 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 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); + } + } + + 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("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 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); + + [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; +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 134b514..855c88f 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -264,3 +264,4 @@ {"ts": "2026-05-31T08:48:00+01:00", "type": "verification", "status": "ok", "note": "Mimir.EveDashboard provider mimir.stream.layout now exposes a richer portable layout document for the Eve/Fensalir surface: Program Output, Mimir Editor, Mimir Sync, Leap Field, both PS3 Eyes, Kiyo Pro, Kiyo Basic, and Eve Camera carry node kind, health, placement, and detail. Aquarium.EveSurface decoded it through the Eve dashboard broker with activeProviderId=mimir.stream.layout and captured artifacts/runtime/mimir-eve-surface-layout-generic-decoder.png. This proves the portable UI document path, but Mimir.App still draws its operator overlay from AquariumUiDocument until the next cut routes the app UI through the same Eve document authority."} {"ts": "2026-05-31T09:05:35+01:00", "type": "decision", "status": "ok", "note": "Eve dashboard state now treats flat nodes as a compatibility/data projection and adds a CultUI-shaped surface composition tree as the distributed UI authority; VoidBot swarm emits cockpit/ctb-rail/workspace/pane/card/avatar/text/metric elements, and Fensalir parses the surface for the future Direct2D compositor."} {"ts": "2026-05-31T17:02:03+01:00", "type": "decision", "status": "ok", "note": "Eve dashboard now publishes mimir.live.stats as a read-only compact telemetry surface; Odin ingests it alongside VoidBot so GUI/TUI clients lower one accepted CultMesh surface instead of scraping renderers."} +{"ts": "2026-05-31T17:20:52+01:00", "type": "implementation", "status": "ok", "note": "Added a dependency-free Mimir.PsMoveProbe for Sony PS Move VID_054C/PID_03D5 over Windows HID. Live USB probe found three collections: generic desktop gamepad with 49-byte input/output/feature reports plus two vendor-defined feature-report collections. Safe read probing now uses overlapped ReadFile/CancelIoEx and times out cleanly at idle; PS Move should be treated as an active marker/IMU witness whose glowing sphere becomes camera calibration evidence, not as a depth owner."} diff --git a/state/map.yaml b/state/map.yaml index 809f0f4..6ba4e98 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -154,6 +154,14 @@ invariants: for both Kiyo Pro and Kiyo Basic. LeapUVC rejected the generic KS exposure/gain writes and PS3 Eye capture remains fixed-control until its native wrapper owns gain/exposure. + - PS Move is usable as an active marker/IMU witness, not as an optical depth + owner. `Mimir.PsMoveProbe` now enumerates Sony `VID_054C` / `PID_03D5` + through Windows HID without third-party packages. The connected controller + exposes three collections: a generic desktop gamepad collection with + 49-byte input/output/feature reports and two vendor-defined feature-report + collections. USB report decoding should own identity/buttons/IMU and later + LED sphere control; camera observations of the sphere should enter the + existing global calibration evidence path. - PS3 Eyes now have a first sparse tracking surface instead of only cadence proof. `MimirSparseFeatureTracker` detects Bayer/luma corner features, links them into sensor-local tracks with clipspace position, velocity, age,