diff --git a/RGB.NET.Devices.PlayStation/DualSense/DualSenseRGBDevice.cs b/RGB.NET.Devices.PlayStation/DualSense/DualSenseRGBDevice.cs
new file mode 100644
index 00000000..26f25490
--- /dev/null
+++ b/RGB.NET.Devices.PlayStation/DualSense/DualSenseRGBDevice.cs
@@ -0,0 +1,94 @@
+using HidSharp;
+using RGB.NET.Core;
+
+namespace RGB.NET.Devices.PlayStation;
+
+///
+///
+/// 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, 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, HidStream stream, HidRawWriter? rawWriter, string devicePath)
+ : base(deviceInfo, updateQueue)
+ {
+ _updateQueue = updateQueue;
+ _stream = stream;
+ _rawWriter = rawWriter;
+ DevicePath = devicePath ?? string.Empty;
+ 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;
+ }
+ }
+
+ ///
+ 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/DualSense/DualSenseUpdateQueue.cs b/RGB.NET.Devices.PlayStation/DualSense/DualSenseUpdateQueue.cs
new file mode 100644
index 00000000..7e01bb33
--- /dev/null
+++ b/RGB.NET.Devices.PlayStation/DualSense/DualSenseUpdateQueue.cs
@@ -0,0 +1,280 @@
+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 HidRawWriter? _rawWriter;
+ 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, 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];
+ }
+
+ #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);
+
+ 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
+ {
+ _stream.Write(_buffer);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Trace.WriteLine($"[RGB.NET.PlayStation] DualSense stream write threw: {ex.Message}");
+ 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 = lightbar.GetR();
+ byte g = lightbar.GetG();
+ byte b = lightbar.GetB();
+
+ 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;
+ // Best-effort — WriteBuffer returns false silently if the handle has
+ // already been invalidated.
+ lock (_writeLock)
+ {
+ Array.Clear(_buffer, 0, _buffer.Length);
+ BuildReport(new Color(0, 0, 0), 0);
+ WriteBuffer();
+ }
+ }
+
+ #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..d95a0a49
--- /dev/null
+++ b/RGB.NET.Devices.PlayStation/DualShock4/DualShock4RGBDevice.cs
@@ -0,0 +1,78 @@
+using HidSharp;
+using RGB.NET.Core;
+
+namespace RGB.NET.Devices.PlayStation;
+
+///
+///
+/// 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, 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, HidStream stream, HidRawWriter? rawWriter, string devicePath)
+ : base(deviceInfo, updateQueue)
+ {
+ _updateQueue = updateQueue;
+ _stream = stream;
+ _rawWriter = rawWriter;
+ DevicePath = devicePath ?? string.Empty;
+ 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;
+ }
+
+ ///
+ 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/DualShock4/DualShock4UpdateQueue.cs b/RGB.NET.Devices.PlayStation/DualShock4/DualShock4UpdateQueue.cs
new file mode 100644
index 00000000..d78b2417
--- /dev/null
+++ b/RGB.NET.Devices.PlayStation/DualShock4/DualShock4UpdateQueue.cs
@@ -0,0 +1,206 @@
+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 HidRawWriter? _rawWriter;
+ 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, 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];
+ }
+
+ #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;
+
+ bool ok;
+ lock (_writeLock)
+ {
+ Array.Clear(_buffer, 0, _buffer.Length);
+ BuildReport(color);
+ ok = WriteBuffer();
+ }
+
+ 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.");
+ _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)
+ {
+ byte r = color.GetR();
+ byte g = color.GetG();
+ byte b = color.GetB();
+
+ 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. Best-effort —
+ // WriteBuffer returns false silently if the handle has already been
+ // invalidated.
+ lock (_writeLock)
+ {
+ Array.Clear(_buffer, 0, _buffer.Length);
+ BuildReport(new Color(0, 0, 0));
+ WriteBuffer();
+ }
+ }
+
+ #endregion
+}
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/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/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..553e274c
--- /dev/null
+++ b/RGB.NET.Devices.PlayStation/PlayStationDeviceProvider.cs
@@ -0,0 +1,571 @@
+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 — 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
+ // 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);
+
+ // 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);
+
+ // 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, rawWriter, transport, devicePath);
+ newDevice = new DualShock4RGBDevice(info, queue, opened, rawWriter, devicePath);
+ }
+ else
+ {
+ DualSenseUpdateQueue queue = new(GetUpdateTrigger(), opened, rawWriter, transport, devicePath);
+ newDevice = new DualSenseRGBDevice(info, queue, opened, rawWriter, 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, 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;
+ 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;
+
+ // 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(device.DevicePath)) continue;
+ if (currentPaths.Contains(device.DevicePath)) continue;
+ if (device.IsKnownDisconnected) continue;
+
+ device.MarkKnownDisconnected();
+ }
+ }
+
+ // Compare current HID enumeration to the open set; add new ones, remove
+ // gone ones. Called from the debounced PnP callback. Iterates the
+ // device collection directly — each device knows its own DevicePath.
+ 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 = 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 (IPlayStationRGBDevice device in snapshot)
+ {
+ if (string.IsNullOrEmpty(device.DevicePath)) continue;
+ if (currentPaths.Contains(device.DevicePath)) continue;
+
+ 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;
+ 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)
+ {
+ // 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)
+ {
+ try { device.Dispose(); } catch { /* best effort */ }
+ }
+ return removed;
+ }
+
+ ///
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ if (_hotplugSubscribed)
+ {
+ try { DeviceList.Local.Changed -= OnHidDeviceListChanged; } catch { /* best effort */ }
+ _hotplugSubscribed = false;
+ }
+
+ // 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 { 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..150c60ba
--- /dev/null
+++ b/RGB.NET.Devices.PlayStation/README.md
@@ -0,0 +1,64 @@
+[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
+- **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
+
+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}