Skip to content

Commit 45c9ce9

Browse files
Added PlayStation-Device-Implementations (DualShock 4, DualSense, DualSense Edge)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 62a6911 commit 45c9ce9

12 files changed

Lines changed: 1457 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: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
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 PlayStationTransport _transport;
69+
private readonly byte[] _buffer;
70+
private readonly string _devicePath;
71+
private readonly Lock _writeLock = new();
72+
private byte _btSeq; // 0..15 rolling
73+
private bool _firstReport = true;
74+
private volatile bool _disposed;
75+
76+
#endregion
77+
78+
#region Constructors
79+
80+
public DualSenseUpdateQueue(IDeviceUpdateTrigger trigger, HidStream stream, PlayStationTransport transport, string devicePath)
81+
: base(trigger)
82+
{
83+
_stream = stream;
84+
_transport = transport;
85+
_devicePath = devicePath ?? string.Empty;
86+
_buffer = new byte[transport == PlayStationTransport.Bluetooth ? 78 : 63];
87+
}
88+
89+
#endregion
90+
91+
#region Methods
92+
93+
protected override bool Update(ReadOnlySpan<(object key, Color color)> dataSet)
94+
{
95+
if (_disposed) return true;
96+
if (dataSet.IsEmpty) return true;
97+
98+
// Per-frame liveness pre-check; see DualShock4UpdateQueue.Update.
99+
if (!PlayStationDeviceProvider.IsDevicePathAlive(_devicePath))
100+
{
101+
_disposed = true;
102+
return false;
103+
}
104+
105+
// Walk the painted LEDs and split them into the payload slots the
106+
// report cares about. dataSet entries arrive keyed by LedId, so we
107+
// can address them individually instead of trusting iteration order.
108+
Color lightbar = default;
109+
byte playerLedBits = 0;
110+
bool gotLightbar = false;
111+
112+
foreach ((object key, Color color) in dataSet)
113+
{
114+
if (key is not LedId id) continue;
115+
switch (id)
116+
{
117+
case LedId.Custom1:
118+
lightbar = color;
119+
gotLightbar = true;
120+
break;
121+
// Custom2..Custom6 = player indicators 1..5 (bits 0..4)
122+
case LedId.Custom2: if (IsLit(color)) playerLedBits |= 1 << 0; break;
123+
case LedId.Custom3: if (IsLit(color)) playerLedBits |= 1 << 1; break;
124+
case LedId.Custom4: if (IsLit(color)) playerLedBits |= 1 << 2; break;
125+
case LedId.Custom5: if (IsLit(color)) playerLedBits |= 1 << 3; break;
126+
case LedId.Custom6: if (IsLit(color)) playerLedBits |= 1 << 4; break;
127+
}
128+
}
129+
130+
// If we somehow get a payload with no lightbar entry (should not
131+
// happen since RGB.NET commits every device LED each tick), keep
132+
// the lightbar at black instead of leaving uninitialised state.
133+
if (!gotLightbar) lightbar = new Color(0, 0, 0);
134+
135+
try
136+
{
137+
lock (_writeLock)
138+
{
139+
Array.Clear(_buffer, 0, _buffer.Length);
140+
BuildReport(lightbar, playerLedBits);
141+
_stream.Write(_buffer);
142+
}
143+
_firstReport = false;
144+
return true;
145+
}
146+
catch (Exception ex)
147+
{
148+
Trace.WriteLine($"[RGB.NET.PlayStation] DualSense write failed, suspending queue: {ex.Message}");
149+
_disposed = true;
150+
return false;
151+
}
152+
}
153+
154+
private static bool IsLit(Color c) => (c.R > 0) || (c.G > 0) || (c.B > 0);
155+
156+
private void BuildReport(Color lightbar, byte playerLedBits)
157+
{
158+
byte r = (byte)Math.Clamp((int)Math.Round(lightbar.R * 255.0), 0, 255);
159+
byte g = (byte)Math.Clamp((int)Math.Round(lightbar.G * 255.0), 0, 255);
160+
byte b = (byte)Math.Clamp((int)Math.Round(lightbar.B * 255.0), 0, 255);
161+
162+
int commonOffset; // start of the 47-byte common block within _buffer
163+
164+
if (_transport == PlayStationTransport.Bluetooth)
165+
{
166+
// BT report 0x31:
167+
// [0] report_id (0x31)
168+
// [1] seq_tag (high 4 bits = sequence number 0..15)
169+
// [2] tag (0x10)
170+
// [3..49] common (47 bytes)
171+
// [50..73] reserved (24 bytes)
172+
// [74..77] CRC32 (LE)
173+
_buffer[0] = 0x31;
174+
_buffer[1] = (byte)((_btSeq << 4) & 0xF0);
175+
_buffer[2] = BT_TAG;
176+
commonOffset = 3;
177+
_btSeq = (byte)((_btSeq + 1) & 0x0F);
178+
}
179+
else
180+
{
181+
// USB report 0x02:
182+
// [0] report_id (0x02)
183+
// [1..47] common (47 bytes)
184+
// [48..62] reserved (15 bytes)
185+
_buffer[0] = 0x02;
186+
commonOffset = 1;
187+
}
188+
189+
// dualsense_output_report_common offsets, 0-indexed from start of
190+
// common block. See struct dualsense_output_report_common in
191+
// Linux's hid-playstation.c.
192+
// [0] valid_flag0
193+
// [1] valid_flag1
194+
// [2] motor_right
195+
// [3] motor_left
196+
// [4] headphone_volume
197+
// [5] speaker_volume
198+
// [6] mic_volume
199+
// [7] audio_control
200+
// [8] mute_button_led
201+
// [9] power_save_control
202+
// [10..36] reserved2 (27 bytes)
203+
// [37] audio_control2
204+
// [38] valid_flag2
205+
// [39..40] reserved3
206+
// [41] lightbar_setup
207+
// [42] led_brightness
208+
// [43] player_leds
209+
// [44] lightbar_red
210+
// [45] lightbar_green
211+
// [46] lightbar_blue
212+
int c = commonOffset;
213+
_buffer[c + 0] = 0; // valid_flag0
214+
_buffer[c + 1] = VALID_FLAG1_ALL; // valid_flag1
215+
// motor_*, audio, power_save, mute_button_led left zero. The
216+
// mute_button_led byte (offset 8) is ignored by firmware because
217+
// we don't set MIC_MUTE_LED_CONTROL_ENABLE in valid_flag1, so
218+
// the firmware retains its default LED-tracks-mute-state behaviour.
219+
220+
if (_firstReport)
221+
{
222+
_buffer[c + 38] = VALID_FLAG2_LIGHTBAR_SETUP_CONTROL; // valid_flag2
223+
_buffer[c + 41] = LIGHTBAR_SETUP_RELEASE_LEDS; // lightbar_setup
224+
}
225+
226+
_buffer[c + 42] = 0; // led_brightness (0 = full per Sony default)
227+
_buffer[c + 43] = (byte)(playerLedBits & 0x1F); // player_leds (bits 0..4)
228+
_buffer[c + 44] = r; // lightbar_red
229+
_buffer[c + 45] = g; // lightbar_green
230+
_buffer[c + 46] = b; // lightbar_blue
231+
232+
if (_transport == PlayStationTransport.Bluetooth)
233+
PlayStationCrc32.AppendOutputCrc(_buffer);
234+
}
235+
236+
/// <summary>
237+
/// See <see cref="DualShock4UpdateQueue.SuspendWrites"/> for the contract.
238+
/// </summary>
239+
public void SuspendWrites() => _disposed = true;
240+
241+
/// <summary>
242+
/// See <see cref="DualShock4UpdateQueue.Shutdown(bool)"/> for the contract.
243+
/// </summary>
244+
public void Shutdown(bool sendOffFrame = true)
245+
{
246+
if (_disposed) return;
247+
_disposed = true;
248+
if (!sendOffFrame) return;
249+
try
250+
{
251+
lock (_writeLock)
252+
{
253+
Array.Clear(_buffer, 0, _buffer.Length);
254+
BuildReport(new Color(0, 0, 0), 0);
255+
_stream.Write(_buffer);
256+
}
257+
}
258+
catch
259+
{
260+
// Best-effort.
261+
}
262+
}
263+
264+
#endregion
265+
}
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)