|
| 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 | +} |
0 commit comments