Feature/arctic fan controller (#2152)
* Port Artic Fan Controller code * Fixed misspelling and added improvements - Fixed thread not starting (added _thread.Start()) - Added control sensor activation - Added PWM initialization from device - Improved PWM value reading for fast updates * Slight refactor Extract message response * Create README.MD * Code review * Add license header * Make readonly fields * Remove redundant const * format * Remove redundant index check in Update() * add missing control lock in close() * add missing hid lock in update loop for array access * join thread (wait for exit) on cose() * Proper Hardware type * File scoped namespaces * Update ArcticFanController.cs --------- Co-authored-by: aureopvh <aureoserrano@hotmail.com>
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
||||
// If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
// Copyright (C) LibreHardwareMonitor and Contributors.
|
||||
// Partial Copyright (C) Michael Möller <mmoeller@openhardwaremonitor.org> and Contributors.
|
||||
// All Rights Reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using HidSharp;
|
||||
|
||||
namespace LibreHardwareMonitor.Hardware.Controller.Arctic;
|
||||
|
||||
internal class ArcticFanController : Hardware
|
||||
{
|
||||
private const int CHANNEL_COUNT = 10;
|
||||
private const int PACKET_SIZE = 32;
|
||||
private const int CONTROL_VALUE_MIN = 0;
|
||||
private const int CONTROL_VALUE_MAX = 100;
|
||||
private const int CONTROL_RESET_VALUE = 30;
|
||||
private const int TIMEOUT_MS = 1000;
|
||||
|
||||
private HidStream _hidStream;
|
||||
private readonly object _hidLock = new();
|
||||
private readonly object _controlLock = new();
|
||||
private readonly Thread _thread;
|
||||
private readonly float[] _requestedFanSpeedsPercent = new float[CHANNEL_COUNT];
|
||||
private readonly float[] _currentFanRpms = new float[CHANNEL_COUNT];
|
||||
private readonly float[] _currentDevicePwmValues = new float[CHANNEL_COUNT]; // Current PWM values from device
|
||||
private bool _sendPwmRequested;
|
||||
private bool _pwmValuesInitialized = false; // Track if we've read initial PWM values from device
|
||||
private readonly System.Collections.Generic.List<Sensor> _rpmSensors = new();
|
||||
private readonly System.Collections.Generic.List<Sensor> _controlSensors = new();
|
||||
|
||||
public ArcticFanController(HidDevice dev, ISettings settings) : base("Arctic Fan Controller", new Identifier(dev), settings)
|
||||
{
|
||||
if (dev.TryOpen(out HidStream hidStream))
|
||||
{
|
||||
// Create fan sensors (RPM monitoring) - all 10 fans have RPM feedback
|
||||
for (int i = 1; i <= CHANNEL_COUNT; i++)
|
||||
{
|
||||
var fanSensor = new Sensor($"Arctic Controller Fan {i}", i, SensorType.Fan, this, settings);
|
||||
ActivateSensor(fanSensor);
|
||||
_rpmSensors.Add(fanSensor);
|
||||
}
|
||||
|
||||
// Create control sensors - all 10 fans can be controlled
|
||||
for (int i = 1; i <= CHANNEL_COUNT; i++)
|
||||
{
|
||||
var controlSensor = new Sensor($"Arctic Controller Fan Control {i}", i, SensorType.Control, this, settings);
|
||||
Control control = new(controlSensor, settings, CONTROL_VALUE_MIN, CONTROL_VALUE_MAX);
|
||||
control.ControlModeChanged += Control_ControlModeChanged;
|
||||
control.SoftwareControlValueChanged += Control_SoftwareControlValueChanged;
|
||||
|
||||
controlSensor.Control = control;
|
||||
ActivateSensor(controlSensor); // Activate the control sensor so it appears in the UI
|
||||
_controlSensors.Add(controlSensor);
|
||||
}
|
||||
|
||||
_hidStream = hidStream;
|
||||
_hidStream.ReadTimeout = TIMEOUT_MS;
|
||||
_hidStream.WriteTimeout = TIMEOUT_MS;
|
||||
|
||||
// create thread
|
||||
_thread = new Thread(ThreadHidLoop);
|
||||
_thread.IsBackground = true; // Allow app to close even if thread is running
|
||||
_thread.Start(); // Start the thread to read RPM data
|
||||
}
|
||||
}
|
||||
|
||||
private void ThreadHidLoop()
|
||||
{
|
||||
while (_hidStream != null)
|
||||
{
|
||||
lock (_hidLock)
|
||||
{
|
||||
if (_hidStream == null) return;
|
||||
|
||||
UpdateRpmAndPwmValues();
|
||||
SendPWMUpdateIfRequired();
|
||||
}
|
||||
|
||||
Thread.Sleep(500);
|
||||
}
|
||||
}
|
||||
|
||||
private void Control_SoftwareControlValueChanged(Control control)
|
||||
{
|
||||
// need PWM update
|
||||
lock (_controlLock)
|
||||
{
|
||||
var value = control.ControlMode switch
|
||||
{
|
||||
ControlMode.Software => Math.Max(Math.Min(control.SoftwareValue, CONTROL_VALUE_MAX), CONTROL_VALUE_MIN),
|
||||
_ => CONTROL_RESET_VALUE,
|
||||
};
|
||||
|
||||
_requestedFanSpeedsPercent[control.Sensor.Index - 1] = value;
|
||||
_sendPwmRequested = true;
|
||||
}
|
||||
|
||||
// update the sensor value
|
||||
(control.Sensor as Sensor)?.Value = control.ControlMode == ControlMode.Software ? control.SoftwareValue : null;
|
||||
}
|
||||
|
||||
private void Control_ControlModeChanged(Control control)
|
||||
{
|
||||
Control_SoftwareControlValueChanged(control);
|
||||
}
|
||||
|
||||
public override HardwareType HardwareType { get; } = HardwareType.Cooler;
|
||||
|
||||
public override void Update()
|
||||
{
|
||||
lock (_hidLock)
|
||||
{
|
||||
foreach (Sensor sensor in _rpmSensors)
|
||||
{
|
||||
sensor.Value = GetRPM(sensor.Index);
|
||||
}
|
||||
|
||||
// Update control sensor values to reflect current device PWM values
|
||||
// This ensures the UI shows the actual current values from the device quickly
|
||||
foreach (Sensor sensor in _controlSensors)
|
||||
{
|
||||
int idx = sensor.Index - 1;
|
||||
if (idx >= 0 && idx < CHANNEL_COUNT &&
|
||||
// Only update if sensor doesn't have a manual value set (to avoid overwriting user input)
|
||||
sensor.Control?.ControlMode != ControlMode.Software)
|
||||
{
|
||||
// Use current device PWM values for immediate display, fallback to requested if not initialized yet
|
||||
float displayValue = _pwmValuesInitialized ? _currentDevicePwmValues[idx] : _requestedFanSpeedsPercent[idx];
|
||||
sensor.Value = displayValue;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override void Close()
|
||||
{
|
||||
lock (_hidLock)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_controlLock)
|
||||
{
|
||||
// Set all fans to 30% before closing (like JS does)
|
||||
for (int i = 0; i < CHANNEL_COUNT; i++)
|
||||
{
|
||||
_requestedFanSpeedsPercent[i] = CONTROL_RESET_VALUE;
|
||||
}
|
||||
|
||||
_sendPwmRequested = true;
|
||||
}
|
||||
|
||||
SendPWMUpdateIfRequired();
|
||||
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
_hidStream?.Close();
|
||||
_hidStream?.Dispose();
|
||||
|
||||
}
|
||||
catch { }
|
||||
// make sure stream is null so the thread can exit
|
||||
finally
|
||||
{
|
||||
_hidStream = null;
|
||||
}
|
||||
}
|
||||
|
||||
// wait for thread to finish
|
||||
_thread?.Join(1000);
|
||||
|
||||
base.Close();
|
||||
}
|
||||
|
||||
private void UpdateRpmAndPwmValues()
|
||||
{
|
||||
if (_hidStream is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_hidStream is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Try to read available data (device sends periodically)
|
||||
byte[] response = null;
|
||||
int attempts = 0;
|
||||
const int maxAttempts = 3;
|
||||
|
||||
while (attempts < maxAttempts)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Set short timeout to check if data is available
|
||||
var originalTimeout = _hidStream.ReadTimeout;
|
||||
_hidStream.ReadTimeout = 200;
|
||||
|
||||
response = new byte[PACKET_SIZE];
|
||||
int bytesRead = _hidStream.Read(response, 0, PACKET_SIZE);
|
||||
|
||||
_hidStream.ReadTimeout = originalTimeout;
|
||||
|
||||
if (bytesRead >= PACKET_SIZE && response[0] == 0x01)
|
||||
{
|
||||
// Valid response with Report ID 0x01
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// No data available yet, continue trying
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Retry on other errors
|
||||
}
|
||||
|
||||
attempts++;
|
||||
if (attempts < maxAttempts) Thread.Sleep(50);
|
||||
}
|
||||
|
||||
ProcessResponse(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"RPM update failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessResponse(byte[] response)
|
||||
{
|
||||
if (response == null || response.Length < PACKET_SIZE || response[0] != 0x01)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse current PWM values from bytes 1-10 (sent by device)
|
||||
// Format: [Report ID=0x01, PWM[1-10] (bytes 1-10), RPM[1-10] (bytes 11-30, 2 bytes each), padding]
|
||||
for (int i = 0; i < CHANNEL_COUNT; i++)
|
||||
{
|
||||
if (1 + i < response.Length)
|
||||
{
|
||||
_currentDevicePwmValues[i] = response[1 + i];
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize requested PWM values with current device values on first read
|
||||
// This prevents other fans from resetting to 0% when one fan is set to manual
|
||||
if (!_pwmValuesInitialized)
|
||||
{
|
||||
lock (_controlLock)
|
||||
{
|
||||
for (int i = 0; i < CHANNEL_COUNT; i++)
|
||||
{
|
||||
_requestedFanSpeedsPercent[i] = _currentDevicePwmValues[i];
|
||||
}
|
||||
_pwmValuesInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse RPM values from bytes 11-30 (10 RPM values as uint16 little-endian)
|
||||
for (int i = 0; i < CHANNEL_COUNT; i++)
|
||||
{
|
||||
int rpmIndex = 11 + i * 2;
|
||||
if (rpmIndex + 1 < response.Length)
|
||||
{
|
||||
int rpmLow = response[rpmIndex];
|
||||
int rpmHigh = response[rpmIndex + 1];
|
||||
_currentFanRpms[i] = rpmLow | (rpmHigh << 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SendPWMUpdateIfRequired()
|
||||
{
|
||||
float[] values = null;
|
||||
lock (_controlLock)
|
||||
{
|
||||
if (!_sendPwmRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_sendPwmRequested = false;
|
||||
values = _requestedFanSpeedsPercent.ToArray();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// New format: [Report ID=0x01, PWM[0-9] (10 bytes), padding (21 bytes)]
|
||||
byte[] pwmPacket = new byte[PACKET_SIZE];
|
||||
pwmPacket[0] = 0x01; // Report ID
|
||||
|
||||
// Set all fan speeds (bytes 1-10)
|
||||
for (int i = 0; i < CHANNEL_COUNT; i++)
|
||||
{
|
||||
pwmPacket[1 + i] = (byte)Math.Round(values[i]);
|
||||
}
|
||||
// Rest are zeros (already initialized)
|
||||
|
||||
_hidStream.Write(pwmPacket);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"PWM update failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private float GetRPM(int fanIndex)
|
||||
{
|
||||
if (fanIndex < 1 || fanIndex > CHANNEL_COUNT)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return _currentFanRpms[fanIndex - 1];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
||||
// If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
// Copyright (C) LibreHardwareMonitor and Contributors.
|
||||
// Partial Copyright (C) Michael Möller <mmoeller@openhardwaremonitor.org> and Contributors.
|
||||
// All Rights Reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using HidSharp;
|
||||
|
||||
namespace LibreHardwareMonitor.Hardware.Controller.Arctic;
|
||||
|
||||
internal class ArcticGroup : IGroup
|
||||
{
|
||||
private readonly List<IHardware> _hardware = new();
|
||||
private readonly StringBuilder _report = new();
|
||||
|
||||
private const int VendorId = 0x3904;
|
||||
private const int ProductId = 0xF001;
|
||||
|
||||
public ArcticGroup(ISettings settings)
|
||||
{
|
||||
_report.AppendLine("Arctic Hardware");
|
||||
_report.AppendLine();
|
||||
try
|
||||
{
|
||||
var devices = DeviceList.Local.GetHidDevices(VendorId, ProductId);
|
||||
var hidDevice = devices.FirstOrDefault();
|
||||
|
||||
if (hidDevice != null)
|
||||
{
|
||||
_hardware.Add(new ArcticFanController(hidDevice, settings));
|
||||
_report.AppendLine("Arctic Fan Controller initialized successfully");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_report.AppendLine($"Arctic Fan Controller Plugin initialization failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<IHardware> Hardware => _hardware;
|
||||
|
||||
public void Close()
|
||||
{
|
||||
foreach (IHardware iHardware in _hardware)
|
||||
{
|
||||
if (iHardware is Hardware hardware)
|
||||
{
|
||||
hardware.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string GetReport()
|
||||
{
|
||||
return _report.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
# Fan Controller Device Integration Guide
|
||||
|
||||
Arctic Fan Controller USB Custom HID device that accepts 10 PWM commands (0–100%) via OUT reports and returns current PWM values plus 10 RPM sensor readings (0–65535) via IN reports every ~1 second, using 32-byte reports.
|
||||
|
||||
## Protocol
|
||||
|
||||
**Interface:** USB Custom HID
|
||||
**Vendor ID (VID):** 0x3904 (14596 decimal)
|
||||
**Product ID (PID):** 0xF001 (61441 decimal)
|
||||
**Report Size:** 32 bytes
|
||||
**Report ID:** 0x01 (byte 0)
|
||||
|
||||
------------------------------------------------
|
||||
|
||||
## PWM Control (OUT Report)
|
||||
|
||||
**Format:**
|
||||
- Byte 0: Report ID = `0x01`
|
||||
- Bytes 1-10: PWM duty cycle for channels 1-10 (0-100)
|
||||
- Bytes 11-31: Ignored
|
||||
|
||||
**Specifications:**
|
||||
- 10 channels (1-10)
|
||||
- Range: 0-100 (percentage)
|
||||
|
||||
**Example:** Set channel 1 to 50%, channel 2 to 75%
|
||||
```
|
||||
[0x01, 50, 75, ...]
|
||||
```
|
||||
|
||||
------------------------------------------------
|
||||
|
||||
## RPM Reading (IN Report)
|
||||
|
||||
**Format:**
|
||||
- Byte 0: Report ID = `0x01`
|
||||
- Bytes 1-10: Current PWM values
|
||||
- Bytes 11-30: RPM values for sensors 1-10 (uint16, little-endian)
|
||||
- Sensor 1: bytes 11-12
|
||||
- Sensor 2: bytes 13-14
|
||||
- ... (2 bytes per sensor)
|
||||
- Byte 31: Padding
|
||||
|
||||
**Specifications:**
|
||||
- 10 sensors (1-10)
|
||||
- Range: 0-65535 RPM
|
||||
- Update rate: ~1 second
|
||||
|
||||
**Example:** Read sensor 1 RPM
|
||||
```
|
||||
RPM = buffer[11] + (buffer[12] << 8)
|
||||
```
|
||||
|
||||
------------------------------------------------
|
||||
|
||||
## Notes
|
||||
|
||||
- Report ID `0x01` required in all reports
|
||||
- RPM updates every ~1 second
|
||||
- PWM values take effect immediately
|
||||
- All multi-byte values are little-endian
|
||||
|
||||
|
||||
|
||||
# Complete Transaction Examples
|
||||
|
||||
### OUT Transaction (PC → Device)
|
||||
|
||||
Setting PWM: Channel 1 = 50%, Channels 2-10 = 75%
|
||||
|
||||
```
|
||||
Byte: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
|
||||
Hex: 0x01 0x32 0x4B 0x4B 0x4B 0x4B 0x4B 0x4B 0x4B 0x4B 0x4B 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
|
||||
Dec: 1 50 75 75 75 75 75 75 75 75 75 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||
└─┘ └────────────────────────────────────────────────────────┘ └────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
ID PWM Channels 1-10 (duty cycle %) Ignored
|
||||
```
|
||||
|
||||
**Explanation:**
|
||||
- Byte 0: Report ID = 1 (0x01)
|
||||
- Bytes 1-10: PWM duty cycles = 50%, 75%, 75%, 75%, 75%, 75%, 75%, 75%, 75%, 75%
|
||||
- Bytes 11-31: Ignored (all zeros)
|
||||
|
||||
---
|
||||
|
||||
### IN Transaction (Device → PC)
|
||||
|
||||
Reading current PWM and RPM values
|
||||
|
||||
```
|
||||
Byte: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
|
||||
Hex: 0x01 0x32 0x4B 0x4B 0x4B 0x4B 0x4B 0x4B 0x4B 0x4B 0x4B 0xDC 0x05 0xD0 0x07 0xD0 0x07 0xD0 0x07 0xD0 0x07 0xD0 0x07 0xD0 0x07 0xD0 0x07 0xD0 0x07 0xD0 0x07 0x00
|
||||
Dec: 1 50 75 75 75 75 75 75 75 75 75 220 5 208 7 208 7 208 7 208 7 208 7 208 7 208 7 208 7 208 7 0
|
||||
└─┘ └────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ └┘
|
||||
ID Current PWM values (Channels 1-10) RPM values (Sensors 1-10, uint16 little-endian) Padding
|
||||
```
|
||||
|
||||
**Explanation:**
|
||||
- Byte 0: Report ID = 1 (0x01)
|
||||
- Bytes 1-10: Current PWM values = 50%, 75%, 75%, 75%, 75%, 75%, 75%, 75%, 75%, 75%
|
||||
- Bytes 11-12: Sensor 1 RPM = 0x05DC = 1500 RPM (220 + 5×256)
|
||||
- Bytes 13-14: Sensor 2 RPM = 0x07D0 = 2000 RPM (208 + 7×256)
|
||||
- Bytes 15-16: Sensor 3 RPM = 0x07D0 = 2000 RPM (208 + 7×256)
|
||||
- Bytes 17-18: Sensor 4 RPM = 0x07D0 = 2000 RPM (208 + 7×256)
|
||||
- Bytes 19-20: Sensor 5 RPM = 0x07D0 = 2000 RPM (208 + 7×256)
|
||||
- Bytes 21-22: Sensor 6 RPM = 0x07D0 = 2000 RPM (208 + 7×256)
|
||||
- Bytes 23-24: Sensor 7 RPM = 0x07D0 = 2000 RPM (208 + 7×256)
|
||||
- Bytes 25-26: Sensor 8 RPM = 0x07D0 = 2000 RPM (208 + 7×256)
|
||||
- Bytes 27-28: Sensor 9 RPM = 0x07D0 = 2000 RPM (208 + 7×256)
|
||||
- Bytes 29-30: Sensor 10 RPM = 0x07D0 = 2000 RPM (208 + 7×256)
|
||||
- Byte 31: Padding = 0
|
||||
Reference in New Issue
Block a user