[PATCH] hwmon: add driver for ARCTIC Fan Controller

From: Aureo Serrano

Date: Tue Mar 03 2026 - 03:30:41 EST


From 1cc962124ca4343e682219372b08dec5d611d1af Mon Sep 17 00:00:00 2001
From: Aureo Serrano de Souza <aureo.serrano@xxxxxxxxx>
Date: Tue, 3 Mar 2026 15:06:35 +0800
Subject: [PATCH] hwmon: add driver for ARCTIC Fan Controller

Add hwmon driver for the ARCTIC Fan Controller (USB HID VID 0x3904,
PID 0xF001) with 10 fan channels. Exposes fan RPM and PWM via sysfs.
Device pushes IN reports ~1 Hz; PWM set via OUT reports.

Signed-off-by: Aureo Serrano de Souza <aureo.serrano@xxxxxxxxx>
---
 Documentation/hwmon/arctic_fan_controller.rst |  33 ++
 drivers/hwmon/Kconfig                         |  12 +
 drivers/hwmon/Makefile                        |   1 +
 drivers/hwmon/arctic_fan_controller.c         | 297 ++++++++++++++++++
 4 files changed, 343 insertions(+)
 create mode 100644 Documentation/hwmon/arctic_fan_controller.rst
 create mode 100644 drivers/hwmon/arctic_fan_controller.c

diff --git a/Documentation/hwmon/arctic_fan_controller.rst b/Documentation/hwmon/arctic_fan_controller.rst
new file mode 100644
index 0000000..fc4ebd4
--- /dev/null
+++ b/Documentation/hwmon/arctic_fan_controller.rst
@@ -0,0 +1,33 @@
+.. SPDX-License-Identifier: GPL-2.0-or-later
+
+Kernel driver arctic_fan_controller
+===================================
+
+Supported devices:
+
+* ARCTIC Fan Controller (USB HID, VID 0x3904, PID 0xF001)
+
+Author: Aureo Serrano de Souza <aureo.serrano@xxxxxxxxx>
+
+Description
+-----------
+
+This driver provides hwmon support for the ARCTIC Fan Controller, a USB Custom HID
+device with 10 fan channels. The device sends IN reports about once per second
+containing current PWM (bytes 1–10) and RPM (bytes 11–30). PWM is set via OUT reports
+(bytes 1–10, 0–100% per channel).
+
+Usage notes
+-----------
+
+Since it is a USB device, hotplug is supported. The device is autodetected.
+
+Sysfs entries
+-------------
+
+================ ===============================================================
+fan[1-10]_input   Fan speed in RPM (read-only, from device IN reports).
+pwm[1-10]         PWM duty cycle. Sysfs uses 0–255 (0%–100%); the device uses
+                  0–100% internally. Read: current duty from IN report (scaled
+                  to 0–255). Write: set duty via OUT report (value 0–255).
+================ ===============================================================
diff --git a/drivers/hwmon/Kconfig b/drivers/hwmon/Kconfig
index ea48ea9..e4b9f48 100644
--- a/drivers/hwmon/Kconfig
+++ b/drivers/hwmon/Kconfig
@@ -368,6 +368,18 @@ config SENSORS_APPLESMC
        Say Y here if you have an applicable laptop and want to experience
        the awesome power of applesmc.
 
+config SENSORS_ARCTIC_FAN_CONTROLLER
+     tristate "ARCTIC Fan Controller"
+     depends on HID
+     help
+       If you say yes here you get support for the ARCTIC Fan Controller,
+       a USB HID device (VID 0x3904, PID 0xF001) with 10 fan channels.
+       The driver exposes fan speed (RPM) and PWM control via the hwmon
+       sysfs interface.
+
+       This driver can also be built as a module. If so, the module
+       will be called arctic_fan_controller.
+
 config SENSORS_ARM_SCMI
      tristate "ARM SCMI Sensors"
      depends on ARM_SCMI_PROTOCOL
diff --git a/drivers/hwmon/Makefile b/drivers/hwmon/Makefile
index d239746..6b12cb1 100644
--- a/drivers/hwmon/Makefile
+++ b/drivers/hwmon/Makefile
@@ -47,6 +47,7 @@ obj-$(CONFIG_SENSORS_ADT7475) += adt7475.o
 obj-$(CONFIG_SENSORS_AHT10)  += aht10.o
 obj-$(CONFIG_SENSORS_APPLESMC)     += applesmc.o
 obj-$(CONFIG_SENSORS_AQUACOMPUTER_D5NEXT) += aquacomputer_d5next.o
+obj-$(CONFIG_SENSORS_ARCTIC_FAN_CONTROLLER) += arctic_fan_controller.o
 obj-$(CONFIG_SENSORS_ARM_SCMI)     += scmi-hwmon.o
 obj-$(CONFIG_SENSORS_ARM_SCPI)     += scpi-hwmon.o
 obj-$(CONFIG_SENSORS_AS370)  += as370-hwmon.o
diff --git a/drivers/hwmon/arctic_fan_controller.c b/drivers/hwmon/arctic_fan_controller.c
new file mode 100644
index 0000000..0738d41
--- /dev/null
+++ b/drivers/hwmon/arctic_fan_controller.c
@@ -0,0 +1,297 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/*
+ * Linux hwmon driver for ARCTIC Fan Controller
+ *
+ * USB Custom HID device (VID 0x3904, PID 0xF001) with 10 fan channels.
+ * Exposes fan RPM (input) and PWM (0-255) via hwmon. Device pushes IN reports
+ * at ~1 Hz; no GET_REPORT. OUT reports set PWM duty (bytes 1-10, 0-100%).
+ */
+
+#include <linux/delay.h>
+#include <linux/err.h>
+#include <linux/hid.h>
+#include <linux/hwmon.h>
+#include <linux/hwmon-sysfs.h>
+#include <linux/kernel.h>
+#include <linux/module.h>
+#include <linux/spinlock.h>
+
+#define ARCTIC_VID           0x3904
+#define ARCTIC_PID           0xF001
+#define ARCTIC_NUM_FANS            10
+#define ARCTIC_REPORT_ID     0x01
+#define ARCTIC_REPORT_LEN    32
+#define ARCTIC_RPM_OFFSET    11    /* bytes 11-30: 10 x uint16 LE */
+
+static int arctic_fan_debug;
+module_param_named(debug, arctic_fan_debug, int, 0644);
+MODULE_PARM_DESC(debug, "Enable debug prints (0=off, 1=on)");
+
+struct arctic_fan_data {
+     struct hid_device *hdev;
+     struct device *hwmon_dev;
+     spinlock_t lock;        /* protects fan_rpm, pwm_duty, out_buf payload */
+     u8 *out_buf;
+     u32 fan_rpm[ARCTIC_NUM_FANS];
+     u8 pwm_duty[ARCTIC_NUM_FANS];
+};
+
+static const struct hid_device_id arctic_fan_id_table[] = {
+     { HID_USB_DEVICE(ARCTIC_VID, ARCTIC_PID) },
+     { }
+};
+MODULE_DEVICE_TABLE(hid, arctic_fan_id_table);
+
+/*
+ * Parse report buffer: PWM at pwm_off (10 bytes 0-100), RPM at rpm_off
+ * (10 x uint16 LE). Called from raw_event in atomic context; must use
+ * spinlock only.
+ */
+static void arctic_fan_parse_report(struct arctic_fan_data *priv, u8 *buf,
+                           int len, int pwm_off, int rpm_off)
+{
+     int i;
+     unsigned long flags;
+
+     if (len < rpm_off + 20 || pwm_off + 10 > len)
+           return;
+     spin_lock_irqsave(&priv->lock, flags);
+     for (i = 0; i < ARCTIC_NUM_FANS; i++) {
+           u8 d = buf[pwm_off + i];
+
+           priv->pwm_duty[i] = d <= 100 ? d : 0;
+     }
+     for (i = 0; i < ARCTIC_NUM_FANS; i++) {
+           priv->fan_rpm[i] = (u32)(buf[rpm_off + i * 2] |
+                             (buf[rpm_off + i * 2 + 1] << 8));
+     }
+     spin_unlock_irqrestore(&priv->lock, flags);
+     if (arctic_fan_debug)
+           hid_dbg(priv->hdev, "arctic_fan: fan1=%u pwm1=%u\n",
+                 (unsigned int)priv->fan_rpm[0], (unsigned int)priv->pwm_duty[0]);
+}
+
+/*
+ * raw_event: IN report id 0x01 len 32 (PWM 1-10, RPM 11-30) or id 0 len 31
+ * (PWM 0-9, RPM 10-29). No GET_REPORT support; device pushes only.
+ */
+static int arctic_fan_raw_event(struct hid_device *hdev,
+                       struct hid_report *report, u8 *data, int size)
+{
+     struct arctic_fan_data *priv;
+     int rpm_off;
+     bool match = false;
+
+     if (report->id == ARCTIC_REPORT_ID) {
+           if (size == ARCTIC_REPORT_LEN) {
+                 rpm_off = ARCTIC_RPM_OFFSET;
+                 match = true;
+           } else if (size == ARCTIC_REPORT_LEN - 1) {
+                 rpm_off = ARCTIC_RPM_OFFSET - 1;
+                 match = true;
+           }
+     } else if (report->id == 0 && size >= 31) {
+           rpm_off = 10;
+           match = true;
+     }
+     if (!match) {
+           if (arctic_fan_debug)
+                 hid_dbg(hdev, "arctic_fan: raw_event id=%u size=%d ignored\n",
+                       report->id, size);
+           return 0;
+     }
+     priv = hid_get_drvdata(hdev);
+     if (!priv)
+           return 0;
+     if (arctic_fan_debug)
+           hid_dbg(hdev, "arctic_fan: raw_event id=%u size=%d\n",
+                 report->id, size);
+     arctic_fan_parse_report(priv, data, size, rpm_off - 10, rpm_off);
+     return 0;
+}
+
+static umode_t arctic_fan_is_visible(const void *data,
+                            enum hwmon_sensor_types type,
+                            u32 attr, int channel)
+{
+     if (channel < 0 || channel >= ARCTIC_NUM_FANS)
+           return 0;
+     if (type == hwmon_fan && attr == hwmon_fan_input)
+           return 0444;
+     if (type == hwmon_pwm && attr == hwmon_pwm_input)
+           return 0644;
+     return 0;
+}
+
+static int arctic_fan_read(struct device *dev, enum hwmon_sensor_types type,
+                    u32 attr, int channel, long *val)
+{
+     struct arctic_fan_data *priv = dev_get_drvdata(dev);
+     unsigned long flags;
+
+     if (channel < 0 || channel >= ARCTIC_NUM_FANS)
+           return -EINVAL;
+     spin_lock_irqsave(&priv->lock, flags);
+     if (type == hwmon_fan && attr == hwmon_fan_input) {
+           *val = priv->fan_rpm[channel];
+     } else if (type == hwmon_pwm && attr == hwmon_pwm_input) {
+           *val = (long)priv->pwm_duty[channel] * 255 / 100;
+     } else {
+           spin_unlock_irqrestore(&priv->lock, flags);
+           return -EINVAL;
+     }
+     spin_unlock_irqrestore(&priv->lock, flags);
+     return 0;
+}
+
+/* Send OUT report; buf[0]=0x01, buf[1..10]=PWM 0-100. May sleep. */
+static int arctic_fan_send_report(struct hid_device *hdev, u8 *buf)
+{
+     int i, ret;
+     const int max_retries = 8;
+
+     for (i = 0; i < max_retries; i++) {
+           ret = hid_hw_output_report(hdev, buf, ARCTIC_REPORT_LEN);
+           if (ret >= 0)
+                 return ret;
+           if (ret != -EAGAIN)
+                 break;      /* e.g. -ENOSYS when transport lacks output_report */
+           msleep(25);
+     }
+     for (i = 0; i < max_retries; i++) {
+           ret = hid_hw_raw_request(hdev, ARCTIC_REPORT_ID, buf,
+                              ARCTIC_REPORT_LEN, HID_OUTPUT_REPORT,
+                              HID_REQ_SET_REPORT);
+           if (ret != -EAGAIN)
+                 return ret;
+           msleep(25);
+     }
+     return -EAGAIN;
+}
+
+static int arctic_fan_write(struct device *dev, enum hwmon_sensor_types type,
+                     u32 attr, int channel, long val)
+{
+     struct arctic_fan_data *priv = dev_get_drvdata(dev);
+     unsigned long flags;
+     int i, ret;
+     u8 duty;
+
+     if (channel < 0 || channel >= ARCTIC_NUM_FANS)
+           return -EINVAL;
+     if (type != hwmon_pwm || attr != hwmon_pwm_input)
+           return -EINVAL;
+     if (val < 0 || val > 255)
+           return -EINVAL;
+     duty = DIV_ROUND_CLOSEST(val * 100, 255);
+     if (duty > 100)
+           duty = 100;
+     if (!priv->out_buf)
+           return -ENOMEM;
+     spin_lock_irqsave(&priv->lock, flags);
+     priv->pwm_duty[channel] = duty;
+     priv->out_buf[0] = ARCTIC_REPORT_ID;
+     for (i = 0; i < ARCTIC_NUM_FANS; i++)
+           priv->out_buf[1 + i] = priv->pwm_duty[i] <= 100 ?
+                             priv->pwm_duty[i] : 0;
+     memset(priv->out_buf + 11, 0, ARCTIC_REPORT_LEN - 11);
+     spin_unlock_irqrestore(&priv->lock, flags);
+     ret = arctic_fan_send_report(priv->hdev, priv->out_buf);
+     return ret < 0 ? ret : 0;
+}
+
+static const struct hwmon_ops arctic_fan_ops = {
+     .is_visible = arctic_fan_is_visible,
+     .read = arctic_fan_read,
+     .write = arctic_fan_write,
+};
+
+static const struct hwmon_channel_info *arctic_fan_info[] = {
+     HWMON_CHANNEL_INFO(fan,
+                    HWMON_F_INPUT, HWMON_F_INPUT, HWMON_F_INPUT,
+                    HWMON_F_INPUT, HWMON_F_INPUT, HWMON_F_INPUT,
+                    HWMON_F_INPUT, HWMON_F_INPUT, HWMON_F_INPUT,
+                    HWMON_F_INPUT),
+     HWMON_CHANNEL_INFO(pwm,
+                    HWMON_PWM_INPUT, HWMON_PWM_INPUT, HWMON_PWM_INPUT,
+                    HWMON_PWM_INPUT, HWMON_PWM_INPUT, HWMON_PWM_INPUT,
+                    HWMON_PWM_INPUT, HWMON_PWM_INPUT, HWMON_PWM_INPUT,
+                    HWMON_PWM_INPUT),
+     NULL
+};
+
+static const struct hwmon_chip_info arctic_fan_chip_info = {
+     .ops = &arctic_fan_ops,
+     .info = arctic_fan_info,
+};
+
+static int arctic_fan_probe(struct hid_device *hdev,
+                     const struct hid_device_id *id)
+{
+     struct arctic_fan_data *priv;
+     struct device *hwmon_dev;
+     int ret;
+
+     ret = hid_parse(hdev);
+     if (ret)
+           return ret;
+
+     priv = devm_kzalloc(&hdev->dev, sizeof(*priv), GFP_KERNEL);
+     if (!priv)
+           return -ENOMEM;
+
+     priv->out_buf = devm_kmalloc(&hdev->dev, ARCTIC_REPORT_LEN, GFP_KERNEL);
+     if (!priv->out_buf)
+           return -ENOMEM;
+
+     priv->hdev = hdev;
+     spin_lock_init(&priv->lock);
+     hid_set_drvdata(hdev, priv);
+
+     ret = hid_hw_start(hdev, HID_CONNECT_DRIVER);
+     if (ret)
+           return ret;
+
+     ret = hid_hw_open(hdev);
+     if (ret)
+           goto out_stop;
+
+     hid_device_io_start(hdev);
+
+     hwmon_dev = devm_hwmon_device_register_with_info(&hdev->dev, "arctic_fan",
+                                          priv, &arctic_fan_chip_info,
+                                          NULL);
+     if (IS_ERR(hwmon_dev)) {
+           ret = PTR_ERR(hwmon_dev);
+           goto out_close;
+     }
+     priv->hwmon_dev = hwmon_dev;
+     return 0;
+
+out_close:
+     hid_hw_close(hdev);
+out_stop:
+     hid_hw_stop(hdev);
+     return ret;
+}
+
+static void arctic_fan_remove(struct hid_device *hdev)
+{
+     hid_device_io_stop(hdev);
+     hid_hw_close(hdev);
+     hid_hw_stop(hdev);
+}
+
+static struct hid_driver arctic_fan_driver = {
+     .name = "arctic_fan",
+     .id_table = arctic_fan_id_table,
+     .probe = arctic_fan_probe,
+     .remove = arctic_fan_remove,
+     .raw_event = arctic_fan_raw_event,
+};
+
+module_hid_driver(arctic_fan_driver);
+
+MODULE_AUTHOR("Aureo Serrano de Souza <aureo.serrano@xxxxxxxxx>");
+MODULE_DESCRIPTION("HID hwmon driver for ARCTIC Fan Controller (VID 0x3904, PID 0xF001)");
+MODULE_LICENSE("GPL");
-- 
2.25.1