From 45c9ce961173c75eab39d66167cdb7178e542827 Mon Sep 17 00:00:00 2001 From: Danielle Date: Fri, 8 May 2026 20:11:36 +1000 Subject: [PATCH 1/5] Added PlayStation-Device-Implementations (DualShock 4, DualSense, DualSense Edge) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../DualSense/DualSenseRGBDevice.cs | 65 ++ .../DualSense/DualSenseUpdateQueue.cs | 265 ++++++++ .../DualShock4/DualShock4RGBDevice.cs | 45 ++ .../DualShock4/DualShock4UpdateQueue.cs | 189 ++++++ .../Generic/PlayStationControllerType.cs | 16 + .../Generic/PlayStationCrc32.cs | 58 ++ .../Generic/PlayStationDeviceInfo.cs | 71 +++ .../Generic/PlayStationTransport.cs | 13 + .../PlayStationDeviceProvider.cs | 598 ++++++++++++++++++ RGB.NET.Devices.PlayStation/README.md | 63 ++ .../RGB.NET.Devices.PlayStation.csproj | 67 ++ RGB.NET.sln | 7 + 12 files changed, 1457 insertions(+) create mode 100644 RGB.NET.Devices.PlayStation/DualSense/DualSenseRGBDevice.cs create mode 100644 RGB.NET.Devices.PlayStation/DualSense/DualSenseUpdateQueue.cs create mode 100644 RGB.NET.Devices.PlayStation/DualShock4/DualShock4RGBDevice.cs create mode 100644 RGB.NET.Devices.PlayStation/DualShock4/DualShock4UpdateQueue.cs create mode 100644 RGB.NET.Devices.PlayStation/Generic/PlayStationControllerType.cs create mode 100644 RGB.NET.Devices.PlayStation/Generic/PlayStationCrc32.cs create mode 100644 RGB.NET.Devices.PlayStation/Generic/PlayStationDeviceInfo.cs create mode 100644 RGB.NET.Devices.PlayStation/Generic/PlayStationTransport.cs create mode 100644 RGB.NET.Devices.PlayStation/PlayStationDeviceProvider.cs create mode 100644 RGB.NET.Devices.PlayStation/README.md create mode 100644 RGB.NET.Devices.PlayStation/RGB.NET.Devices.PlayStation.csproj diff --git a/RGB.NET.Devices.PlayStation/DualSense/DualSenseRGBDevice.cs b/RGB.NET.Devices.PlayStation/DualSense/DualSenseRGBDevice.cs new file mode 100644 index 00000000..d708b391 --- /dev/null +++ b/RGB.NET.Devices.PlayStation/DualSense/DualSenseRGBDevice.cs @@ -0,0 +1,65 @@ +using RGB.NET.Core; + +namespace RGB.NET.Devices.PlayStation; + +/// +/// +/// Represents a Sony DualSense controller (PS5 / DualSense Edge). +/// +public sealed class DualSenseRGBDevice : AbstractRGBDevice +{ + #region Properties & Fields + + private readonly DualSenseUpdateQueue _updateQueue; + + #endregion + + #region Constructors + + internal DualSenseRGBDevice(PlayStationDeviceInfo deviceInfo, DualSenseUpdateQueue updateQueue) + : base(deviceInfo, updateQueue) + { + _updateQueue = updateQueue; + InitializeLayout(); + } + + #endregion + + #region Methods + + // DualSense LED layout (left→right when looking at the controller): + // - The lightbar runs along the bottom edge of the touchpad in two + // mirrored strips. Modelled as one wide rectangle (Custom1). + // - The 5 player indicator LEDs sit in a row directly below the + // touchpad. Bit 0 = leftmost, bit 4 = rightmost from the player's + // POV (matches Linux's player_leds bit ordering). + // + // Note: the mic-mute LED is intentionally NOT exposed. The controller + // firmware drives that LED to track mic-mute toggle state — pressing + // the mute button mutes the microphone AND lights the LED, regardless + // of any host involvement. Taking host control of the LED would only + // suppress that visual feedback for an action that still happens, so + // we leave the firmware default in place. See DualSenseUpdateQueue + // header for the protocol detail (we deliberately don't set the + // MIC_MUTE_LED_CONTROL_ENABLE bit in valid_flag1). + // + // Coordinates are arbitrary visual approximations for layout consumers — + // they don't drive any hardware addressing. + private void InitializeLayout() + { + Led? lightbar = AddLed(LedId.Custom1, new Point(0, 0), new Size(80, 8)); + if (lightbar != null) lightbar.Shape = Shape.Rectangle; + + // Five player indicator dots, evenly spaced beneath the lightbar. + for (int i = 0; i < 5; i++) + { + Led? led = AddLed((LedId)(LedId.Custom2 + i), new Point(20 + (i * 12), 16), new Size(6)); + if (led != null) led.Shape = Shape.Circle; + } + } + + internal void SuspendWrites() => _updateQueue.SuspendWrites(); + internal void Shutdown(bool sendOffFrame = true) => _updateQueue.Shutdown(sendOffFrame); + + #endregion +} diff --git a/RGB.NET.Devices.PlayStation/DualSense/DualSenseUpdateQueue.cs b/RGB.NET.Devices.PlayStation/DualSense/DualSenseUpdateQueue.cs new file mode 100644 index 00000000..b62a4f49 --- /dev/null +++ b/RGB.NET.Devices.PlayStation/DualSense/DualSenseUpdateQueue.cs @@ -0,0 +1,265 @@ +using System; +using System.Diagnostics; +using System.Threading; +using HidSharp; +using RGB.NET.Core; + +namespace RGB.NET.Devices.PlayStation; + +// Builds and writes DualSense main output reports. +// USB report 0x02, 63 bytes total (incl. report ID). +// BT report 0x31, 78 bytes total + trailing little-endian CRC32. +// +// The DualSense exposes: +// - one RGB lightbar (sides of the touchpad) +// - five monochrome player indicator LEDs in a row below the touchpad +// - one mic-mute LED (orange, in the centre of the mic-mute button) — +// NOT exposed by this provider. We deliberately leave the firmware in +// control so the LED keeps its default behaviour of tracking the +// hardware mic-mute toggle (tap the button → firmware mutes the mic +// AND lights the LED). The mute BUTTON itself remains firmware-driven +// regardless of host activity, so taking control of the LED would +// only suppress the visual feedback for an action that still happens. +// +// We model the controllable LEDs as Custom1 (lightbar) + Custom2..Custom6 +// (P1..P5 left→right in bit-position order, see player_leds bit layout +// below). Player indicators are monochrome so any non-black colour turns +// them on at full brightness, and pure black turns them off. +// +// valid_flag1 gates which sub-systems the controller should accept updates +// for. Without those bits set, the controller ignores the corresponding +// bytes — so we must set them every report or e.g. the lightbar will stay +// on the firmware's default. We deliberately do NOT set the mic-mute-LED +// bit (BIT(0)), which keeps the firmware-driven default LED behaviour. +// +// The first report after open also sets LIGHTBAR_SETUP_CONTROL_ENABLE + +// lightbar_setup = 0x02 ("release leds"). On a fresh connect, the +// controller plays a fade-in animation on the lightbar that overrides +// host-driven colours until released. Without this one-shot, the first +// few seconds of host control look like nothing is happening. +internal sealed class DualSenseUpdateQueue : UpdateQueue +{ + #region Constants + + // valid_flag1 bits we want the controller to honour. Mic-mute LED is + // intentionally NOT here — see file header for rationale. + // BIT(0) = MIC_MUTE_LED_CONTROL_ENABLE remains clear; the firmware + // ignores any value we'd put in mute_button_led and uses its own logic. + private const byte VALID_FLAG1_LIGHTBAR_CONTROL = 0x04; // BIT(2) + private const byte VALID_FLAG1_PLAYER_INDICATOR_CONTROL = 0x10; // BIT(4) + private const byte VALID_FLAG1_ALL = + VALID_FLAG1_LIGHTBAR_CONTROL | VALID_FLAG1_PLAYER_INDICATOR_CONTROL; + + // valid_flag2 bit for the one-shot "release lightbar from boot animation" + // setup. Cleared after the first report. + private const byte VALID_FLAG2_LIGHTBAR_SETUP_CONTROL = 0x02; // BIT(1) + private const byte LIGHTBAR_SETUP_RELEASE_LEDS = 0x02; + + // BT-specific tag — Sony driver requires a fixed value here. Lower 4 + // bits of seq_tag carry an alternate tag (0); upper 4 bits carry a + // sequence number that increments per report. + private const byte BT_TAG = 0x10; + + #endregion + + #region Properties & Fields + + private readonly HidStream _stream; + private readonly PlayStationTransport _transport; + private readonly byte[] _buffer; + private readonly string _devicePath; + private readonly Lock _writeLock = new(); + private byte _btSeq; // 0..15 rolling + private bool _firstReport = true; + private volatile bool _disposed; + + #endregion + + #region Constructors + + public DualSenseUpdateQueue(IDeviceUpdateTrigger trigger, HidStream stream, PlayStationTransport transport, string devicePath) + : base(trigger) + { + _stream = stream; + _transport = transport; + _devicePath = devicePath ?? string.Empty; + _buffer = new byte[transport == PlayStationTransport.Bluetooth ? 78 : 63]; + } + + #endregion + + #region Methods + + protected override bool Update(ReadOnlySpan<(object key, Color color)> dataSet) + { + if (_disposed) return true; + if (dataSet.IsEmpty) return true; + + // Per-frame liveness pre-check; see DualShock4UpdateQueue.Update. + if (!PlayStationDeviceProvider.IsDevicePathAlive(_devicePath)) + { + _disposed = true; + return false; + } + + // Walk the painted LEDs and split them into the payload slots the + // report cares about. dataSet entries arrive keyed by LedId, so we + // can address them individually instead of trusting iteration order. + Color lightbar = default; + byte playerLedBits = 0; + bool gotLightbar = false; + + foreach ((object key, Color color) in dataSet) + { + if (key is not LedId id) continue; + switch (id) + { + case LedId.Custom1: + lightbar = color; + gotLightbar = true; + break; + // Custom2..Custom6 = player indicators 1..5 (bits 0..4) + case LedId.Custom2: if (IsLit(color)) playerLedBits |= 1 << 0; break; + case LedId.Custom3: if (IsLit(color)) playerLedBits |= 1 << 1; break; + case LedId.Custom4: if (IsLit(color)) playerLedBits |= 1 << 2; break; + case LedId.Custom5: if (IsLit(color)) playerLedBits |= 1 << 3; break; + case LedId.Custom6: if (IsLit(color)) playerLedBits |= 1 << 4; break; + } + } + + // If we somehow get a payload with no lightbar entry (should not + // happen since RGB.NET commits every device LED each tick), keep + // the lightbar at black instead of leaving uninitialised state. + if (!gotLightbar) lightbar = new Color(0, 0, 0); + + try + { + lock (_writeLock) + { + Array.Clear(_buffer, 0, _buffer.Length); + BuildReport(lightbar, playerLedBits); + _stream.Write(_buffer); + } + _firstReport = false; + return true; + } + catch (Exception ex) + { + Trace.WriteLine($"[RGB.NET.PlayStation] DualSense write failed, suspending queue: {ex.Message}"); + _disposed = true; + return false; + } + } + + private static bool IsLit(Color c) => (c.R > 0) || (c.G > 0) || (c.B > 0); + + private void BuildReport(Color lightbar, byte playerLedBits) + { + byte r = (byte)Math.Clamp((int)Math.Round(lightbar.R * 255.0), 0, 255); + byte g = (byte)Math.Clamp((int)Math.Round(lightbar.G * 255.0), 0, 255); + byte b = (byte)Math.Clamp((int)Math.Round(lightbar.B * 255.0), 0, 255); + + int commonOffset; // start of the 47-byte common block within _buffer + + if (_transport == PlayStationTransport.Bluetooth) + { + // BT report 0x31: + // [0] report_id (0x31) + // [1] seq_tag (high 4 bits = sequence number 0..15) + // [2] tag (0x10) + // [3..49] common (47 bytes) + // [50..73] reserved (24 bytes) + // [74..77] CRC32 (LE) + _buffer[0] = 0x31; + _buffer[1] = (byte)((_btSeq << 4) & 0xF0); + _buffer[2] = BT_TAG; + commonOffset = 3; + _btSeq = (byte)((_btSeq + 1) & 0x0F); + } + else + { + // USB report 0x02: + // [0] report_id (0x02) + // [1..47] common (47 bytes) + // [48..62] reserved (15 bytes) + _buffer[0] = 0x02; + commonOffset = 1; + } + + // dualsense_output_report_common offsets, 0-indexed from start of + // common block. See struct dualsense_output_report_common in + // Linux's hid-playstation.c. + // [0] valid_flag0 + // [1] valid_flag1 + // [2] motor_right + // [3] motor_left + // [4] headphone_volume + // [5] speaker_volume + // [6] mic_volume + // [7] audio_control + // [8] mute_button_led + // [9] power_save_control + // [10..36] reserved2 (27 bytes) + // [37] audio_control2 + // [38] valid_flag2 + // [39..40] reserved3 + // [41] lightbar_setup + // [42] led_brightness + // [43] player_leds + // [44] lightbar_red + // [45] lightbar_green + // [46] lightbar_blue + int c = commonOffset; + _buffer[c + 0] = 0; // valid_flag0 + _buffer[c + 1] = VALID_FLAG1_ALL; // valid_flag1 + // motor_*, audio, power_save, mute_button_led left zero. The + // mute_button_led byte (offset 8) is ignored by firmware because + // we don't set MIC_MUTE_LED_CONTROL_ENABLE in valid_flag1, so + // the firmware retains its default LED-tracks-mute-state behaviour. + + if (_firstReport) + { + _buffer[c + 38] = VALID_FLAG2_LIGHTBAR_SETUP_CONTROL; // valid_flag2 + _buffer[c + 41] = LIGHTBAR_SETUP_RELEASE_LEDS; // lightbar_setup + } + + _buffer[c + 42] = 0; // led_brightness (0 = full per Sony default) + _buffer[c + 43] = (byte)(playerLedBits & 0x1F); // player_leds (bits 0..4) + _buffer[c + 44] = r; // lightbar_red + _buffer[c + 45] = g; // lightbar_green + _buffer[c + 46] = b; // lightbar_blue + + if (_transport == PlayStationTransport.Bluetooth) + PlayStationCrc32.AppendOutputCrc(_buffer); + } + + /// + /// See for the contract. + /// + public void SuspendWrites() => _disposed = true; + + /// + /// See for the contract. + /// + public void Shutdown(bool sendOffFrame = true) + { + if (_disposed) return; + _disposed = true; + if (!sendOffFrame) return; + try + { + lock (_writeLock) + { + Array.Clear(_buffer, 0, _buffer.Length); + BuildReport(new Color(0, 0, 0), 0); + _stream.Write(_buffer); + } + } + catch + { + // Best-effort. + } + } + + #endregion +} diff --git a/RGB.NET.Devices.PlayStation/DualShock4/DualShock4RGBDevice.cs b/RGB.NET.Devices.PlayStation/DualShock4/DualShock4RGBDevice.cs new file mode 100644 index 00000000..02c0f3ba --- /dev/null +++ b/RGB.NET.Devices.PlayStation/DualShock4/DualShock4RGBDevice.cs @@ -0,0 +1,45 @@ +using RGB.NET.Core; + +namespace RGB.NET.Devices.PlayStation; + +/// +/// +/// Represents a Sony DualShock 4 controller. +/// +public sealed class DualShock4RGBDevice : AbstractRGBDevice +{ + #region Properties & Fields + + private readonly DualShock4UpdateQueue _updateQueue; + + #endregion + + #region Constructors + + internal DualShock4RGBDevice(PlayStationDeviceInfo deviceInfo, DualShock4UpdateQueue updateQueue) + : base(deviceInfo, updateQueue) + { + _updateQueue = updateQueue; + InitializeLayout(); + } + + #endregion + + #region Methods + + // DS4 has a single RGB lightbar above the touchpad. Custom1 keeps the LED + // enum stable across DS4 / DS5 — DualSenseRGBDevice's Custom1 is also the + // lightbar so a host mapping for "Custom 1" carries sensible meaning across + // both controller types. + private void InitializeLayout() + { + Led? lightbar = AddLed(LedId.Custom1, new Point(0, 0), new Size(60, 14)); + if (lightbar != null) + lightbar.Shape = Shape.Rectangle; + } + + internal void SuspendWrites() => _updateQueue.SuspendWrites(); + internal void Shutdown(bool sendOffFrame = true) => _updateQueue.Shutdown(sendOffFrame); + + #endregion +} diff --git a/RGB.NET.Devices.PlayStation/DualShock4/DualShock4UpdateQueue.cs b/RGB.NET.Devices.PlayStation/DualShock4/DualShock4UpdateQueue.cs new file mode 100644 index 00000000..a6010c75 --- /dev/null +++ b/RGB.NET.Devices.PlayStation/DualShock4/DualShock4UpdateQueue.cs @@ -0,0 +1,189 @@ +using System; +using System.Diagnostics; +using System.Threading; +using HidSharp; +using RGB.NET.Core; + +namespace RGB.NET.Devices.PlayStation; + +// Builds and writes DualShock 4 main output reports. +// USB report 0x05, 32 bytes total (incl. report ID). +// BT report 0x11, 78 bytes total + trailing little-endian CRC32. +// +// Byte layouts mirror struct dualshock4_output_report_{usb,bt} in the Linux +// hid-playstation driver. See PlayStationCrc32 for the CRC details. +// +// The DS4 only has a single RGB lightbar (no player indicator row, no mic LED), +// so this queue handles one Color slot. +internal sealed class DualShock4UpdateQueue : UpdateQueue +{ + #region Constants + + // valid_flag0 bits — DS4_OUTPUT_VALID_FLAG0_LED. We only update LEDs; rumble + // & blink stay 0 so the controller doesn't vibrate when the queue runs. + private const byte VALID_FLAG0_LED = 0x02; + + // hw_control bits for BT report 0x11. Driver always sets HID|CRC32 so the + // controller knows the report carries main HID state and validates the CRC + // tail. Lower 6 bits are the BT poll interval (0 = 1ms, fastest). + private const byte BT_HW_CONTROL_HID = 0x80; + private const byte BT_HW_CONTROL_CRC32 = 0x40; + + #endregion + + #region Properties & Fields + + private readonly HidStream _stream; + private readonly PlayStationTransport _transport; + private readonly byte[] _buffer; + private readonly string _devicePath; + private readonly Lock _writeLock = new(); + private volatile bool _disposed; + + #endregion + + #region Constructors + + public DualShock4UpdateQueue(IDeviceUpdateTrigger trigger, HidStream stream, PlayStationTransport transport, string devicePath) + : base(trigger) + { + _stream = stream; + _transport = transport; + _devicePath = devicePath ?? string.Empty; + _buffer = new byte[transport == PlayStationTransport.Bluetooth ? 78 : 32]; + } + + #endregion + + #region Methods + + protected override bool Update(ReadOnlySpan<(object key, Color color)> dataSet) + { + if (_disposed) return true; + if (dataSet.IsEmpty) return true; + + // Per-frame liveness pre-check. The provider's PnP handler refreshes + // this snapshot synchronously the moment the OS reports a change, + // while we're consulted on the trigger thread. Skipping the Write + // here avoids the IOException entirely on hot-unplug. + if (!PlayStationDeviceProvider.IsDevicePathAlive(_devicePath)) + { + _disposed = true; + return false; + } + + // The DualShock4 device exposes a single Lightbar LED. Take the first + // colour we see — RGB.NET commits the painted colour for that LED each tick. + Color color = dataSet[0].color; + + try + { + lock (_writeLock) + { + Array.Clear(_buffer, 0, _buffer.Length); + BuildReport(color); + _stream.Write(_buffer); + } + return true; + } + catch (Exception ex) + { + // Device went away mid-write or another tool grabbed exclusive access. + // Suspend the queue so the next 30Hz tick short-circuits at the + // `if (_disposed)` gate. The provider's hot-plug Reconcile will + // RemoveDevice us shortly. + Trace.WriteLine($"[RGB.NET.PlayStation] DualShock4 write failed, suspending queue: {ex.Message}"); + _disposed = true; + return false; + } + } + + private void BuildReport(Color color) + { + byte r = (byte)Math.Clamp((int)Math.Round(color.R * 255.0), 0, 255); + byte g = (byte)Math.Clamp((int)Math.Round(color.G * 255.0), 0, 255); + byte b = (byte)Math.Clamp((int)Math.Round(color.B * 255.0), 0, 255); + + if (_transport == PlayStationTransport.Bluetooth) + { + // BT report 0x11. Layout: report_id, hw_control, audio_control, + // then the common 9-byte block (valid_flag0, valid_flag1, + // reserved, motor_right, motor_left, lightbar_red/green/blue, + // blink_on, blink_off), then 61 bytes reserved padding, then + // 4-byte LE CRC32 over [0xA2 || buffer[0..len-4]]. + _buffer[0] = 0x11; + _buffer[1] = BT_HW_CONTROL_HID | BT_HW_CONTROL_CRC32; // hw_control + _buffer[2] = 0; // audio_control + _buffer[3] = VALID_FLAG0_LED; // valid_flag0 + _buffer[4] = 0; // valid_flag1 + _buffer[5] = 0; // reserved + _buffer[6] = 0; // motor_right + _buffer[7] = 0; // motor_left + _buffer[8] = r; // lightbar_red + _buffer[9] = g; // lightbar_green + _buffer[10] = b; // lightbar_blue + _buffer[11] = 0; // blink_on + _buffer[12] = 0; // blink_off + // Bytes 13..73 already zero from Array.Clear. + PlayStationCrc32.AppendOutputCrc(_buffer); + } + else + { + // USB report 0x05. Layout: report_id, common(10 bytes), then + // 21 bytes reserved padding. No CRC. + _buffer[0] = 0x05; + _buffer[1] = VALID_FLAG0_LED; + _buffer[2] = 0; + _buffer[3] = 0; // reserved + _buffer[4] = 0; // motor_right + _buffer[5] = 0; // motor_left + _buffer[6] = r; + _buffer[7] = g; + _buffer[8] = b; + _buffer[9] = 0; + _buffer[10] = 0; + } + } + + /// + /// Sets the queue's disposed flag so subsequent ticks short-circuit before + /// attempting any HidStream.Write. Called by the provider's immediate + /// hot-unplug handler the moment the OS reports the device is gone, so the + /// next trigger tick has nothing to write. Lighter than Shutdown — no off- + /// frame attempt, no other state mutation. Idempotent. + /// + public void SuspendWrites() => _disposed = true; + + /// + /// Tears down the queue. defaults to true for + /// "voluntary" teardowns (provider unloaded by the user, app exit) where the + /// controller is still connected and benefits from a clean off-state. Pass + /// false from the hot-plug-disconnect path. + /// + public void Shutdown(bool sendOffFrame = true) + { + if (_disposed) return; + _disposed = true; + if (!sendOffFrame) return; + // Send one final all-zero lightbar so the controller doesn't sit on our + // last colour after we tear down. The controller's firmware restores + // the OS-driven indicator (e.g. player number) shortly after we stop + // sending reports anyway, but explicit black avoids the visible "stuck + // on last colour" beat between shutdown and firmware reset. + try + { + lock (_writeLock) + { + Array.Clear(_buffer, 0, _buffer.Length); + BuildReport(new Color(0, 0, 0)); + _stream.Write(_buffer); + } + } + catch + { + // Best-effort — handle may already have been invalidated. + } + } + + #endregion +} diff --git a/RGB.NET.Devices.PlayStation/Generic/PlayStationControllerType.cs b/RGB.NET.Devices.PlayStation/Generic/PlayStationControllerType.cs new file mode 100644 index 00000000..374f3e04 --- /dev/null +++ b/RGB.NET.Devices.PlayStation/Generic/PlayStationControllerType.cs @@ -0,0 +1,16 @@ +namespace RGB.NET.Devices.PlayStation; + +/// +/// Identifies the family of PlayStation controller exposed by the provider. +/// +public enum PlayStationControllerType +{ + /// The PlayStation 4 DualShock 4 controller (v1 and v2). + DualShock4, + + /// The PlayStation 5 DualSense controller. + DualSense, + + /// The PlayStation 5 DualSense Edge controller. + DualSenseEdge, +} diff --git a/RGB.NET.Devices.PlayStation/Generic/PlayStationCrc32.cs b/RGB.NET.Devices.PlayStation/Generic/PlayStationCrc32.cs new file mode 100644 index 00000000..fb1fb6c4 --- /dev/null +++ b/RGB.NET.Devices.PlayStation/Generic/PlayStationCrc32.cs @@ -0,0 +1,58 @@ +namespace RGB.NET.Devices.PlayStation; + +// Sony's BT main output reports for both DualShock 4 (0x11) and DualSense (0x31) +// end in a little-endian CRC-32 over the report contents, prefixed with a magic +// seed byte 0xA2 ("output report" tag — 0xA1 input, 0xA3 feature). Algorithm is +// standard CRC-32/zlib (poly 0xEDB88320, init 0xFFFFFFFF, reflected, XOR-out +// 0xFFFFFFFF). Mirrors crc32_le in the Linux hid-playstation driver. +// +// Newer PS5 firmware silently drops malformed reports — bad CRC means the +// lightbar simply doesn't change, no transport-level error. Test on real +// hardware once any byte layout shifts. +internal static class PlayStationCrc32 +{ + // Output-report seed prepended to the CRC input. 0xA1=input, 0xA2=output, 0xA3=feature. + public const byte OutputReportSeed = 0xA2; + + private static readonly uint[] Table = BuildTable(); + + private static uint[] BuildTable() + { + const uint poly = 0xEDB88320u; + uint[] table = new uint[256]; + for (uint i = 0; i < 256; i++) + { + uint c = i; + for (int j = 0; j < 8; j++) + c = (c & 1) != 0 ? (poly ^ (c >> 1)) : (c >> 1); + table[i] = c; + } + return table; + } + + // Compute the CRC32 to write into the last 4 bytes of a BT output report. + // `data` is the buffer including the report ID at index 0; `payloadLength` + // is the count of bytes that participate in the CRC (everything before the + // 4-byte CRC tail, i.e. typically buffer.Length - 4). + public static uint ComputeOutputCrc(byte[] data, int payloadLength) + { + uint crc = 0xFFFFFFFFu; + // Seed byte first (matches Linux: crc32_le(0xFFFFFFFF, &seed, 1)) + crc = (crc >> 8) ^ Table[(crc ^ OutputReportSeed) & 0xFF]; + for (int i = 0; i < payloadLength; i++) + crc = (crc >> 8) ^ Table[(crc ^ data[i]) & 0xFF]; + return ~crc; + } + + // Convenience: compute and write the CRC into the last 4 bytes of `buffer`. + // Caller is responsible for sizing `buffer` correctly (DS4 BT = 78, DS5 BT = 78). + public static void AppendOutputCrc(byte[] buffer) + { + uint crc = ComputeOutputCrc(buffer, buffer.Length - 4); + int o = buffer.Length - 4; + buffer[o + 0] = (byte)(crc & 0xFF); + buffer[o + 1] = (byte)((crc >> 8) & 0xFF); + buffer[o + 2] = (byte)((crc >> 16) & 0xFF); + buffer[o + 3] = (byte)((crc >> 24) & 0xFF); + } +} diff --git a/RGB.NET.Devices.PlayStation/Generic/PlayStationDeviceInfo.cs b/RGB.NET.Devices.PlayStation/Generic/PlayStationDeviceInfo.cs new file mode 100644 index 00000000..6716c7bf --- /dev/null +++ b/RGB.NET.Devices.PlayStation/Generic/PlayStationDeviceInfo.cs @@ -0,0 +1,71 @@ +using RGB.NET.Core; + +namespace RGB.NET.Devices.PlayStation; + +/// +/// +/// Represents a generic device-info for a PlayStation controller. +/// +public sealed class PlayStationDeviceInfo : IRGBDeviceInfo +{ + #region Properties & Fields + + /// Gets the controller family (DualShock 4 / DualSense / DualSense Edge). + public PlayStationControllerType ControllerType { get; } + + /// Gets the transport the controller is connected by. + public PlayStationTransport Transport { get; } + + /// + /// Gets a stable per-controller identifier derived from the OS device path. Used + /// to disambiguate two same-model controllers connected at the same time. + /// + public string SerialNumber { get; } + + /// + public RGBDeviceType DeviceType { get; } + + /// + public string DeviceName { get; } + + /// + public string Manufacturer { get; } + + /// + public string Model { get; } + + /// + public object? LayoutMetadata { get; set; } + + #endregion + + #region Constructors + + internal PlayStationDeviceInfo(PlayStationControllerType controllerType, PlayStationTransport transport, string serialNumber) + { + this.ControllerType = controllerType; + this.Transport = transport; + this.SerialNumber = serialNumber ?? string.Empty; + + this.DeviceType = RGBDeviceType.GameController; + this.Manufacturer = "Sony"; + this.Model = controllerType switch + { + PlayStationControllerType.DualShock4 => "DualShock 4", + PlayStationControllerType.DualSense => "DualSense", + PlayStationControllerType.DualSenseEdge => "DualSense Edge", + _ => "PlayStation Controller", + }; + + // Including transport + serial in DeviceName gives every physical controller + // a stable identity that survives app restarts and disambiguates two same- + // model controllers without depending on enumeration order. Trade-off: + // switching the same controller from USB to BT produces a new name, so any + // host-side state keyed by DeviceName won't auto-apply across transports. + string transportTag = transport == PlayStationTransport.Bluetooth ? "BT" : "USB"; + string serialTag = !string.IsNullOrEmpty(SerialNumber) ? $" [{SerialNumber}]" : string.Empty; + this.DeviceName = $"{Model} ({transportTag}){serialTag}"; + } + + #endregion +} diff --git a/RGB.NET.Devices.PlayStation/Generic/PlayStationTransport.cs b/RGB.NET.Devices.PlayStation/Generic/PlayStationTransport.cs new file mode 100644 index 00000000..8e46ae02 --- /dev/null +++ b/RGB.NET.Devices.PlayStation/Generic/PlayStationTransport.cs @@ -0,0 +1,13 @@ +namespace RGB.NET.Devices.PlayStation; + +/// +/// Identifies the physical transport over which a PlayStation controller is connected. +/// +public enum PlayStationTransport +{ + /// USB (wired) connection. + Usb, + + /// Bluetooth (wireless) connection. + Bluetooth, +} diff --git a/RGB.NET.Devices.PlayStation/PlayStationDeviceProvider.cs b/RGB.NET.Devices.PlayStation/PlayStationDeviceProvider.cs new file mode 100644 index 00000000..7d93e841 --- /dev/null +++ b/RGB.NET.Devices.PlayStation/PlayStationDeviceProvider.cs @@ -0,0 +1,598 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using HidSharp; +using RGB.NET.Core; + +namespace RGB.NET.Devices.PlayStation; + +/// +/// +/// Represents a device provider responsible for Sony PlayStation controllers — +/// DualShock 4 (PS4) and DualSense / DualSense Edge (PS5). +/// +/// +/// Talks raw HID via HidSharp; no third-party drivers (no DS4Windows, no SignalRGB, +/// no HidHide). Both USB and Bluetooth transports are supported. +/// +/// Lighting only — input reports continue to flow through the OS HID stack to games +/// normally. Sony's HID gamepads accept *shared* output writes by default, so +/// coexisting with Steam or a game's native lighting integration is the expected +/// case. Last-writer-wins per output report period; at the 30Hz cadence this +/// provider comfortably overrides most intermittent setters (Steam profile changes, +/// game state events). +/// +/// Hot-plug: 's Changed event fires on PnP events +/// (USB connect/disconnect, BT pair/unpair). Reconcile is debounced, then +/// the open set is diffed against the current HID enumeration — new +/// controllers get opened + AddDevice'd, removed ones are disposed and +/// RemoveDevice'd. +/// +/// Known collisions: +/// +/// DS4Windows / reWASD with "Exclusive Mode" enabled — they hold the HID +/// handle exclusive, so opens fail with UnauthorizedAccessException / +/// IOException. +/// HidHide hiding the controller from non-allow-listed apps — the device +/// never appears in HidSharp enumeration. +/// +/// +public sealed class PlayStationDeviceProvider : AbstractRGBDeviceProvider +{ + #region Constants + + // Sony Interactive Entertainment's USB vendor id. + private const int SONY_VENDOR_ID = 0x054C; + + // PlayStation HID product ids relevant for lighting. + // DualShock 4 v1: 0x05C4 (original "JDM-001/011"). + // DualShock 4 v2: 0x09CC (revised "JDM-040/050/055" with lightbar visible + // through touchpad). + // DualSense: 0x0CE6 (PS5 launch model "CFI-ZCT1"). + // DualSense Edge: 0x0DF2 (PS5 pro variant "CFI-ZCP1"). + // The "Wireless Adapter" 0x0BA0 is the BT bridge for DS4 — also has the + // Sony VID and reports as a DualShock 4. Treated like DS4 v2 (later + // firmware, supports same lighting protocol). + private const int PID_DUALSHOCK4_V1 = 0x05C4; + private const int PID_DUALSHOCK4_V2 = 0x09CC; + private const int PID_DUALSHOCK4_WIRELESS = 0x0BA0; + private const int PID_DUALSENSE = 0x0CE6; + private const int PID_DUALSENSE_EDGE = 0x0DF2; + + // 30Hz update rate. Faster than Steam's intermittent profile-change writes, + // slower than USB full-speed bandwidth (would run fine at 250Hz but it's + // wasted writes — perceptual change isn't there). Matches what OpenRGB's + // DualSense plugin uses. + private const double UPDATE_FREQUENCY_SECONDS = 1.0 / 30.0; + + // PnP can fire several Changed events for one logical connect (driver + // initialisation, child interface enumeration, etc.). Coalesce them + // AND wait long enough that the OS has finished setting up the HID + // device — TryOpen can succeed against a partially-enumerated device + // and the first write will then fail with "A device which does not + // exist was specified". + private const int HOTPLUG_DEBOUNCE_MS = 1500; + + #endregion + + #region Properties & Fields + + // ReSharper disable once InconsistentNaming + private static readonly Lock _lock = new(); + + private static PlayStationDeviceProvider? _instance; + + /// Gets the singleton instance. + public static PlayStationDeviceProvider Instance + { + get + { + lock (_lock) + return _instance ?? new PlayStationDeviceProvider(); + } + } + + // Per-device state needed for lifecycle: the open HidStream (for dispose + // on remove) and the HidDevice's DevicePath (for identity comparison + // during reconcile, since serial isn't always available, especially on + // BT-paired controllers). Both keyed by IRGBDevice so RemoveDevice can + // find them when given the device instance. + private readonly Dictionary _openStreams = []; + private readonly Dictionary _devicePaths = []; + + // Tracks devices that Reconcile has already confirmed as physically + // disconnected. RemoveDevice consults this to decide whether the + // graceful "send a final all-black frame" attempt is worth making. + private readonly HashSet _confirmedDisconnected = []; + + // Snapshot of currently-alive Sony controller DevicePaths, refreshed + // synchronously by SuspendDeadDevices on every DeviceList.Changed + // and at the end of LoadDevices / Reconcile. UpdateQueues consult + // it via IsDevicePathAlive before each HidStream.Write — this + // closes the race between PnP unplug and the next 30Hz trigger + // tick. Without a pre-check, even when the PnP handler runs + // promptly, a tick already in flight can still call Write against + // a now-invalid handle and throw IOException. + private static volatile HashSet _alivePathsSnapshot = new(StringComparer.OrdinalIgnoreCase); + + // Set true inside Dispose so RemoveDevice can also skip the off-frame + // at app shutdown — the OS may already have invalidated the HID + // handle even though the controller is physically connected, and + // the firmware resets to its default indicator on process exit + // regardless of whether we send black first. + private volatile bool _disposing; + private readonly Lock _stateLock = new(); + + // Hot-plug bookkeeping: subscription flag (so re-init doesn't double-subscribe), + // and a serial counter so debounced reconciles on stale enqueues short-circuit. + private bool _hotplugSubscribed; + private int _hotplugScheduleSeq; + + #endregion + + #region Constructors + + /// Initializes a new instance of the class. + /// Thrown if a second instance is constructed. + public PlayStationDeviceProvider() + { + lock (_lock) + { + if (_instance != null) throw new InvalidOperationException($"There can be only one instance of type {nameof(PlayStationDeviceProvider)}"); + _instance = this; + } + } + + #endregion + + #region Methods + + /// + protected override void InitializeSDK() + { + // Subscribe once for the lifetime of this provider instance. The + // subscription is unhooked in Dispose. Guard against double-subscribe + // in case Initialize is invoked twice. + if (!_hotplugSubscribed) + { + DeviceList.Local.Changed += OnHidDeviceListChanged; + _hotplugSubscribed = true; + } + } + + /// + protected override IDeviceUpdateTrigger CreateUpdateTrigger(int id, double updateRateHardLimit) + => new DeviceUpdateTrigger(UPDATE_FREQUENCY_SECONDS); + + /// + protected override IEnumerable LoadDevices() + { + List devices = []; + + HidDevice[] all; + try + { + all = DeviceList.Local.GetHidDevices(vendorID: SONY_VENDOR_ID).ToArray(); + } + catch (Exception ex) + { + Throw(ex); + return devices; + } + + foreach (HidDevice hid in all) + { + int pid = hid.ProductID; + if (!IsSupportedPid(pid)) continue; + + if (TryOpenAndCreateDevice(hid, pid, out IRGBDevice? device) && device != null) + devices.Add(device); + } + + // Seed the alive-path snapshot so UpdateQueues' per-frame pre-check + // answers correctly from the very first trigger tick. + try { SuspendDeadDevices(); } catch { /* best effort */ } + + return devices; + } + + private static bool IsSupportedPid(int pid) + => pid is PID_DUALSHOCK4_V1 + or PID_DUALSHOCK4_V2 + or PID_DUALSHOCK4_WIRELESS + or PID_DUALSENSE + or PID_DUALSENSE_EDGE; + + // Centralised "open + construct + register" path used by both initial + // enumeration and hot-plug. Handles the predictable failure modes + // (TryOpen returns false, UnauthorizedAccessException, the broader + // DeviceIOException family that HidSharp throws when the kernel + // rejects the descriptor-query handle) and returns false silently — + // caller doesn't need to distinguish "not openable" from "openable + // but build failed". + // + // Only call HidDevice methods that are absolutely necessary, and only + // call them in this order: + // 1. DevicePath (cheap property, no descriptor query) + // 2. TryOpen (this also primes the ReportInfo cache as a side + // effect) + // 3. GetMaxOutputReportLength on the open stream (free — ReportInfo + // is now cached) + // + // We deliberately do NOT call GetSerialNumber. HidSharp's RequiresGetInfo + // opens a *separate* read-info handle to satisfy any flag not already + // cached — and on some hardware (DS4 v1 in particular, also any + // controller whose descriptor query can't get a handle because Steam / + // driver / power state is holding the device) this throws + // DeviceIOException("Failed to get info."). Identity always comes from + // a stable hash of DevicePath instead. + private bool TryOpenAndCreateDevice(HidDevice hid, int pid, out IRGBDevice? device) + { + device = null; + + string devicePath; + try { devicePath = hid.DevicePath ?? string.Empty; } + catch { devicePath = string.Empty; } + + string serial = string.IsNullOrEmpty(devicePath) ? string.Empty : ShortHashOf(devicePath); + + HidStream opened; + try + { + if (!hid.TryOpen(out opened!)) + { + Trace.WriteLine($"[RGB.NET.PlayStation] Could not open controller (VID 0x{hid.VendorID:X4} PID 0x{pid:X4}). Another application may have exclusive HID access (DS4Windows / reWASD with exclusive mode enabled)."); + return false; + } + } + catch (UnauthorizedAccessException) + { + Trace.WriteLine($"[RGB.NET.PlayStation] Access denied opening controller (VID 0x{hid.VendorID:X4} PID 0x{pid:X4}). Another application has exclusive HID access — close DS4Windows / reWASD or disable their exclusive mode."); + return false; + } + catch (Exception ex) + { + // HidSharp.Exceptions.DeviceIOException ("Failed to get info.") + // lands here when the kernel refuses the descriptor-query handle. + Trace.WriteLine($"[RGB.NET.PlayStation] Failed to open controller (VID 0x{hid.VendorID:X4} PID 0x{pid:X4}): {ex.Message}"); + return false; + } + + try + { + // Transport detection: DS4 USB max output report is 32 bytes (incl. + // report ID), DS4 BT is 78. DS5 USB is 64, DS5 BT is 78. Any + // controller that reports an output buffer of 78+ is on Bluetooth. + // ReportInfo was cached by TryOpen above, so this call is free. + int maxOut; + try { maxOut = opened.Device.GetMaxOutputReportLength(); } + catch { maxOut = 0; /* default to USB byte count */ } + PlayStationTransport transport = maxOut >= 78 ? PlayStationTransport.Bluetooth : PlayStationTransport.Usb; + + PlayStationControllerType controllerType = pid switch + { + PID_DUALSENSE => PlayStationControllerType.DualSense, + PID_DUALSENSE_EDGE => PlayStationControllerType.DualSenseEdge, + _ => PlayStationControllerType.DualShock4, + }; + + PlayStationDeviceInfo info = new(controllerType, transport, serial); + + IRGBDevice newDevice; + if (controllerType == PlayStationControllerType.DualShock4) + { + DualShock4UpdateQueue queue = new(GetUpdateTrigger(), opened, transport, devicePath); + newDevice = new DualShock4RGBDevice(info, queue); + } + else + { + DualSenseUpdateQueue queue = new(GetUpdateTrigger(), opened, transport, devicePath); + newDevice = new DualSenseRGBDevice(info, queue); + } + + lock (_stateLock) + { + _openStreams[newDevice] = opened; + _devicePaths[newDevice] = devicePath; + } + + device = newDevice; + return true; + } + catch (Exception ex) + { + try { opened.Dispose(); } catch { /* best effort */ } + Trace.WriteLine($"[RGB.NET.PlayStation] Failed to construct device for VID 0x{hid.VendorID:X4} PID 0x{pid:X4}: {ex.Message}"); + return false; + } + } + + #endregion + + #region Hot-plug + + private void OnHidDeviceListChanged(object? sender, DeviceListChangedEventArgs e) + { + // Two-pass design. + // + // Pass 1 (immediate, no debounce): walk the open set against the + // current HID enumeration and call SuspendWrites() on any device + // that has disappeared. This sets the queue's _disposed flag + // BEFORE the next 30Hz trigger tick fires, so the trigger never + // reaches HidStream.Write — no IOException thrown at all. + // + // Pass 2 (debounced 1500ms): full Reconcile that handles + // - the slow-side cleanup (RemoveDevice + stream dispose + + // surface.Detach via the bookkeeping handler) + // - new-device opens (which need the debounce to let the OS + // finish enumerating — TryOpen on a partially-enumerated + // device succeeds but the first Write fails) + // The seq counter cancels stale debounces so only the latest + // PnP burst's Reconcile actually runs. + try { SuspendDeadDevices(); } + catch (Exception ex) + { + Trace.WriteLine($"[RGB.NET.PlayStation] Suspend-dead-devices pass threw: {ex.Message}"); + } + + int mySeq = System.Threading.Interlocked.Increment(ref _hotplugScheduleSeq); + Task.Run(async () => + { + await Task.Delay(HOTPLUG_DEBOUNCE_MS).ConfigureAwait(false); + if (System.Threading.Volatile.Read(ref _hotplugScheduleSeq) != mySeq) return; + try { Reconcile(); } + catch (Exception ex) + { + Trace.WriteLine($"[RGB.NET.PlayStation] Hot-plug reconcile threw: {ex.Message}"); + } + }); + } + + /// + /// Per-frame pre-check used by the update queues. Queries HidSharp's device + /// list LIVE — HidSharp invalidates its internal device-keys cache + /// synchronously on WM_DEVICECHANGE inside DeviceMonitorWindowProc on the + /// message-pump thread, BEFORE pulsing the notify thread that eventually + /// fires the Changed event. So a live + /// GetHidDevices call sees the unplug ahead of any subscriber, which is + /// the race that would otherwise leave the snapshot stale through the + /// first post-unplug 30Hz tick. + /// + public static bool IsDevicePathAlive(string devicePath) + { + if (string.IsNullOrEmpty(devicePath)) return false; + try + { + foreach (HidDevice hid in DeviceList.Local.GetHidDevices(vendorID: SONY_VENDOR_ID)) + { + if (string.Equals(hid.DevicePath, devicePath, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + catch + { + // Fail closed — if enumeration itself throws, skip the write + // rather than fall through to HidStream.Write where the failure + // mode is exactly the IOException we're trying to avoid. + return false; + } + } + + // Immediate-pass companion to Reconcile. Compares currently-tracked device + // paths to the live HID enumeration; for anything still held open that no + // longer enumerates, suspend writes on its queue AND refresh the alive- + // path snapshot UpdateQueues consult per frame. + private void SuspendDeadDevices() + { + HashSet currentPaths; + try + { + currentPaths = DeviceList.Local.GetHidDevices(vendorID: SONY_VENDOR_ID) + .Where(h => IsSupportedPid(h.ProductID)) + .Select(h => h.DevicePath ?? string.Empty) + .Where(p => p.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + catch + { + return; + } + + // Publish the new snapshot atomically. UpdateQueues see the change + // on the next trigger tick (volatile reference write). + _alivePathsSnapshot = currentPaths; + + List> snapshot; + lock (_stateLock) + { + snapshot = [.. _devicePaths]; + } + + foreach (KeyValuePair kvp in snapshot) + { + if (string.IsNullOrEmpty(kvp.Value)) continue; + if (currentPaths.Contains(kvp.Value)) continue; + + // Mark as confirmed gone so when the debounced Reconcile gets here + // it skips the off-frame write in RemoveDevice. + lock (_stateLock) { _confirmedDisconnected.Add(kvp.Key); } + + switch (kvp.Key) + { + case DualShock4RGBDevice ds4: ds4.SuspendWrites(); break; + case DualSenseRGBDevice ds: ds.SuspendWrites(); break; + } + } + } + + // Compare current HID enumeration to the open set; add new ones, remove + // gone ones. Called from the debounced PnP callback. Holds _stateLock for + // the snapshot read so we don't race with a concurrent Dispose; opens and + // AddDevice/RemoveDevice are done outside the lock so we don't deadlock + // against any handler that might call back into the provider. + private void Reconcile() + { + HashSet currentPaths; + try + { + currentPaths = DeviceList.Local.GetHidDevices(vendorID: SONY_VENDOR_ID) + .Where(h => IsSupportedPid(h.ProductID)) + .Select(h => h.DevicePath ?? string.Empty) + .Where(p => p.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + catch (Exception ex) + { + Trace.WriteLine($"[RGB.NET.PlayStation] Reconcile enumeration failed: {ex.Message}"); + return; + } + + List> snapshot; + lock (_stateLock) + { + snapshot = [.. _devicePaths]; + } + + // Removals first (devices held but no longer enumerated) — done before + // adds so a controller that quickly reconnects on a different path can + // be re-added cleanly. + foreach (KeyValuePair kvp in snapshot) + { + if (string.IsNullOrEmpty(kvp.Value)) continue; + if (!currentPaths.Contains(kvp.Value)) + { + lock (_stateLock) { _confirmedDisconnected.Add(kvp.Key); } + RemoveDevice(kvp.Key); + } + } + + // Additions: any enumerated path not currently open. + HashSet openedPaths; + lock (_stateLock) + { + openedPaths = _devicePaths.Values + .Where(p => !string.IsNullOrEmpty(p)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + foreach (HidDevice hid in DeviceList.Local.GetHidDevices(vendorID: SONY_VENDOR_ID)) + { + if (!IsSupportedPid(hid.ProductID)) continue; + string path; + try { path = hid.DevicePath ?? string.Empty; } catch { continue; } + if (string.IsNullOrEmpty(path)) continue; + if (openedPaths.Contains(path)) continue; + + if (TryOpenAndCreateDevice(hid, hid.ProductID, out IRGBDevice? newDevice) && newDevice != null) + { + // Refresh the alive-path snapshot BEFORE AddDevice so the first + // trigger tick after AddDevice already sees the new device's + // path. + try { SuspendDeadDevices(); } catch { /* best effort */ } + + AddDevice(newDevice); + + // Make sure the DeviceUpdateTrigger is actually running. + // AbstractRGBDeviceProvider.Initialize() calls Start() on every + // trigger in UpdateTriggerMapping at the end of initial load — + // but if no controllers were connected at launch, the trigger + // wasn't created until just now. Start() is idempotent. + try { GetUpdateTrigger().Start(); } catch { /* best effort */ } + } + } + } + + #endregion + + #region Lifecycle + + /// + protected override bool RemoveDevice(IRGBDevice device) + { + HidStream? stream = null; + bool wasConfirmedGone; + lock (_stateLock) + { + if (_openStreams.TryGetValue(device, out stream)) + _openStreams.Remove(device); + _devicePaths.Remove(device); + wasConfirmedGone = _confirmedDisconnected.Remove(device); + } + + // Send a final off-frame ONLY when removal is voluntary (provider + // unloaded by the host app). Skip it when the device was confirmed + // physically gone, or we're inside Dispose. In both skip cases the + // write would throw IOException. + bool sendOffFrame = !wasConfirmedGone && !_disposing; + try { (device as DualShock4RGBDevice)?.Shutdown(sendOffFrame); } catch { /* best effort */ } + try { (device as DualSenseRGBDevice)?.Shutdown(sendOffFrame); } catch { /* best effort */ } + + if (stream != null) + { + try { stream.Dispose(); } catch { /* best effort */ } + } + + return base.RemoveDevice(device); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _disposing = true; + + if (_hotplugSubscribed) + { + try { DeviceList.Local.Changed -= OnHidDeviceListChanged; } catch { /* best effort */ } + _hotplugSubscribed = false; + } + + // Snapshot devices to remove. RemoveDevice mutates the dictionaries, + // so iterate a copy. + List snapshot; + lock (_stateLock) + { + snapshot = [.. _openStreams.Keys]; + } + foreach (IRGBDevice d in snapshot) + { + try { RemoveDevice(d); } catch { /* best effort */ } + } + } + + base.Dispose(disposing); + + lock (_lock) + { + if (ReferenceEquals(_instance, this)) + _instance = null; + } + } + + #endregion + + #region Helpers + + // 12-char hex hash of an arbitrary string. Used to derive a stable + // pseudo-serial from DevicePath when the controller's HID descriptor + // doesn't expose a real serial — short enough to look reasonable in + // the device name, long enough that two distinct USB instances of the + // same product won't collide. Identity is the only requirement; cryptographic + // strength is not. + private static string ShortHashOf(string input) + { + byte[] hash = SHA1.HashData(Encoding.UTF8.GetBytes(input)); + StringBuilder sb = new(12); + for (int i = 0; i < 6; i++) sb.Append(hash[i].ToString("X2")); + return sb.ToString(); + } + + #endregion +} diff --git a/RGB.NET.Devices.PlayStation/README.md b/RGB.NET.Devices.PlayStation/README.md new file mode 100644 index 00000000..a58e48e7 --- /dev/null +++ b/RGB.NET.Devices.PlayStation/README.md @@ -0,0 +1,63 @@ +[RGB.NET](https://github.com/DarthAffe/RGB.NET) Device-Provider-Package for Sony PlayStation controllers (DualShock 4, DualSense, DualSense Edge). + +## Usage +This provider follows the default pattern and does not require additional setup. + +```csharp +surface.Load(PlayStationDeviceProvider.Instance); +``` + +The provider auto-detects connected controllers at load time and tracks hot-plug events for the lifetime of the provider — no rescan call is required when controllers are connected, disconnected, or paired/unpaired over Bluetooth. + +# Required SDK +This provider does not require an additional SDK. It speaks raw HID via the bundled `HidSharp` dependency (transitive through `RGB.NET.HID`); no Sony driver, no DS4Windows, no SignalRGB, no HidHide, no DualSenseX. + +## Supported devices + +| Controller | USB | Bluetooth | Notes | +|---|---|---|---| +| DualShock 4 v1 (PID `0x05C4`) | yes | yes | Original "JDM-001/011" | +| DualShock 4 v2 (PID `0x09CC`) | yes | yes | Revised "JDM-040/050/055"; lightbar visible through touchpad | +| DualShock 4 Wireless Adapter (PID `0x0BA0`) | n/a | yes | Sony's official BT bridge — treated like DS4 v2 | +| DualSense (PID `0x0CE6`) | yes | yes | PS5 launch controller "CFI-ZCT1" | +| DualSense Edge (PID `0x0DF2`) | yes | yes | Pro variant "CFI-ZCP1" | + +## LED layout + +| `LedId` | DualShock 4 | DualSense / DualSense Edge | +|---|---|---| +| `Custom1` | Lightbar (RGB) | Lightbar (RGB) | +| `Custom2` | — | Player indicator 1 (leftmost, monochrome) | +| `Custom3` | — | Player indicator 2 (monochrome) | +| `Custom4` | — | Player indicator 3 (monochrome) | +| `Custom5` | — | Player indicator 4 (monochrome) | +| `Custom6` | — | Player indicator 5 (rightmost, monochrome) | + +`Custom1` is the lightbar on both controller families, so a host-side mapping for "Custom 1" carries sensible meaning across DS4 and DS5. + +The DualSense player indicator LEDs are monochrome — they don't accept colour, only on/off. Any non-black colour written to `Custom2`..`Custom6` lights the corresponding indicator at full brightness; pure black turns it off. + +## Limitations + +### Not exposed +- **DualSense mic-mute LED.** Deliberately left under firmware control. The mic-mute *button* mutes the microphone in hardware regardless of host activity — taking control of the LED would suppress visual feedback for an action that still happens. The provider does not set the `MIC_MUTE_LED_CONTROL_ENABLE` bit in the output report, so the firmware retains its default LED-tracks-mute-state behaviour. +- **Rumble, adaptive triggers, haptics.** This provider is lighting only. Output reports are written with rumble/audio/haptic fields zeroed and their corresponding `valid_flag` bits clear, so the firmware ignores those fields and games / Steam Input continue to drive them normally. +- **Audio routing, headset volume, speaker volume, microphone gain.** Same as above — fields are zeroed, flags are clear. + +### Coexistence +- **Shared HID writes.** Sony's HID gamepads accept shared output writes by default on Windows. Steam Input, a game's native lighting integration, and this provider can all write to the same controller — last writer wins per output report period. At the provider's 30Hz cadence intermittent setters (Steam profile changes, in-game state events) get overridden quickly enough that the net visual is the host app's colour. +- **DS4Windows / reWASD with "Exclusive Mode" enabled** hold the HID handle exclusive. The provider's `TryOpen` call fails (`UnauthorizedAccessException` / `IOException`) and the controller is skipped. Diagnostic message logged via `Trace.WriteLine`. Disable exclusive mode in those tools or close them to restore lighting. +- **HidHide** hiding the controller from non-allow-listed apps means the device never appears in the HidSharp enumeration — indistinguishable from "controller not connected". Add the host application to HidHide's allow-list. +- **The DualSense lightbar boot animation.** A fresh DualSense connection plays a fade-in animation that overrides host-driven colours until released. The provider sends the `LIGHTBAR_SETUP_RELEASE_LEDS` setup-control bit on the first output report after open, so host control takes effect immediately on connect. + +### Identity +- **Device identity is derived from the OS device path**, hashed to a 12-character hex tag and embedded in `DeviceName`. Stable across application restarts for the same physical controller in the same USB port / BT pairing. Switching the same controller between USB and Bluetooth produces a different `DeviceName`, so any host-side state keyed by device name won't auto-apply across transports. +- **`PlayStationDeviceInfo.SerialNumber` is the path-derived hash, not the controller's HID serial descriptor.** The descriptor query (`HidDevice.GetSerialNumber`) opens a separate read-info handle that throws `DeviceIOException("Failed to get info.")` on some hardware (DS4 v1 in particular, and any controller whose descriptor query is blocked by Steam / driver / power state). The path-derived hash is sufficient for identity and avoids the throw. + +### Platform +- HidSharp targets Windows, macOS, and Linux, but the controllers' BT output report formats and PnP semantics have only been verified on Windows. The provider is expected to work on macOS and Linux for USB-connected controllers; Bluetooth has not been tested on those platforms. Reports welcome. +- On Linux, `hid-playstation` (kernel 5.12+) drives most lighting itself and may compete with this provider for output reports — last writer wins, but the kernel's player-LED logic may overwrite host-driven indicators. + +## Protocol references + +The output report layouts are the same ones the Linux kernel `hid-playstation` driver uses. See [`drivers/hid/hid-playstation.c`](https://github.com/torvalds/linux/blob/master/drivers/hid/hid-playstation.c) — specifically `struct dualshock4_output_report_{usb,bt}` and `struct dualsense_output_report_common`. The `PlayStationCrc32` helper mirrors the same `crc32_le` seed-byte convention the kernel uses for BT reports. diff --git a/RGB.NET.Devices.PlayStation/RGB.NET.Devices.PlayStation.csproj b/RGB.NET.Devices.PlayStation/RGB.NET.Devices.PlayStation.csproj new file mode 100644 index 00000000..c79057b0 --- /dev/null +++ b/RGB.NET.Devices.PlayStation/RGB.NET.Devices.PlayStation.csproj @@ -0,0 +1,67 @@ + + + net10.0;net9.0;net8.0 + latest + enable + + Darth Affe + Wyrez + en-US + en-US + RGB.NET.Devices.PlayStation + RGB.NET.Devices.PlayStation + RGB.NET.Devices.PlayStation + RGB.NET.Devices.PlayStation + RGB.NET.Devices.PlayStation + PlayStation-Device-Implementations of RGB.NET + PlayStation-Device-Implementations of RGB.NET, a C# (.NET) library for accessing various RGB-peripherals + Copyright © Darth Affe 2026 + Copyright © Darth Affe 2026 + icon.png + README.md + https://github.com/DarthAffe/RGB.NET + LGPL-2.1-only + Github + https://github.com/DarthAffe/RGB.NET + True + + + + 0.0.1 + 0.0.1 + 0.0.1 + + ..\bin\ + true + True + True + portable + snupkg + + + + TRACE;DEBUG + true + false + + + + true + $(NoWarn);CS1591;CS1572;CS1573 + RELEASE + + + + + + + + + + + + + + + + diff --git a/RGB.NET.sln b/RGB.NET.sln index 44b786e5..1b164676 100644 --- a/RGB.NET.sln +++ b/RGB.NET.sln @@ -51,6 +51,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RGB.NET.Devices.Corsair_Leg EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RGB.NET.Devices.WLED", "RGB.NET.Devices.WLED\RGB.NET.Devices.WLED.csproj", "{C533C5EA-66A8-4826-A814-80791E7593ED}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RGB.NET.Devices.PlayStation", "RGB.NET.Devices.PlayStation\RGB.NET.Devices.PlayStation.csproj", "{8A21B62B-9B4E-4D7E-8F8E-6E6E6F2C8D1A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -145,6 +147,10 @@ Global {C533C5EA-66A8-4826-A814-80791E7593ED}.Debug|Any CPU.Build.0 = Debug|Any CPU {C533C5EA-66A8-4826-A814-80791E7593ED}.Release|Any CPU.ActiveCfg = Release|Any CPU {C533C5EA-66A8-4826-A814-80791E7593ED}.Release|Any CPU.Build.0 = Release|Any CPU + {8A21B62B-9B4E-4D7E-8F8E-6E6E6F2C8D1A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8A21B62B-9B4E-4D7E-8F8E-6E6E6F2C8D1A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8A21B62B-9B4E-4D7E-8F8E-6E6E6F2C8D1A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8A21B62B-9B4E-4D7E-8F8E-6E6E6F2C8D1A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -168,6 +174,7 @@ Global {F29A96E5-CDD0-469F-A871-A35A7519BC49} = {D13032C6-432E-4F43-8A32-071133C22B16} {66AF690C-27A1-4097-AC53-57C0ED89E286} = {D13032C6-432E-4F43-8A32-071133C22B16} {C533C5EA-66A8-4826-A814-80791E7593ED} = {D13032C6-432E-4F43-8A32-071133C22B16} + {8A21B62B-9B4E-4D7E-8F8E-6E6E6F2C8D1A} = {D13032C6-432E-4F43-8A32-071133C22B16} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7F222AD4-1F9E-4AAB-9D69-D62372D4C1BA} From 6b81f4c2d7613ee90ba98b4801935f74d8ed9a53 Mon Sep 17 00:00:00 2001 From: Danielle Date: Sat, 9 May 2026 11:31:44 +1000 Subject: [PATCH 2/5] PlayStation: bypass HidSharp overlapped I/O on Windows via direct WriteFile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field reports against the USB transport showed lightbar updating once and then freezing. Root cause: HidSharp's HidStream opens its handle with FILE_FLAG_OVERLAPPED and runs an asynchronous WriteFile + GetOverlappedResult dance. The PlayStation HID minidriver returns failure on the second and subsequent overlapped writes, which HidSharp surfaces as IOException — the queue's catch handler then suspends the queue. Reintroduced HidRawWriter (synchronous Win32 WriteFile on a separate kernel handle, BOOL return — never throws). On Windows, both DS4 and DS5 queues prefer the raw writer; on non-Windows the queues fall back to HidStream.Write since HidSharp's macOS/Linux paths don't share the overlapped Windows code. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../DualSense/DualSenseUpdateQueue.cs | 57 ++++--- .../DualShock4/DualShock4UpdateQueue.cs | 63 +++++--- .../Generic/HidRawWriter.cs | 141 ++++++++++++++++++ .../PlayStationDeviceProvider.cs | 36 ++++- RGB.NET.Devices.PlayStation/README.md | 3 +- 5 files changed, 252 insertions(+), 48 deletions(-) create mode 100644 RGB.NET.Devices.PlayStation/Generic/HidRawWriter.cs diff --git a/RGB.NET.Devices.PlayStation/DualSense/DualSenseUpdateQueue.cs b/RGB.NET.Devices.PlayStation/DualSense/DualSenseUpdateQueue.cs index b62a4f49..70238295 100644 --- a/RGB.NET.Devices.PlayStation/DualSense/DualSenseUpdateQueue.cs +++ b/RGB.NET.Devices.PlayStation/DualSense/DualSenseUpdateQueue.cs @@ -65,6 +65,7 @@ internal sealed class DualSenseUpdateQueue : UpdateQueue #region Properties & Fields private readonly HidStream _stream; + private readonly HidRawWriter? _rawWriter; private readonly PlayStationTransport _transport; private readonly byte[] _buffer; private readonly string _devicePath; @@ -77,10 +78,11 @@ internal sealed class DualSenseUpdateQueue : UpdateQueue #region Constructors - public DualSenseUpdateQueue(IDeviceUpdateTrigger trigger, HidStream stream, PlayStationTransport transport, string devicePath) + public DualSenseUpdateQueue(IDeviceUpdateTrigger trigger, HidStream stream, HidRawWriter? rawWriter, PlayStationTransport transport, string devicePath) : base(trigger) { _stream = stream; + _rawWriter = rawWriter; _transport = transport; _devicePath = devicePath ?? string.Empty; _buffer = new byte[transport == PlayStationTransport.Bluetooth ? 78 : 63]; @@ -132,21 +134,39 @@ protected override bool Update(ReadOnlySpan<(object key, Color color)> dataSet) // the lightbar at black instead of leaving uninitialised state. if (!gotLightbar) lightbar = new Color(0, 0, 0); + bool ok; + lock (_writeLock) + { + Array.Clear(_buffer, 0, _buffer.Length); + BuildReport(lightbar, playerLedBits); + ok = WriteBuffer(); + } + + if (!ok) + { + Trace.WriteLine("[RGB.NET.PlayStation] DualSense write failed, suspending queue."); + _disposed = true; + return false; + } + _firstReport = false; + return true; + } + + // See DualShock4UpdateQueue.WriteBuffer for the rationale behind preferring + // HidRawWriter over HidStream.Write on Windows. + private bool WriteBuffer() + { + if (_rawWriter != null) + return _rawWriter.TryWrite(_buffer); + try { - lock (_writeLock) - { - Array.Clear(_buffer, 0, _buffer.Length); - BuildReport(lightbar, playerLedBits); - _stream.Write(_buffer); - } - _firstReport = false; + _stream.Write(_buffer); return true; } catch (Exception ex) { - Trace.WriteLine($"[RGB.NET.PlayStation] DualSense write failed, suspending queue: {ex.Message}"); - _disposed = true; + Trace.WriteLine($"[RGB.NET.PlayStation] DualSense stream write threw: {ex.Message}"); return false; } } @@ -246,18 +266,13 @@ public void Shutdown(bool sendOffFrame = true) if (_disposed) return; _disposed = true; if (!sendOffFrame) return; - try - { - lock (_writeLock) - { - Array.Clear(_buffer, 0, _buffer.Length); - BuildReport(new Color(0, 0, 0), 0); - _stream.Write(_buffer); - } - } - catch + // Best-effort — WriteBuffer returns false silently if the handle has + // already been invalidated. + lock (_writeLock) { - // Best-effort. + Array.Clear(_buffer, 0, _buffer.Length); + BuildReport(new Color(0, 0, 0), 0); + WriteBuffer(); } } diff --git a/RGB.NET.Devices.PlayStation/DualShock4/DualShock4UpdateQueue.cs b/RGB.NET.Devices.PlayStation/DualShock4/DualShock4UpdateQueue.cs index a6010c75..dc01e589 100644 --- a/RGB.NET.Devices.PlayStation/DualShock4/DualShock4UpdateQueue.cs +++ b/RGB.NET.Devices.PlayStation/DualShock4/DualShock4UpdateQueue.cs @@ -34,6 +34,7 @@ internal sealed class DualShock4UpdateQueue : UpdateQueue #region Properties & Fields private readonly HidStream _stream; + private readonly HidRawWriter? _rawWriter; private readonly PlayStationTransport _transport; private readonly byte[] _buffer; private readonly string _devicePath; @@ -44,10 +45,11 @@ internal sealed class DualShock4UpdateQueue : UpdateQueue #region Constructors - public DualShock4UpdateQueue(IDeviceUpdateTrigger trigger, HidStream stream, PlayStationTransport transport, string devicePath) + public DualShock4UpdateQueue(IDeviceUpdateTrigger trigger, HidStream stream, HidRawWriter? rawWriter, PlayStationTransport transport, string devicePath) : base(trigger) { _stream = stream; + _rawWriter = rawWriter; _transport = transport; _devicePath = devicePath ?? string.Empty; _buffer = new byte[transport == PlayStationTransport.Bluetooth ? 78 : 32]; @@ -76,26 +78,46 @@ protected override bool Update(ReadOnlySpan<(object key, Color color)> dataSet) // colour we see — RGB.NET commits the painted colour for that LED each tick. Color color = dataSet[0].color; - try + bool ok; + lock (_writeLock) { - lock (_writeLock) - { - Array.Clear(_buffer, 0, _buffer.Length); - BuildReport(color); - _stream.Write(_buffer); - } - return true; + Array.Clear(_buffer, 0, _buffer.Length); + BuildReport(color); + ok = WriteBuffer(); } - catch (Exception ex) + + if (!ok) { // Device went away mid-write or another tool grabbed exclusive access. // Suspend the queue so the next 30Hz tick short-circuits at the // `if (_disposed)` gate. The provider's hot-plug Reconcile will // RemoveDevice us shortly. - Trace.WriteLine($"[RGB.NET.PlayStation] DualShock4 write failed, suspending queue: {ex.Message}"); + Trace.WriteLine("[RGB.NET.PlayStation] DualShock4 write failed, suspending queue."); _disposed = true; return false; } + return true; + } + + // On Windows, prefer HidRawWriter (synchronous Win32 WriteFile) — HidSharp's + // overlapped HidStream.Write fails on the second and subsequent USB writes + // against the PlayStation HID minidriver. On non-Windows or if HidRawWriter + // failed to open, fall back to HidStream.Write. + private bool WriteBuffer() + { + if (_rawWriter != null) + return _rawWriter.TryWrite(_buffer); + + try + { + _stream.Write(_buffer); + return true; + } + catch (Exception ex) + { + Trace.WriteLine($"[RGB.NET.PlayStation] DualShock4 stream write threw: {ex.Message}"); + return false; + } } private void BuildReport(Color color) @@ -169,19 +191,14 @@ public void Shutdown(bool sendOffFrame = true) // last colour after we tear down. The controller's firmware restores // the OS-driven indicator (e.g. player number) shortly after we stop // sending reports anyway, but explicit black avoids the visible "stuck - // on last colour" beat between shutdown and firmware reset. - try - { - lock (_writeLock) - { - Array.Clear(_buffer, 0, _buffer.Length); - BuildReport(new Color(0, 0, 0)); - _stream.Write(_buffer); - } - } - catch + // on last colour" beat between shutdown and firmware reset. Best-effort — + // WriteBuffer returns false silently if the handle has already been + // invalidated. + lock (_writeLock) { - // Best-effort — handle may already have been invalidated. + Array.Clear(_buffer, 0, _buffer.Length); + BuildReport(new Color(0, 0, 0)); + WriteBuffer(); } } diff --git a/RGB.NET.Devices.PlayStation/Generic/HidRawWriter.cs b/RGB.NET.Devices.PlayStation/Generic/HidRawWriter.cs new file mode 100644 index 00000000..fb460b5d --- /dev/null +++ b/RGB.NET.Devices.PlayStation/Generic/HidRawWriter.cs @@ -0,0 +1,141 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace RGB.NET.Devices.PlayStation; + +// Direct Win32 WriteFile wrapper for HID output reports — Windows only. +// +// On Windows, HidSharp's HidStream opens its underlying handle with +// FILE_FLAG_OVERLAPPED and runs an asynchronous WriteFile + GetOverlappedResult +// dance internally. Field reports against the PlayStation USB minidriver +// indicate that overlapped path can return non-ERROR_IO_PENDING failure on the +// second and subsequent writes, which HidSharp surfaces as IOException — and +// our queue's catch handler turns the exception into a queue suspension. End +// result: lightbar updates once, then freezes. +// +// To sidestep the overlapped I/O path entirely we open OUR OWN kernel handle +// alongside HidSharp's, with shared read/write access and dwFlagsAndAttributes +// = 0 (synchronous I/O, no overlapped). WriteFile then returns BOOL — false on +// failure surfaces as a return value, no exception. HidSharp keeps the handle +// it opens during TryOpen (still useful for detecting exclusive-access +// conflicts via DS4Windows / reWASD at open time). Two handles per controller +// is fine — Sony HID gamepads accept shared writes by default on Windows. +// +// The class is intentionally minimal: open with shared read/write access, +// write a buffer, dispose. No reads, no overlapped I/O, no internal locking +// (the caller's UpdateQueue already serialises via its own write lock). +// +// Windows-only at runtime — the kernel32 P/Invokes will throw +// DllNotFoundException on Linux/macOS. Construction is gated on +// OperatingSystem.IsWindows() inside the provider; the class itself is not +// platform-attributed to keep call sites readable across the assembly. +internal sealed class HidRawWriter : IDisposable +{ + #region Win32 + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFileW( + string lpFileName, + uint dwDesiredAccess, + uint dwShareMode, + IntPtr lpSecurityAttributes, + uint dwCreationDisposition, + uint dwFlagsAndAttributes, + IntPtr hTemplateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool WriteFile( + SafeFileHandle hFile, + byte[] lpBuffer, + uint nNumberOfBytesToWrite, + out uint lpNumberOfBytesWritten, + IntPtr lpOverlapped); + + private const uint GENERIC_WRITE = 0x40000000; + private const uint FILE_SHARE_READ = 0x00000001; + private const uint FILE_SHARE_WRITE = 0x00000002; + private const uint OPEN_EXISTING = 3; + + #endregion + + #region Properties & Fields + + private readonly SafeFileHandle _handle; + private volatile bool _closed; + + #endregion + + #region Constructors + + public HidRawWriter(string devicePath) + { + if (string.IsNullOrEmpty(devicePath)) + throw new ArgumentException("Device path is required.", nameof(devicePath)); + + // Shared read/write so we coexist with HidSharp's handle and any other + // app (Steam Input, game native lighting, etc.) that may also be + // opening the device. dwFlagsAndAttributes = 0 → synchronous I/O. We + // don't need overlapped — writes are small (78 bytes max) and our + // caller is already on a dedicated trigger thread. + _handle = CreateFileW( + devicePath, + GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + IntPtr.Zero, + OPEN_EXISTING, + 0, + IntPtr.Zero); + + if (_handle == null || _handle.IsInvalid) + { + int err = Marshal.GetLastWin32Error(); + throw new IOException($"CreateFile failed for HID device ({devicePath}). Win32 error: {err}"); + } + } + + #endregion + + #region Methods + + /// + /// True on successful write, false on any failure (handle invalid, device + /// gone, partial write, etc.). Never throws — that's the whole point. + /// Caller checks the return value and decides whether to log, retry, or + /// self-suspend the queue. + /// + /// The first byte of must be the HID report ID, + /// matching the convention HidStream.Write uses. + /// + public bool TryWrite(byte[] buffer) + { + if (_closed) return false; + if (buffer == null || buffer.Length == 0) return false; + + try + { + if (_handle == null || _handle.IsClosed || _handle.IsInvalid) + return false; + + return WriteFile(_handle, buffer, (uint)buffer.Length, out _, IntPtr.Zero); + } + catch + { + // P/Invoke marshalling could conceivably fault on a pathological + // handle state; swallow and report failure rather than escape the + // contract. + return false; + } + } + + public void Dispose() + { + if (_closed) return; + _closed = true; + try { _handle?.Dispose(); } catch { /* best effort */ } + } + + #endregion +} diff --git a/RGB.NET.Devices.PlayStation/PlayStationDeviceProvider.cs b/RGB.NET.Devices.PlayStation/PlayStationDeviceProvider.cs index 7d93e841..529ff49a 100644 --- a/RGB.NET.Devices.PlayStation/PlayStationDeviceProvider.cs +++ b/RGB.NET.Devices.PlayStation/PlayStationDeviceProvider.cs @@ -104,6 +104,13 @@ public static PlayStationDeviceProvider Instance // find them when given the device instance. private readonly Dictionary _openStreams = []; private readonly Dictionary _devicePaths = []; + // Win32-direct WriteFile wrappers used for the actual lighting writes on + // Windows. Kept separate from HidStream because HidSharp's overlapped I/O + // path fails on the second and subsequent USB writes against the + // PlayStation HID minidriver — see HidRawWriter for the full rationale. + // Null entries indicate non-Windows or a failed open; queues fall back to + // HidStream.Write in those cases. + private readonly Dictionary _rawWriters = []; // Tracks devices that Reconcile has already confirmed as physically // disconnected. RemoveDevice consults this to decide whether the @@ -283,21 +290,36 @@ private bool TryOpenAndCreateDevice(HidDevice hid, int pid, out IRGBDevice? devi PlayStationDeviceInfo info = new(controllerType, transport, serial); + // On Windows, open a second handle for synchronous WriteFile use. + // If this fails (rare — same flags as HidSharp's open which already + // succeeded), log and continue with HidStream.Write fallback. On + // non-Windows, rawWriter stays null and the queues use HidStream. + HidRawWriter? rawWriter = null; + if (OperatingSystem.IsWindows() && !string.IsNullOrEmpty(devicePath)) + { + try { rawWriter = new HidRawWriter(devicePath); } + catch (Exception writerEx) + { + Trace.WriteLine($"[RGB.NET.PlayStation] Could not open raw write handle for {info.DeviceName}: {writerEx.Message} — falling back to HidStream.Write."); + } + } + IRGBDevice newDevice; if (controllerType == PlayStationControllerType.DualShock4) { - DualShock4UpdateQueue queue = new(GetUpdateTrigger(), opened, transport, devicePath); + DualShock4UpdateQueue queue = new(GetUpdateTrigger(), opened, rawWriter, transport, devicePath); newDevice = new DualShock4RGBDevice(info, queue); } else { - DualSenseUpdateQueue queue = new(GetUpdateTrigger(), opened, transport, devicePath); + DualSenseUpdateQueue queue = new(GetUpdateTrigger(), opened, rawWriter, transport, devicePath); newDevice = new DualSenseRGBDevice(info, queue); } lock (_stateLock) { _openStreams[newDevice] = opened; + _rawWriters[newDevice] = rawWriter; _devicePaths[newDevice] = devicePath; } @@ -516,11 +538,14 @@ private void Reconcile() protected override bool RemoveDevice(IRGBDevice device) { HidStream? stream = null; + HidRawWriter? rawWriter = null; bool wasConfirmedGone; lock (_stateLock) { if (_openStreams.TryGetValue(device, out stream)) _openStreams.Remove(device); + if (_rawWriters.TryGetValue(device, out rawWriter)) + _rawWriters.Remove(device); _devicePaths.Remove(device); wasConfirmedGone = _confirmedDisconnected.Remove(device); } @@ -528,11 +553,16 @@ protected override bool RemoveDevice(IRGBDevice device) // Send a final off-frame ONLY when removal is voluntary (provider // unloaded by the host app). Skip it when the device was confirmed // physically gone, or we're inside Dispose. In both skip cases the - // write would throw IOException. + // write would fail silently anyway. bool sendOffFrame = !wasConfirmedGone && !_disposing; try { (device as DualShock4RGBDevice)?.Shutdown(sendOffFrame); } catch { /* best effort */ } try { (device as DualSenseRGBDevice)?.Shutdown(sendOffFrame); } catch { /* best effort */ } + if (rawWriter != null) + { + try { rawWriter.Dispose(); } catch { /* best effort */ } + } + if (stream != null) { try { stream.Dispose(); } catch { /* best effort */ } diff --git a/RGB.NET.Devices.PlayStation/README.md b/RGB.NET.Devices.PlayStation/README.md index a58e48e7..150c60ba 100644 --- a/RGB.NET.Devices.PlayStation/README.md +++ b/RGB.NET.Devices.PlayStation/README.md @@ -55,7 +55,8 @@ The DualSense player indicator LEDs are monochrome — they don't accept colour, - **`PlayStationDeviceInfo.SerialNumber` is the path-derived hash, not the controller's HID serial descriptor.** The descriptor query (`HidDevice.GetSerialNumber`) opens a separate read-info handle that throws `DeviceIOException("Failed to get info.")` on some hardware (DS4 v1 in particular, and any controller whose descriptor query is blocked by Steam / driver / power state). The path-derived hash is sufficient for identity and avoids the throw. ### Platform -- HidSharp targets Windows, macOS, and Linux, but the controllers' BT output report formats and PnP semantics have only been verified on Windows. The provider is expected to work on macOS and Linux for USB-connected controllers; Bluetooth has not been tested on those platforms. Reports welcome. +- **Windows**: each opened controller gets a second synchronous Win32 `WriteFile` handle (`HidRawWriter`) alongside the HidSharp-managed `HidStream`. All output reports go through the synchronous handle. HidSharp's `HidStream.Write` opens its handle with `FILE_FLAG_OVERLAPPED` and runs an asynchronous `WriteFile` + `GetOverlappedResult` dance internally; against the PlayStation USB HID minidriver that overlapped path returns failure on the second and subsequent writes, manifesting as "lightbar updates once and then freezes". The synchronous handle sidesteps the issue entirely. The two-handle overhead is negligible — Sony HID gamepads accept shared writes by default. If the second handle fails to open (rare), the queue falls back to `HidStream.Write` and logs a diagnostic. +- **macOS / Linux**: HidSharp's platform implementations don't use the same overlapped Windows code path, so the provider uses `HidStream.Write` directly. The controllers' BT output report formats and PnP semantics have only been verified on Windows; macOS and Linux are expected to work for USB but Bluetooth has not been tested. Reports welcome. - On Linux, `hid-playstation` (kernel 5.12+) drives most lighting itself and may compete with this provider for output reports — last writer wins, but the kernel's player-LED logic may overwrite host-driven indicators. ## Protocol references From 6a3462b4a9ea0d065e02aeff0b76f43f217257fc Mon Sep 17 00:00:00 2001 From: Danielle Date: Tue, 19 May 2026 23:20:42 +1000 Subject: [PATCH 3/5] PlayStation: use Color.GetR/GetG/GetB extensions for byte conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses DarthAffe's review feedback on PR #454 — the manual (byte)Math.Clamp((int)Math.Round(c * 255.0), 0, 255) pattern in both update queues is replaced with the project's standard Color.GetR / GetG / GetB extensions, which delegate to GetByteValueFromPercentage in RGB.NET.Core. That gives consistent rounding behaviour across the codebase and matches the convention every other provider uses. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../DualSense/DualSenseUpdateQueue.cs | 6 +++--- .../DualShock4/DualShock4UpdateQueue.cs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/RGB.NET.Devices.PlayStation/DualSense/DualSenseUpdateQueue.cs b/RGB.NET.Devices.PlayStation/DualSense/DualSenseUpdateQueue.cs index 70238295..7e01bb33 100644 --- a/RGB.NET.Devices.PlayStation/DualSense/DualSenseUpdateQueue.cs +++ b/RGB.NET.Devices.PlayStation/DualSense/DualSenseUpdateQueue.cs @@ -175,9 +175,9 @@ private bool WriteBuffer() private void BuildReport(Color lightbar, byte playerLedBits) { - byte r = (byte)Math.Clamp((int)Math.Round(lightbar.R * 255.0), 0, 255); - byte g = (byte)Math.Clamp((int)Math.Round(lightbar.G * 255.0), 0, 255); - byte b = (byte)Math.Clamp((int)Math.Round(lightbar.B * 255.0), 0, 255); + byte r = lightbar.GetR(); + byte g = lightbar.GetG(); + byte b = lightbar.GetB(); int commonOffset; // start of the 47-byte common block within _buffer diff --git a/RGB.NET.Devices.PlayStation/DualShock4/DualShock4UpdateQueue.cs b/RGB.NET.Devices.PlayStation/DualShock4/DualShock4UpdateQueue.cs index dc01e589..d78b2417 100644 --- a/RGB.NET.Devices.PlayStation/DualShock4/DualShock4UpdateQueue.cs +++ b/RGB.NET.Devices.PlayStation/DualShock4/DualShock4UpdateQueue.cs @@ -122,9 +122,9 @@ private bool WriteBuffer() private void BuildReport(Color color) { - byte r = (byte)Math.Clamp((int)Math.Round(color.R * 255.0), 0, 255); - byte g = (byte)Math.Clamp((int)Math.Round(color.G * 255.0), 0, 255); - byte b = (byte)Math.Clamp((int)Math.Round(color.B * 255.0), 0, 255); + byte r = color.GetR(); + byte g = color.GetG(); + byte b = color.GetB(); if (_transport == PlayStationTransport.Bluetooth) { From 22a1a92b5056c27bb2562ff98e11259ffc8f2d9b Mon Sep 17 00:00:00 2001 From: Danielle Date: Tue, 19 May 2026 23:28:51 +1000 Subject: [PATCH 4/5] PlayStation: move per-device HID state onto the device classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses DarthAffe's review feedback on PR #454 — the per-device lifecycle state (HidStream, optional HidRawWriter, DevicePath, and the "known disconnected" hint) was previously held in four collections on the provider, all keyed by IRGBDevice. Moves that state onto the DualShock4 / DualSense device classes themselves and lets each device clean up its own I/O via Dispose. - New IPlayStationRGBDevice interface exposes DevicePath / IsKnownDisconnected / MarkKnownDisconnected so the provider's hot-plug iteration can walk Devices.OfType() instead of consulting a Dictionary. - DualShock4RGBDevice and DualSenseRGBDevice each take their HidStream, HidRawWriter? and DevicePath in the constructor, override Dispose to send a graceful off-frame (when not known-disconnected) and release the I/O. SuspendWrites / Shutdown are no longer needed on the device class — MarkKnownDisconnected covers the former, Dispose covers the latter. - Provider drops _openStreams, _devicePaths, _rawWriters, _confirmedDisconnected dictionaries plus the _stateLock and _disposing flag they protected. RemoveDevice becomes a base.RemoveDevice + device.Dispose passthrough. Reconcile and SuspendDeadDevices iterate Devices.OfType() and read .DevicePath off each. Dispose marks all owned devices as known-disconnected before tearing them down so their off-frame attempt is skipped at app shutdown. No behaviour change: same hot-plug timings, same off-frame policy, same alive-paths snapshot mechanism. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../DualSense/DualSenseRGBDevice.cs | 41 +++- .../DualShock4/DualShock4RGBDevice.cs | 45 ++++- .../Generic/IPlayStationRGBDevice.cs | 29 +++ .../PlayStationDeviceProvider.cs | 175 ++++++------------ 4 files changed, 160 insertions(+), 130 deletions(-) create mode 100644 RGB.NET.Devices.PlayStation/Generic/IPlayStationRGBDevice.cs diff --git a/RGB.NET.Devices.PlayStation/DualSense/DualSenseRGBDevice.cs b/RGB.NET.Devices.PlayStation/DualSense/DualSenseRGBDevice.cs index d708b391..26f25490 100644 --- a/RGB.NET.Devices.PlayStation/DualSense/DualSenseRGBDevice.cs +++ b/RGB.NET.Devices.PlayStation/DualSense/DualSenseRGBDevice.cs @@ -1,25 +1,40 @@ +using HidSharp; using RGB.NET.Core; namespace RGB.NET.Devices.PlayStation; -/// +/// /// -/// Represents a Sony DualSense controller (PS5 / DualSense Edge). +/// Represents a Sony DualSense controller (PS5 / DualSense Edge). Owns its +/// HID I/O directly — the open , the optional Win32 +/// raw-write fallback, and the device path used for identity comparison +/// during hot-plug — and tears them down on . /// -public sealed class DualSenseRGBDevice : AbstractRGBDevice +public sealed class DualSenseRGBDevice : AbstractRGBDevice, IPlayStationRGBDevice { #region Properties & Fields private readonly DualSenseUpdateQueue _updateQueue; + private readonly HidStream _stream; + private readonly HidRawWriter? _rawWriter; + + /// + public string DevicePath { get; } + + /// + public bool IsKnownDisconnected { get; private set; } #endregion #region Constructors - internal DualSenseRGBDevice(PlayStationDeviceInfo deviceInfo, DualSenseUpdateQueue updateQueue) + internal DualSenseRGBDevice(PlayStationDeviceInfo deviceInfo, DualSenseUpdateQueue updateQueue, HidStream stream, HidRawWriter? rawWriter, string devicePath) : base(deviceInfo, updateQueue) { _updateQueue = updateQueue; + _stream = stream; + _rawWriter = rawWriter; + DevicePath = devicePath ?? string.Empty; InitializeLayout(); } @@ -58,8 +73,22 @@ private void InitializeLayout() } } - internal void SuspendWrites() => _updateQueue.SuspendWrites(); - internal void Shutdown(bool sendOffFrame = true) => _updateQueue.Shutdown(sendOffFrame); + /// + public void MarkKnownDisconnected() + { + IsKnownDisconnected = true; + try { _updateQueue.SuspendWrites(); } catch { /* best effort */ } + } + + /// + public override void Dispose() + { + try { _updateQueue.Shutdown(sendOffFrame: !IsKnownDisconnected); } catch { /* best effort */ } + try { _rawWriter?.Dispose(); } catch { /* best effort */ } + try { _stream.Dispose(); } catch { /* best effort */ } + + base.Dispose(); + } #endregion } diff --git a/RGB.NET.Devices.PlayStation/DualShock4/DualShock4RGBDevice.cs b/RGB.NET.Devices.PlayStation/DualShock4/DualShock4RGBDevice.cs index 02c0f3ba..d95a0a49 100644 --- a/RGB.NET.Devices.PlayStation/DualShock4/DualShock4RGBDevice.cs +++ b/RGB.NET.Devices.PlayStation/DualShock4/DualShock4RGBDevice.cs @@ -1,25 +1,40 @@ +using HidSharp; using RGB.NET.Core; namespace RGB.NET.Devices.PlayStation; -/// +/// /// -/// Represents a Sony DualShock 4 controller. +/// Represents a Sony DualShock 4 controller. Owns its HID I/O directly — +/// the open , the optional Win32 raw-write fallback, +/// and the device path used for identity comparison during hot-plug — and +/// tears them down on . /// -public sealed class DualShock4RGBDevice : AbstractRGBDevice +public sealed class DualShock4RGBDevice : AbstractRGBDevice, IPlayStationRGBDevice { #region Properties & Fields private readonly DualShock4UpdateQueue _updateQueue; + private readonly HidStream _stream; + private readonly HidRawWriter? _rawWriter; + + /// + public string DevicePath { get; } + + /// + public bool IsKnownDisconnected { get; private set; } #endregion #region Constructors - internal DualShock4RGBDevice(PlayStationDeviceInfo deviceInfo, DualShock4UpdateQueue updateQueue) + internal DualShock4RGBDevice(PlayStationDeviceInfo deviceInfo, DualShock4UpdateQueue updateQueue, HidStream stream, HidRawWriter? rawWriter, string devicePath) : base(deviceInfo, updateQueue) { _updateQueue = updateQueue; + _stream = stream; + _rawWriter = rawWriter; + DevicePath = devicePath ?? string.Empty; InitializeLayout(); } @@ -38,8 +53,26 @@ private void InitializeLayout() lightbar.Shape = Shape.Rectangle; } - internal void SuspendWrites() => _updateQueue.SuspendWrites(); - internal void Shutdown(bool sendOffFrame = true) => _updateQueue.Shutdown(sendOffFrame); + /// + public void MarkKnownDisconnected() + { + IsKnownDisconnected = true; + try { _updateQueue.SuspendWrites(); } catch { /* best effort */ } + } + + /// + public override void Dispose() + { + // Off-frame is best-effort: send a final all-zero lightbar so the + // controller doesn't sit on our last colour after we tear down. + // Skipped when we've already been marked disconnected — the handle + // is invalid and the write would just throw. + try { _updateQueue.Shutdown(sendOffFrame: !IsKnownDisconnected); } catch { /* best effort */ } + try { _rawWriter?.Dispose(); } catch { /* best effort */ } + try { _stream.Dispose(); } catch { /* best effort */ } + + base.Dispose(); + } #endregion } diff --git a/RGB.NET.Devices.PlayStation/Generic/IPlayStationRGBDevice.cs b/RGB.NET.Devices.PlayStation/Generic/IPlayStationRGBDevice.cs new file mode 100644 index 00000000..aec9b918 --- /dev/null +++ b/RGB.NET.Devices.PlayStation/Generic/IPlayStationRGBDevice.cs @@ -0,0 +1,29 @@ +using RGB.NET.Core; + +namespace RGB.NET.Devices.PlayStation; + +/// +/// Common contract for PlayStation controller RGB devices. Each device owns +/// its own HID I/O (HidStream + optional HidRawWriter) and DevicePath, and +/// is responsible for tearing those down on . +/// +internal interface IPlayStationRGBDevice : IRGBDevice +{ + /// The Windows / Linux HID device path the controller was opened on. + string DevicePath { get; } + + /// + /// Set by the provider's hot-plug pass when the controller has disappeared + /// from HID enumeration. Causes Dispose to skip the polite off-frame write + /// (which would just throw against the invalidated handle anyway). + /// + bool IsKnownDisconnected { get; } + + /// + /// Records that the device is no longer reachable on its HID path AND + /// suspends any further writes from the update queue. Called from the + /// hot-plug callback before the debounced Reconcile fires, so the next + /// 30Hz tick is a no-op rather than an exception. + /// + void MarkKnownDisconnected(); +} diff --git a/RGB.NET.Devices.PlayStation/PlayStationDeviceProvider.cs b/RGB.NET.Devices.PlayStation/PlayStationDeviceProvider.cs index 529ff49a..4837014e 100644 --- a/RGB.NET.Devices.PlayStation/PlayStationDeviceProvider.cs +++ b/RGB.NET.Devices.PlayStation/PlayStationDeviceProvider.cs @@ -97,25 +97,13 @@ public static PlayStationDeviceProvider Instance } } - // Per-device state needed for lifecycle: the open HidStream (for dispose - // on remove) and the HidDevice's DevicePath (for identity comparison - // during reconcile, since serial isn't always available, especially on - // BT-paired controllers). Both keyed by IRGBDevice so RemoveDevice can - // find them when given the device instance. - private readonly Dictionary _openStreams = []; - private readonly Dictionary _devicePaths = []; - // Win32-direct WriteFile wrappers used for the actual lighting writes on - // Windows. Kept separate from HidStream because HidSharp's overlapped I/O - // path fails on the second and subsequent USB writes against the - // PlayStation HID minidriver — see HidRawWriter for the full rationale. - // Null entries indicate non-Windows or a failed open; queues fall back to - // HidStream.Write in those cases. - private readonly Dictionary _rawWriters = []; - - // Tracks devices that Reconcile has already confirmed as physically - // disconnected. RemoveDevice consults this to decide whether the - // graceful "send a final all-black frame" attempt is worth making. - private readonly HashSet _confirmedDisconnected = []; + // Per-device state — HidStream, optional HidRawWriter, DevicePath — lives + // on the device class itself (DualShock4RGBDevice / DualSenseRGBDevice) + // and is disposed by the device's Dispose. The provider only needs to + // know which devices it owns, which it gets via the inherited + // collection. Hot-plug iteration walks that + // collection and reads each + // off the device. // Snapshot of currently-alive Sony controller DevicePaths, refreshed // synchronously by SuspendDeadDevices on every DeviceList.Changed @@ -127,14 +115,6 @@ public static PlayStationDeviceProvider Instance // a now-invalid handle and throw IOException. private static volatile HashSet _alivePathsSnapshot = new(StringComparer.OrdinalIgnoreCase); - // Set true inside Dispose so RemoveDevice can also skip the off-frame - // at app shutdown — the OS may already have invalidated the HID - // handle even though the controller is physically connected, and - // the firmware resets to its default indicator on process exit - // regardless of whether we send black first. - private volatile bool _disposing; - private readonly Lock _stateLock = new(); - // Hot-plug bookkeeping: subscription flag (so re-init doesn't double-subscribe), // and a serial counter so debounced reconciles on stale enqueues short-circuit. private bool _hotplugSubscribed; @@ -308,19 +288,12 @@ private bool TryOpenAndCreateDevice(HidDevice hid, int pid, out IRGBDevice? devi if (controllerType == PlayStationControllerType.DualShock4) { DualShock4UpdateQueue queue = new(GetUpdateTrigger(), opened, rawWriter, transport, devicePath); - newDevice = new DualShock4RGBDevice(info, queue); + newDevice = new DualShock4RGBDevice(info, queue, opened, rawWriter, devicePath); } else { DualSenseUpdateQueue queue = new(GetUpdateTrigger(), opened, rawWriter, transport, devicePath); - newDevice = new DualSenseRGBDevice(info, queue); - } - - lock (_stateLock) - { - _openStreams[newDevice] = opened; - _rawWriters[newDevice] = rawWriter; - _devicePaths[newDevice] = devicePath; + newDevice = new DualSenseRGBDevice(info, queue, opened, rawWriter, devicePath); } device = newDevice; @@ -408,8 +381,9 @@ public static bool IsDevicePathAlive(string devicePath) // Immediate-pass companion to Reconcile. Compares currently-tracked device // paths to the live HID enumeration; for anything still held open that no - // longer enumerates, suspend writes on its queue AND refresh the alive- - // path snapshot UpdateQueues consult per frame. + // longer enumerates, mark the device as known-disconnected (suspends its + // queue) AND refresh the alive-path snapshot the update queues consult + // per frame. private void SuspendDeadDevices() { HashSet currentPaths; @@ -430,34 +404,22 @@ private void SuspendDeadDevices() // on the next trigger tick (volatile reference write). _alivePathsSnapshot = currentPaths; - List> snapshot; - lock (_stateLock) - { - snapshot = [.. _devicePaths]; - } - - foreach (KeyValuePair kvp in snapshot) + // Iterate a copy so concurrent removals during Reconcile don't + // mutate the collection mid-enumeration. + List snapshot = Devices.OfType().ToList(); + foreach (IPlayStationRGBDevice device in snapshot) { - if (string.IsNullOrEmpty(kvp.Value)) continue; - if (currentPaths.Contains(kvp.Value)) continue; + if (string.IsNullOrEmpty(device.DevicePath)) continue; + if (currentPaths.Contains(device.DevicePath)) continue; + if (device.IsKnownDisconnected) continue; - // Mark as confirmed gone so when the debounced Reconcile gets here - // it skips the off-frame write in RemoveDevice. - lock (_stateLock) { _confirmedDisconnected.Add(kvp.Key); } - - switch (kvp.Key) - { - case DualShock4RGBDevice ds4: ds4.SuspendWrites(); break; - case DualSenseRGBDevice ds: ds.SuspendWrites(); break; - } + device.MarkKnownDisconnected(); } } // Compare current HID enumeration to the open set; add new ones, remove - // gone ones. Called from the debounced PnP callback. Holds _stateLock for - // the snapshot read so we don't race with a concurrent Dispose; opens and - // AddDevice/RemoveDevice are done outside the lock so we don't deadlock - // against any handler that might call back into the provider. + // gone ones. Called from the debounced PnP callback. Iterates the + // device collection directly — each device knows its own DevicePath. private void Reconcile() { HashSet currentPaths; @@ -475,34 +437,29 @@ private void Reconcile() return; } - List> snapshot; - lock (_stateLock) - { - snapshot = [.. _devicePaths]; - } + List snapshot = Devices.OfType().ToList(); // Removals first (devices held but no longer enumerated) — done before // adds so a controller that quickly reconnects on a different path can // be re-added cleanly. - foreach (KeyValuePair kvp in snapshot) + foreach (IPlayStationRGBDevice device in snapshot) { - if (string.IsNullOrEmpty(kvp.Value)) continue; - if (!currentPaths.Contains(kvp.Value)) - { - lock (_stateLock) { _confirmedDisconnected.Add(kvp.Key); } - RemoveDevice(kvp.Key); - } - } + if (string.IsNullOrEmpty(device.DevicePath)) continue; + if (currentPaths.Contains(device.DevicePath)) continue; - // Additions: any enumerated path not currently open. - HashSet openedPaths; - lock (_stateLock) - { - openedPaths = _devicePaths.Values - .Where(p => !string.IsNullOrEmpty(p)) - .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (!device.IsKnownDisconnected) + device.MarkKnownDisconnected(); + RemoveDevice(device); } + // Additions: any enumerated path not currently open. Re-snapshot Devices + // after removals so a controller that disappeared and immediately + // reconnected on the same path can be re-added. + HashSet openedPaths = Devices.OfType() + .Select(d => d.DevicePath) + .Where(p => !string.IsNullOrEmpty(p)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (HidDevice hid in DeviceList.Local.GetHidDevices(vendorID: SONY_VENDOR_ID)) { if (!IsSupportedPid(hid.ProductID)) continue; @@ -537,38 +494,18 @@ private void Reconcile() /// protected override bool RemoveDevice(IRGBDevice device) { - HidStream? stream = null; - HidRawWriter? rawWriter = null; - bool wasConfirmedGone; - lock (_stateLock) + // Provider Dispose marks every device as known-disconnected first, + // so the device's own Dispose skips the off-frame attempt in that + // case. PnP-driven removal goes through Reconcile which also marks + // first. Voluntary host-app removal of a still-connected device + // (the rare case) leaves IsKnownDisconnected false, so the device + // sends a graceful off-frame before tearing its stream down. + bool removed = base.RemoveDevice(device); + if (removed) { - if (_openStreams.TryGetValue(device, out stream)) - _openStreams.Remove(device); - if (_rawWriters.TryGetValue(device, out rawWriter)) - _rawWriters.Remove(device); - _devicePaths.Remove(device); - wasConfirmedGone = _confirmedDisconnected.Remove(device); + try { device.Dispose(); } catch { /* best effort */ } } - - // Send a final off-frame ONLY when removal is voluntary (provider - // unloaded by the host app). Skip it when the device was confirmed - // physically gone, or we're inside Dispose. In both skip cases the - // write would fail silently anyway. - bool sendOffFrame = !wasConfirmedGone && !_disposing; - try { (device as DualShock4RGBDevice)?.Shutdown(sendOffFrame); } catch { /* best effort */ } - try { (device as DualSenseRGBDevice)?.Shutdown(sendOffFrame); } catch { /* best effort */ } - - if (rawWriter != null) - { - try { rawWriter.Dispose(); } catch { /* best effort */ } - } - - if (stream != null) - { - try { stream.Dispose(); } catch { /* best effort */ } - } - - return base.RemoveDevice(device); + return removed; } /// @@ -576,22 +513,24 @@ protected override void Dispose(bool disposing) { if (disposing) { - _disposing = true; - if (_hotplugSubscribed) { try { DeviceList.Local.Changed -= OnHidDeviceListChanged; } catch { /* best effort */ } _hotplugSubscribed = false; } - // Snapshot devices to remove. RemoveDevice mutates the dictionaries, - // so iterate a copy. - List snapshot; - lock (_stateLock) + // Inside Dispose the OS may have already invalidated the HID + // handles even if the controller is physically connected — so + // mark every device as known-disconnected first. Their own + // Dispose then skips the polite off-frame write that would + // throw against the invalid handle. Iterate a copy because + // RemoveDevice mutates InternalDevices. + List snapshot = Devices.OfType().ToList(); + foreach (IPlayStationRGBDevice d in snapshot) { - snapshot = [.. _openStreams.Keys]; + try { d.MarkKnownDisconnected(); } catch { /* best effort */ } } - foreach (IRGBDevice d in snapshot) + foreach (IPlayStationRGBDevice d in snapshot) { try { RemoveDevice(d); } catch { /* best effort */ } } From f2e09736f20c1b6973e628524113a7b77675c612 Mon Sep 17 00:00:00 2001 From: Danielle Date: Wed, 20 May 2026 00:08:48 +1000 Subject: [PATCH 5/5] PlayStation: send off-frame on voluntary Dispose so LEDs don't freeze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dispose(bool) was unconditionally calling MarkKnownDisconnected on every device before RemoveDevice, which forced each device's own Dispose to skip the off-frame write (sendOffFrame = !IsKnownDisconnected = false). Net effect: when the host app unloaded the provider voluntarily — settings toggle off, app shutdown — the lightbar froze on the last painted colour instead of blanking and letting the firmware take back over. The PnP path (Reconcile / SuspendDeadDevices) still marks physically-gone devices correctly and their Dispose still skips the doomed write against the invalid handle. Removing the unconditional mark only changes behaviour on the voluntary-teardown paths, where the handle is still valid and the all-zero report goes out cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PlayStationDeviceProvider.cs | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/RGB.NET.Devices.PlayStation/PlayStationDeviceProvider.cs b/RGB.NET.Devices.PlayStation/PlayStationDeviceProvider.cs index 4837014e..553e274c 100644 --- a/RGB.NET.Devices.PlayStation/PlayStationDeviceProvider.cs +++ b/RGB.NET.Devices.PlayStation/PlayStationDeviceProvider.cs @@ -519,18 +519,22 @@ protected override void Dispose(bool disposing) _hotplugSubscribed = false; } - // Inside Dispose the OS may have already invalidated the HID - // handles even if the controller is physically connected — so - // mark every device as known-disconnected first. Their own - // Dispose then skips the polite off-frame write that would - // throw against the invalid handle. Iterate a copy because - // RemoveDevice mutates InternalDevices. + // Voluntary teardown: the controller is still physically attached + // and its HID handle is still valid (the OS doesn't invalidate + // handles just because Dispose is being called). Leave + // IsKnownDisconnected alone so each device's Dispose sees it as + // false and sends a final all-zero output report — the lightbar + // and player indicators blank out instead of freezing on the last + // colour they were painted. HidRawWriter.TryWrite is non-throwing + // anyway, so a stale handle just fails silently. + // + // Devices that were already torn down by the PnP path + // (Reconcile / SuspendDeadDevices) have IsKnownDisconnected = true + // and skip the off-frame correctly on their own. + // + // Iterate a copy because RemoveDevice mutates InternalDevices. List snapshot = Devices.OfType().ToList(); foreach (IPlayStationRGBDevice d in snapshot) - { - try { d.MarkKnownDisconnected(); } catch { /* best effort */ } - } - foreach (IPlayStationRGBDevice d in snapshot) { try { RemoveDevice(d); } catch { /* best effort */ } }