Skip to content

Commit 6dd3b99

Browse files
Merge PR DarthAffe#454: PlayStation device provider
Brings in the PlayStation controller provider (DualShock 4 / DualSense / DualSense Edge, USB + BT) from PR DarthAffe#454 - already mergeable against master so no conflict resolution needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2 parents 55a648c + 6b81f4c commit 6dd3b99

13 files changed

Lines changed: 1661 additions & 0 deletions
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using RGB.NET.Core;
2+
3+
namespace RGB.NET.Devices.PlayStation;
4+
5+
/// <inheritdoc />
6+
/// <summary>
7+
/// Represents a Sony DualSense controller (PS5 / DualSense Edge).
8+
/// </summary>
9+
public sealed class DualSenseRGBDevice : AbstractRGBDevice<PlayStationDeviceInfo>
10+
{
11+
#region Properties & Fields
12+
13+
private readonly DualSenseUpdateQueue _updateQueue;
14+
15+
#endregion
16+
17+
#region Constructors
18+
19+
internal DualSenseRGBDevice(PlayStationDeviceInfo deviceInfo, DualSenseUpdateQueue updateQueue)
20+
: base(deviceInfo, updateQueue)
21+
{
22+
_updateQueue = updateQueue;
23+
InitializeLayout();
24+
}
25+
26+
#endregion
27+
28+
#region Methods
29+
30+
// DualSense LED layout (left→right when looking at the controller):
31+
// - The lightbar runs along the bottom edge of the touchpad in two
32+
// mirrored strips. Modelled as one wide rectangle (Custom1).
33+
// - The 5 player indicator LEDs sit in a row directly below the
34+
// touchpad. Bit 0 = leftmost, bit 4 = rightmost from the player's
35+
// POV (matches Linux's player_leds bit ordering).
36+
//
37+
// Note: the mic-mute LED is intentionally NOT exposed. The controller
38+
// firmware drives that LED to track mic-mute toggle state — pressing
39+
// the mute button mutes the microphone AND lights the LED, regardless
40+
// of any host involvement. Taking host control of the LED would only
41+
// suppress that visual feedback for an action that still happens, so
42+
// we leave the firmware default in place. See DualSenseUpdateQueue
43+
// header for the protocol detail (we deliberately don't set the
44+
// MIC_MUTE_LED_CONTROL_ENABLE bit in valid_flag1).
45+
//
46+
// Coordinates are arbitrary visual approximations for layout consumers —
47+
// they don't drive any hardware addressing.
48+
private void InitializeLayout()
49+
{
50+
Led? lightbar = AddLed(LedId.Custom1, new Point(0, 0), new Size(80, 8));
51+
if (lightbar != null) lightbar.Shape = Shape.Rectangle;
52+
53+
// Five player indicator dots, evenly spaced beneath the lightbar.
54+
for (int i = 0; i < 5; i++)
55+
{
56+
Led? led = AddLed((LedId)(LedId.Custom2 + i), new Point(20 + (i * 12), 16), new Size(6));
57+
if (led != null) led.Shape = Shape.Circle;
58+
}
59+
}
60+
61+
internal void SuspendWrites() => _updateQueue.SuspendWrites();
62+
internal void Shutdown(bool sendOffFrame = true) => _updateQueue.Shutdown(sendOffFrame);
63+
64+
#endregion
65+
}
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
using System;
2+
using System.Diagnostics;
3+
using System.Threading;
4+
using HidSharp;
5+
using RGB.NET.Core;
6+
7+
namespace RGB.NET.Devices.PlayStation;
8+
9+
// Builds and writes DualSense main output reports.
10+
// USB report 0x02, 63 bytes total (incl. report ID).
11+
// BT report 0x31, 78 bytes total + trailing little-endian CRC32.
12+
//
13+
// The DualSense exposes:
14+
// - one RGB lightbar (sides of the touchpad)
15+
// - five monochrome player indicator LEDs in a row below the touchpad
16+
// - one mic-mute LED (orange, in the centre of the mic-mute button) —
17+
// NOT exposed by this provider. We deliberately leave the firmware in
18+
// control so the LED keeps its default behaviour of tracking the
19+
// hardware mic-mute toggle (tap the button → firmware mutes the mic
20+
// AND lights the LED). The mute BUTTON itself remains firmware-driven
21+
// regardless of host activity, so taking control of the LED would
22+
// only suppress the visual feedback for an action that still happens.
23+
//
24+
// We model the controllable LEDs as Custom1 (lightbar) + Custom2..Custom6
25+
// (P1..P5 left→right in bit-position order, see player_leds bit layout
26+
// below). Player indicators are monochrome so any non-black colour turns
27+
// them on at full brightness, and pure black turns them off.
28+
//
29+
// valid_flag1 gates which sub-systems the controller should accept updates
30+
// for. Without those bits set, the controller ignores the corresponding
31+
// bytes — so we must set them every report or e.g. the lightbar will stay
32+
// on the firmware's default. We deliberately do NOT set the mic-mute-LED
33+
// bit (BIT(0)), which keeps the firmware-driven default LED behaviour.
34+
//
35+
// The first report after open also sets LIGHTBAR_SETUP_CONTROL_ENABLE +
36+
// lightbar_setup = 0x02 ("release leds"). On a fresh connect, the
37+
// controller plays a fade-in animation on the lightbar that overrides
38+
// host-driven colours until released. Without this one-shot, the first
39+
// few seconds of host control look like nothing is happening.
40+
internal sealed class DualSenseUpdateQueue : UpdateQueue
41+
{
42+
#region Constants
43+
44+
// valid_flag1 bits we want the controller to honour. Mic-mute LED is
45+
// intentionally NOT here — see file header for rationale.
46+
// BIT(0) = MIC_MUTE_LED_CONTROL_ENABLE remains clear; the firmware
47+
// ignores any value we'd put in mute_button_led and uses its own logic.
48+
private const byte VALID_FLAG1_LIGHTBAR_CONTROL = 0x04; // BIT(2)
49+
private const byte VALID_FLAG1_PLAYER_INDICATOR_CONTROL = 0x10; // BIT(4)
50+
private const byte VALID_FLAG1_ALL =
51+
VALID_FLAG1_LIGHTBAR_CONTROL | VALID_FLAG1_PLAYER_INDICATOR_CONTROL;
52+
53+
// valid_flag2 bit for the one-shot "release lightbar from boot animation"
54+
// setup. Cleared after the first report.
55+
private const byte VALID_FLAG2_LIGHTBAR_SETUP_CONTROL = 0x02; // BIT(1)
56+
private const byte LIGHTBAR_SETUP_RELEASE_LEDS = 0x02;
57+
58+
// BT-specific tag — Sony driver requires a fixed value here. Lower 4
59+
// bits of seq_tag carry an alternate tag (0); upper 4 bits carry a
60+
// sequence number that increments per report.
61+
private const byte BT_TAG = 0x10;
62+
63+
#endregion
64+
65+
#region Properties & Fields
66+
67+
private readonly HidStream _stream;
68+
private readonly HidRawWriter? _rawWriter;
69+
private readonly PlayStationTransport _transport;
70+
private readonly byte[] _buffer;
71+
private readonly string _devicePath;
72+
private readonly Lock _writeLock = new();
73+
private byte _btSeq; // 0..15 rolling
74+
private bool _firstReport = true;
75+
private volatile bool _disposed;
76+
77+
#endregion
78+
79+
#region Constructors
80+
81+
public DualSenseUpdateQueue(IDeviceUpdateTrigger trigger, HidStream stream, HidRawWriter? rawWriter, PlayStationTransport transport, string devicePath)
82+
: base(trigger)
83+
{
84+
_stream = stream;
85+
_rawWriter = rawWriter;
86+
_transport = transport;
87+
_devicePath = devicePath ?? string.Empty;
88+
_buffer = new byte[transport == PlayStationTransport.Bluetooth ? 78 : 63];
89+
}
90+
91+
#endregion
92+
93+
#region Methods
94+
95+
protected override bool Update(ReadOnlySpan<(object key, Color color)> dataSet)
96+
{
97+
if (_disposed) return true;
98+
if (dataSet.IsEmpty) return true;
99+
100+
// Per-frame liveness pre-check; see DualShock4UpdateQueue.Update.
101+
if (!PlayStationDeviceProvider.IsDevicePathAlive(_devicePath))
102+
{
103+
_disposed = true;
104+
return false;
105+
}
106+
107+
// Walk the painted LEDs and split them into the payload slots the
108+
// report cares about. dataSet entries arrive keyed by LedId, so we
109+
// can address them individually instead of trusting iteration order.
110+
Color lightbar = default;
111+
byte playerLedBits = 0;
112+
bool gotLightbar = false;
113+
114+
foreach ((object key, Color color) in dataSet)
115+
{
116+
if (key is not LedId id) continue;
117+
switch (id)
118+
{
119+
case LedId.Custom1:
120+
lightbar = color;
121+
gotLightbar = true;
122+
break;
123+
// Custom2..Custom6 = player indicators 1..5 (bits 0..4)
124+
case LedId.Custom2: if (IsLit(color)) playerLedBits |= 1 << 0; break;
125+
case LedId.Custom3: if (IsLit(color)) playerLedBits |= 1 << 1; break;
126+
case LedId.Custom4: if (IsLit(color)) playerLedBits |= 1 << 2; break;
127+
case LedId.Custom5: if (IsLit(color)) playerLedBits |= 1 << 3; break;
128+
case LedId.Custom6: if (IsLit(color)) playerLedBits |= 1 << 4; break;
129+
}
130+
}
131+
132+
// If we somehow get a payload with no lightbar entry (should not
133+
// happen since RGB.NET commits every device LED each tick), keep
134+
// the lightbar at black instead of leaving uninitialised state.
135+
if (!gotLightbar) lightbar = new Color(0, 0, 0);
136+
137+
bool ok;
138+
lock (_writeLock)
139+
{
140+
Array.Clear(_buffer, 0, _buffer.Length);
141+
BuildReport(lightbar, playerLedBits);
142+
ok = WriteBuffer();
143+
}
144+
145+
if (!ok)
146+
{
147+
Trace.WriteLine("[RGB.NET.PlayStation] DualSense write failed, suspending queue.");
148+
_disposed = true;
149+
return false;
150+
}
151+
_firstReport = false;
152+
return true;
153+
}
154+
155+
// See DualShock4UpdateQueue.WriteBuffer for the rationale behind preferring
156+
// HidRawWriter over HidStream.Write on Windows.
157+
private bool WriteBuffer()
158+
{
159+
if (_rawWriter != null)
160+
return _rawWriter.TryWrite(_buffer);
161+
162+
try
163+
{
164+
_stream.Write(_buffer);
165+
return true;
166+
}
167+
catch (Exception ex)
168+
{
169+
Trace.WriteLine($"[RGB.NET.PlayStation] DualSense stream write threw: {ex.Message}");
170+
return false;
171+
}
172+
}
173+
174+
private static bool IsLit(Color c) => (c.R > 0) || (c.G > 0) || (c.B > 0);
175+
176+
private void BuildReport(Color lightbar, byte playerLedBits)
177+
{
178+
byte r = (byte)Math.Clamp((int)Math.Round(lightbar.R * 255.0), 0, 255);
179+
byte g = (byte)Math.Clamp((int)Math.Round(lightbar.G * 255.0), 0, 255);
180+
byte b = (byte)Math.Clamp((int)Math.Round(lightbar.B * 255.0), 0, 255);
181+
182+
int commonOffset; // start of the 47-byte common block within _buffer
183+
184+
if (_transport == PlayStationTransport.Bluetooth)
185+
{
186+
// BT report 0x31:
187+
// [0] report_id (0x31)
188+
// [1] seq_tag (high 4 bits = sequence number 0..15)
189+
// [2] tag (0x10)
190+
// [3..49] common (47 bytes)
191+
// [50..73] reserved (24 bytes)
192+
// [74..77] CRC32 (LE)
193+
_buffer[0] = 0x31;
194+
_buffer[1] = (byte)((_btSeq << 4) & 0xF0);
195+
_buffer[2] = BT_TAG;
196+
commonOffset = 3;
197+
_btSeq = (byte)((_btSeq + 1) & 0x0F);
198+
}
199+
else
200+
{
201+
// USB report 0x02:
202+
// [0] report_id (0x02)
203+
// [1..47] common (47 bytes)
204+
// [48..62] reserved (15 bytes)
205+
_buffer[0] = 0x02;
206+
commonOffset = 1;
207+
}
208+
209+
// dualsense_output_report_common offsets, 0-indexed from start of
210+
// common block. See struct dualsense_output_report_common in
211+
// Linux's hid-playstation.c.
212+
// [0] valid_flag0
213+
// [1] valid_flag1
214+
// [2] motor_right
215+
// [3] motor_left
216+
// [4] headphone_volume
217+
// [5] speaker_volume
218+
// [6] mic_volume
219+
// [7] audio_control
220+
// [8] mute_button_led
221+
// [9] power_save_control
222+
// [10..36] reserved2 (27 bytes)
223+
// [37] audio_control2
224+
// [38] valid_flag2
225+
// [39..40] reserved3
226+
// [41] lightbar_setup
227+
// [42] led_brightness
228+
// [43] player_leds
229+
// [44] lightbar_red
230+
// [45] lightbar_green
231+
// [46] lightbar_blue
232+
int c = commonOffset;
233+
_buffer[c + 0] = 0; // valid_flag0
234+
_buffer[c + 1] = VALID_FLAG1_ALL; // valid_flag1
235+
// motor_*, audio, power_save, mute_button_led left zero. The
236+
// mute_button_led byte (offset 8) is ignored by firmware because
237+
// we don't set MIC_MUTE_LED_CONTROL_ENABLE in valid_flag1, so
238+
// the firmware retains its default LED-tracks-mute-state behaviour.
239+
240+
if (_firstReport)
241+
{
242+
_buffer[c + 38] = VALID_FLAG2_LIGHTBAR_SETUP_CONTROL; // valid_flag2
243+
_buffer[c + 41] = LIGHTBAR_SETUP_RELEASE_LEDS; // lightbar_setup
244+
}
245+
246+
_buffer[c + 42] = 0; // led_brightness (0 = full per Sony default)
247+
_buffer[c + 43] = (byte)(playerLedBits & 0x1F); // player_leds (bits 0..4)
248+
_buffer[c + 44] = r; // lightbar_red
249+
_buffer[c + 45] = g; // lightbar_green
250+
_buffer[c + 46] = b; // lightbar_blue
251+
252+
if (_transport == PlayStationTransport.Bluetooth)
253+
PlayStationCrc32.AppendOutputCrc(_buffer);
254+
}
255+
256+
/// <summary>
257+
/// See <see cref="DualShock4UpdateQueue.SuspendWrites"/> for the contract.
258+
/// </summary>
259+
public void SuspendWrites() => _disposed = true;
260+
261+
/// <summary>
262+
/// See <see cref="DualShock4UpdateQueue.Shutdown(bool)"/> for the contract.
263+
/// </summary>
264+
public void Shutdown(bool sendOffFrame = true)
265+
{
266+
if (_disposed) return;
267+
_disposed = true;
268+
if (!sendOffFrame) return;
269+
// Best-effort — WriteBuffer returns false silently if the handle has
270+
// already been invalidated.
271+
lock (_writeLock)
272+
{
273+
Array.Clear(_buffer, 0, _buffer.Length);
274+
BuildReport(new Color(0, 0, 0), 0);
275+
WriteBuffer();
276+
}
277+
}
278+
279+
#endregion
280+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using RGB.NET.Core;
2+
3+
namespace RGB.NET.Devices.PlayStation;
4+
5+
/// <inheritdoc />
6+
/// <summary>
7+
/// Represents a Sony DualShock 4 controller.
8+
/// </summary>
9+
public sealed class DualShock4RGBDevice : AbstractRGBDevice<PlayStationDeviceInfo>
10+
{
11+
#region Properties & Fields
12+
13+
private readonly DualShock4UpdateQueue _updateQueue;
14+
15+
#endregion
16+
17+
#region Constructors
18+
19+
internal DualShock4RGBDevice(PlayStationDeviceInfo deviceInfo, DualShock4UpdateQueue updateQueue)
20+
: base(deviceInfo, updateQueue)
21+
{
22+
_updateQueue = updateQueue;
23+
InitializeLayout();
24+
}
25+
26+
#endregion
27+
28+
#region Methods
29+
30+
// DS4 has a single RGB lightbar above the touchpad. Custom1 keeps the LED
31+
// enum stable across DS4 / DS5 — DualSenseRGBDevice's Custom1 is also the
32+
// lightbar so a host mapping for "Custom 1" carries sensible meaning across
33+
// both controller types.
34+
private void InitializeLayout()
35+
{
36+
Led? lightbar = AddLed(LedId.Custom1, new Point(0, 0), new Size(60, 14));
37+
if (lightbar != null)
38+
lightbar.Shape = Shape.Rectangle;
39+
}
40+
41+
internal void SuspendWrites() => _updateQueue.SuspendWrites();
42+
internal void Shutdown(bool sendOffFrame = true) => _updateQueue.Shutdown(sendOffFrame);
43+
44+
#endregion
45+
}

0 commit comments

Comments
 (0)