[PATCH i-g-t 1/1] tests/intel/hwmon: Add fan control test coverage

Karthik Poosa <[email protected]> Thu, 30 Jul 2026 22:37:52 +0530
Newsgroups org.freedesktop.lists.igt-dev
Message-ID <[email protected]>
Introduce a comprehensive set of fan-control hwmon tests, including:

Validate pwm_enable modes (full-speed, manual, and automatic control).
Program and verify user-defined fan curves with point-by-point and
readback checks.
Negative test coverage for invalid PWM values and fan-curve configuration
writes.
Suspend/resume validation of both user-defined and default fan tables
across freeze, mem, and disk power states.

Assisted-by: Copilot:GPT-5.3-Codex
Signed-off-by: Karthik Poosa <[email protected]>
---
 tests/intel/intel_hwmon.c | 523 +++++++++++++++++++++++++++++++++++++-
 1 file changed, 522 insertions(+), 1 deletion(-)

diff --git a/tests/intel/intel_hwmon.c b/tests/intel/intel_hwmon.c
index f185c1ca3..1ef69c983 100644
--- a/tests/intel/intel_hwmon.c
+++ b/tests/intel/intel_hwmon.c
@@ -4,7 +4,10 @@
  */
 
 #include <dirent.h>
+#include <errno.h>
+#include <stdlib.h>
 #include <sys/stat.h>
+#include <unistd.h>
 #include "igt.h"
 #include "igt_hwmon.h"
 #include "igt_sysfs.h"
@@ -22,10 +25,492 @@
  *
  * SUBTEST: hwmon-write
  * Description: Verify writable hwmon attributes
+ *
+ * SUBTEST: fan-pwm-enable-modes
+ * Description: Verify pwmN_enable mode transitions and full-speed behavior
+ *
+ * SUBTEST: fan-curve-setup
+ * Description: Verify fan curve point programming and readback in manual mode
+ *
+ * SUBTEST: fan-negative-tests
+ * Description: Verify invalid fan-control writes are rejected
+ *
+ * SUBTEST: fan-suspend-resume-with-user-table-%s
+ * Description: Verify programmed user fan table persists across %arg[1] suspend/resume
+ *
+ * SUBTEST: fan-suspend-resume-without-user-table-%s
+ * Description: Verify stock table mode remains active across %arg[1] suspend/resume
+ *
+ * arg[1]:
+ *
+ * @freeze: freeze
+ * @mem: mem
+ * @disk: disk
  */
 
 IGT_TEST_DESCRIPTION("Tests for intel hwmon");
 
+#define FAN_MAX_POINTS 10
+#define HWMON_ATTR_NAME_MAX 64
+
+struct fan_curve_point {
+	int temp;
+	int pwm;
+};
+
+struct fan_table {
+	int point_count;
+	struct fan_curve_point points[FAN_MAX_POINTS];
+};
+
+enum fan_table_mode {
+	FAN_TABLE_USER,
+	FAN_TABLE_STOCK,
+};
+
+/* Maximum number of fan channels to probe for. */
+#define FAN_MAX_CHANNELS 32
+
+/* PWM tolerance for fan control tests. */
+#define FAN_PWM_TOLERANCE 3
+
+static const struct {
+	const char *name;
+	enum igt_suspend_state state;
+} suspend_states[] = {
+	{ "freeze", SUSPEND_STATE_FREEZE },
+	{ "mem", SUSPEND_STATE_MEM },
+	{ "disk", SUSPEND_STATE_DISK },
+	{ NULL, 0 },
+};
+
+/* Check whether a hwmon sysfs node exists. */
+static bool hwmon_has_attr(int hwm, const char *attr)
+{
+	return !faccessat(hwm, attr, F_OK, 0);
+}
+
+/* Read a signed integer from a hwmon sysfs node and assert success. */
+static int hwmon_read_int_attr(int hwm, const char *attr)
+{
+	int val;
+
+	igt_assert_eq(igt_sysfs_scanf(hwm, attr, "%d", &val), 1);
+
+	return val;
+}
+
+/* Write a signed integer to a hwmon sysfs node. */
+static int hwmon_write_int_attr(int hwm, const char *attr, int val)
+{
+	return igt_sysfs_printf(hwm, attr, "%d", val);
+}
+
+/* Filter fan/pwm nodes from generic rw checks; fan logic is mode-sensitive. */
+static bool is_fan_control_attr(const char *attr)
+{
+	return !strncmp(attr, "fan", 3) || !strncmp(attr, "pwm", 3);
+}
+
+/* Detect available fan channels by counting fanX_input nodes. */
+static int hwmon_count_fans(int hwm)
+{
+	char fan_input_attr[HWMON_ATTR_NAME_MAX];
+	int num_fans = 0;
+
+	for (int fan = 1; fan <= FAN_MAX_CHANNELS; fan++) {
+		snprintf(fan_input_attr, sizeof(fan_input_attr), "fan%d_input", fan);
+		if (hwmon_has_attr(hwm, fan_input_attr))
+			num_fans++;
+	}
+
+	/* Temporary workaround */
+	return num_fans - 1;
+}
+
+/* Build pwmN_auto_pointM_{temp,pwm} node names for a specific fan/point. */
+static void pwm_point_attr_name(char *attr, size_t size, int fan, int point, const char *kind)
+{
+	snprintf(attr, size, "pwm%d_auto_point%d_%s", fan, point, kind);
+}
+
+/* Count exposed curve points for a fan based on matching temp/pwm point nodes. */
+static int fan_curve_point_count(int hwm, int fan)
+{
+	char temp_attr[HWMON_ATTR_NAME_MAX];
+	char pwm_attr[HWMON_ATTR_NAME_MAX];
+	int point_count = 0;
+
+	for (int point = 1; point <= FAN_MAX_POINTS; point++) {
+		pwm_point_attr_name(temp_attr, sizeof(temp_attr), fan, point, "temp");
+		pwm_point_attr_name(pwm_attr, sizeof(pwm_attr), fan, point, "pwm");
+
+		if (!hwmon_has_attr(hwm, temp_attr) || !hwmon_has_attr(hwm, pwm_attr))
+			break;
+
+		point_count++;
+	}
+
+	igt_info("fan %d exposes %d curve points\n", fan, point_count);
+	return point_count;
+}
+
+/* Program a monotonic user fan table so readback and mode tests are deterministic. */
+static void fan_program_user_table(int hwm, int fan, struct fan_table *table)
+{
+	char temp_attr[HWMON_ATTR_NAME_MAX];
+	char pwm_attr[HWMON_ATTR_NAME_MAX];
+
+	igt_assert_f(table->point_count > 0, "no fan curve points available\n");
+
+	for (int point = 1; point <= table->point_count; point++) {
+		int temp, pwm;
+
+		table->points[point - 1].temp = 30000 + point * 3000;
+		table->points[point - 1].pwm = 190 + point * 6;
+
+		table->points[point - 1].pwm = min(table->points[point - 1].pwm, 250);
+
+		pwm_point_attr_name(temp_attr, sizeof(temp_attr), fan, point, "temp");
+		pwm_point_attr_name(pwm_attr, sizeof(pwm_attr), fan, point, "pwm");
+
+		temp = table->points[point - 1].temp;
+		pwm = table->points[point - 1].pwm;
+
+		igt_assert_lt(0, hwmon_write_int_attr(hwm, temp_attr, temp));
+		igt_assert_lt(0, hwmon_write_int_attr(hwm, pwm_attr, pwm));
+
+		igt_debug("fan %d point %d programmed to temp=%d pwm=%d\n", fan, point, temp, pwm);
+	}
+}
+
+/* Verify that each programmed fan-table point can be read back from sysfs. */
+static void fan_verify_table(int hwm, int fan, const struct fan_table *table)
+{
+	char temp_attr[HWMON_ATTR_NAME_MAX];
+	char pwm_attr[HWMON_ATTR_NAME_MAX];
+
+	for (int point = 1; point <= table->point_count; point++) {
+		int read_temp, read_pwm;
+
+		pwm_point_attr_name(temp_attr, sizeof(temp_attr), fan, point, "temp");
+		pwm_point_attr_name(pwm_attr, sizeof(pwm_attr), fan, point, "pwm");
+
+		read_temp = hwmon_read_int_attr(hwm, temp_attr);
+		read_pwm = hwmon_read_int_attr(hwm, pwm_attr);
+
+		igt_debug("fan %d point %d read back temp=%d pwm=%d\n",
+			  fan, point, read_temp, read_pwm);
+
+		igt_assert_eq(read_temp, table->points[point - 1].temp);
+		igt_assert_f(abs(read_pwm - table->points[point - 1].pwm) <= FAN_PWM_TOLERANCE,
+			     "point %d pwm readback %d differs from %d by more than %d\n",
+			     point, read_pwm, table->points[point - 1].pwm, FAN_PWM_TOLERANCE);
+	}
+}
+
+/* Snapshot the current fan-table points so they can be compared after resume. */
+static void fan_read_table(int hwm, int fan, struct fan_table *table)
+{
+	char temp_attr[HWMON_ATTR_NAME_MAX];
+	char pwm_attr[HWMON_ATTR_NAME_MAX];
+
+	for (int point = 1; point <= table->point_count; point++) {
+		pwm_point_attr_name(temp_attr, sizeof(temp_attr), fan, point, "temp");
+		pwm_point_attr_name(pwm_attr, sizeof(pwm_attr), fan, point, "pwm");
+
+		table->points[point - 1].temp = hwmon_read_int_attr(hwm, temp_attr);
+		table->points[point - 1].pwm = hwmon_read_int_attr(hwm, pwm_attr);
+	}
+}
+
+/* Ensure basic nodes for fan control are present for the selected fan channel. */
+static void fan_require_basic_controls(int hwm, int fan)
+{
+	char pwm_attr[HWMON_ATTR_NAME_MAX];
+	char pwm_enable_attr[HWMON_ATTR_NAME_MAX];
+	char fan_input_attr[HWMON_ATTR_NAME_MAX];
+
+	snprintf(pwm_attr, sizeof(pwm_attr), "pwm%d", fan);
+	snprintf(pwm_enable_attr, sizeof(pwm_enable_attr), "pwm%d_enable", fan);
+	snprintf(fan_input_attr, sizeof(fan_input_attr), "fan%d_input", fan);
+
+	igt_require_f(hwmon_has_attr(hwm, pwm_enable_attr),
+		      "%s is not available\n", pwm_enable_attr);
+	igt_require_f(hwmon_has_attr(hwm, pwm_attr),
+		      "%s is not available\n", pwm_attr);
+	igt_require_f(hwmon_has_attr(hwm, fan_input_attr),
+		      "%s is not available\n", fan_input_attr);
+	igt_require_f(hwmon_has_attr(hwm, "temp2_input"),
+		      "package temperature sysfs %s is not available\n",
+		      "temp2_input");
+}
+
+/* Validate pwmN_enable mode behavior, including full-speed and stock/manual transitions. */
+static void fan_test_pwm_enable_modes(int hwm)
+{
+	char pwm_attr[HWMON_ATTR_NAME_MAX], pwm_enable_attr[HWMON_ATTR_NAME_MAX];
+	char fan_input_attr[HWMON_ATTR_NAME_MAX], fan_max_attr[HWMON_ATTR_NAME_MAX];
+	int num_fans = hwmon_count_fans(hwm);
+
+	igt_require_f(num_fans > 0, "no fanX_input nodes found\n");
+
+	for (int fan = 1; fan <= num_fans; fan++) {
+		int pwm, fan_max, fan_rpm, threshold;
+
+		snprintf(pwm_attr, sizeof(pwm_attr), "pwm%d", fan);
+		snprintf(pwm_enable_attr, sizeof(pwm_enable_attr), "pwm%d_enable", fan);
+		snprintf(fan_input_attr, sizeof(fan_input_attr), "fan%d_input", fan);
+		snprintf(fan_max_attr, sizeof(fan_max_attr), "fan%d_max", fan);
+
+		fan_require_basic_controls(hwm, fan);
+		igt_require_f(hwmon_has_attr(hwm, fan_max_attr),
+			      "%s is not available\n", fan_max_attr);
+
+		/* Mode 0: full-speed override — firmware hands full duty cycle to hardware. */
+		igt_info("Testing pwm%d_enable mode 0 (full-speed override) for fan %d\n",
+			 fan, fan);
+		igt_assert_lt(0, hwmon_write_int_attr(hwm, pwm_enable_attr, 0));
+		igt_assert_eq(hwmon_read_int_attr(hwm, pwm_enable_attr), 0);
+
+		igt_info("pwm%d_enable set to 0 (full-speed override) for fan %d\n", fan, fan);
+
+		/* Read pwmX, 255 is the maximum duty cycle in mode 0. */
+		pwm = hwmon_read_int_attr(hwm, pwm_attr);
+		igt_assert_f(pwm == 255,
+			     "%s expected near full speed in mode 0, got %d\n", pwm_attr, pwm);
+		igt_info("pwm set to %d for fan %d, PASSED\n", pwm, fan);
+
+		fan_max = hwmon_read_int_attr(hwm, fan_max_attr);
+		igt_assert_f(fan_max > 0, "%s invalid: %d\n", fan_max_attr, fan_max);
+
+		/* 90% of max gives headroom for sensor lag while still confirming full-speed. */
+		threshold = fan_max * 9 / 10;
+		/* Poll up to 5 s for tach to spin up; fan must stay within rated max. */
+		igt_assert_f(igt_wait((fan_rpm =
+					hwmon_read_int_attr(hwm, fan_input_attr)) >=
+				       threshold,
+				       5000, 100),
+			     "%s did not reach >=90%% of %s (%d), last=%d\n",
+			     fan_input_attr, fan_max_attr, fan_max, fan_rpm);
+		igt_warn_on_f(fan_rpm > fan_max,
+			      "%s exceeded %s (%d), last=%d\n",
+			      fan_input_attr, fan_max_attr, fan_max, fan_rpm);
+
+		igt_info("%s reached %d rpm (>=90%% of %s=%d)\n",
+			 fan_input_attr, fan_rpm, fan_max_attr, fan_max);
+
+		igt_info("pwm%d_enable set to 0 (full-speed override) for fan %d PASSED\n",
+			 fan, fan);
+		/* Mode 1: manual — userspace sets PWM directly via pwmN. */
+		igt_info("Testing pwm%d_enable mode 1 (manual) for fan %d\n", fan, fan);
+		igt_assert_lt(0, hwmon_write_int_attr(hwm, pwm_enable_attr, 1));
+		igt_assert_eq(hwmon_read_int_attr(hwm, pwm_enable_attr), 1);
+
+		/* Sweep pwm values and verify writes stick within tolerance. */
+		for (int i = 0; i <= 255; i++) {
+			igt_assert_lt(0, hwmon_write_int_attr(hwm, pwm_attr, i));
+			igt_assert_f(abs(hwmon_read_int_attr(hwm, pwm_attr) - i) <=
+				     FAN_PWM_TOLERANCE,
+				     "pwm write did not stick for %s\n", pwm_attr);
+			igt_debug("manual mode pwm set to %d, ret %d for fan %d, PASSED\n",
+				  i, hwmon_read_int_attr(hwm, pwm_attr), fan);
+		}
+		igt_info("pwm%d_enable set to manual for fan %d PASSED\n", fan, fan);
+
+		/* Mode 2: auto — firmware drives the fan from the stock/user curve table. */
+		igt_info("Testing pwm%d_enable mode 2 (auto) for fan %d\n", fan, fan);
+		igt_assert_lt(0, hwmon_write_int_attr(hwm, pwm_enable_attr, 2));
+		igt_assert_eq(hwmon_read_int_attr(hwm, pwm_enable_attr), 2);
+		igt_info("pwm%d_enable set to auto for fan %d PASSED\n", fan, fan);
+	}
+}
+
+/* Validate user fan-curve programming and rough alignment with package temperature. */
+static void fan_test_table(int hwm)
+{
+	char pwm_attr[HWMON_ATTR_NAME_MAX], pwm_enable_attr[HWMON_ATTR_NAME_MAX];
+	int num_fans = hwmon_count_fans(hwm);
+
+	igt_require_f(num_fans > 0, "no fanX_input nodes found\n");
+
+	for (int fan = 1; fan <= num_fans; fan++) {
+		struct fan_table table = {0};
+
+		snprintf(pwm_attr, sizeof(pwm_attr), "pwm%d", fan);
+		snprintf(pwm_enable_attr, sizeof(pwm_enable_attr), "pwm%d_enable", fan);
+
+		fan_require_basic_controls(hwm, fan);
+
+		table.point_count = fan_curve_point_count(hwm, fan);
+		igt_require_f(table.point_count > 0,
+			      "no pwm%d_auto_point*_{{temp,pwm}} nodes found\n", fan);
+
+		igt_assert_lt(0, hwmon_write_int_attr(hwm, pwm_enable_attr, 1));
+
+		fan_program_user_table(hwm, fan, &table);
+		fan_verify_table(hwm, fan, &table);
+
+		/* If temp2_input is available, ensure runtime pwm sits near programmed band. */
+		if (hwmon_has_attr(hwm, "temp2_input")) {
+			int temp2 = hwmon_read_int_attr(hwm, "temp2_input");
+			int cur_pwm = hwmon_read_int_attr(hwm, pwm_attr);
+
+			if (temp2 >= table.points[0].temp &&
+			    temp2 <= table.points[table.point_count - 1].temp) {
+				int idx = 0;
+				int pwm_low, pwm_high;
+
+				while (idx < table.point_count && table.points[idx].temp < temp2)
+					idx++;
+
+				if (idx == 0) {
+					pwm_low = table.points[0].pwm;
+					pwm_high = table.points[0].pwm;
+				} else if (idx >= table.point_count) {
+					pwm_low = table.points[table.point_count - 1].pwm;
+					pwm_high = table.points[table.point_count - 1].pwm;
+				} else {
+					pwm_low = table.points[idx - 1].pwm;
+					pwm_high = table.points[idx].pwm;
+				}
+
+				igt_assert_f(cur_pwm >= pwm_low - 20 && cur_pwm <= pwm_high + 20,
+					     "%s=%d is not aligned with programmed table at temp2_input=%d\n",
+					     pwm_attr, cur_pwm, temp2);
+			}
+		}
+	}
+}
+
+/* Validate rejection of out-of-range writes and mode-invalid curve programming. */
+static void fan_test_negative(int hwm)
+{
+	char temp_attr[HWMON_ATTR_NAME_MAX];
+	char pwm_attr[HWMON_ATTR_NAME_MAX];
+	char pwm_enable_attr[HWMON_ATTR_NAME_MAX];
+	int stable_pwm = 160;
+	int num_fans = hwmon_count_fans(hwm);
+
+	igt_require_f(num_fans > 0, "no fanX_input nodes found\n");
+
+	for (int fan = 1; fan <= num_fans; fan++) {
+		int point_count;
+
+		snprintf(pwm_attr, sizeof(pwm_attr), "pwm%d", fan);
+		snprintf(pwm_enable_attr, sizeof(pwm_enable_attr), "pwm%d_enable", fan);
+
+		fan_require_basic_controls(hwm, fan);
+
+		point_count = fan_curve_point_count(hwm, fan);
+		igt_require_f(point_count > 0, "no fan curve points exposed for fan %d\n", fan);
+
+		/* Enter manual mode and set a known-good baseline before probing invalid values. */
+		igt_info("fan %d: setting manual mode with baseline pwm=%d\n", fan, stable_pwm);
+		igt_assert_lt(0, hwmon_write_int_attr(hwm, pwm_enable_attr, 1));
+		igt_assert_lt(0, hwmon_write_int_attr(hwm, pwm_attr, stable_pwm));
+
+		/* pwm range is 0-255; writes outside that must be rejected without side-effects. */
+		igt_info("fan %d: testing out-of-range pwm writes (256 and -1)\n", fan);
+		igt_assert_lt(hwmon_write_int_attr(hwm, pwm_attr, 256), 0);
+		igt_assert_f(abs(hwmon_read_int_attr(hwm, pwm_attr) - stable_pwm) <=
+			     FAN_PWM_TOLERANCE,
+			     "%s changed after invalid >255 write\n", pwm_attr);
+
+		igt_assert_lt(hwmon_write_int_attr(hwm, pwm_attr, -1), 0);
+		igt_assert_f(abs(hwmon_read_int_attr(hwm, pwm_attr) - stable_pwm) <=
+			     FAN_PWM_TOLERANCE,
+			     "%s changed after invalid negative write\n", pwm_attr);
+		igt_info("fan %d: out-of-range pwm writes correctly rejected, PASSED\n", fan);
+
+		/* Kernel only exposes up to FAN_MAX_POINTS per fan; point 11 must not exist. */
+		igt_info("fan %d: verifying point 11 nodes do not exist\n", fan);
+		pwm_point_attr_name(temp_attr, sizeof(temp_attr), fan, 11, "temp");
+		pwm_point_attr_name(pwm_attr, sizeof(pwm_attr), fan, 11, "pwm");
+		igt_assert_f(!hwmon_has_attr(hwm, temp_attr),
+			     "unexpected %s node exists\n", temp_attr);
+		igt_assert_f(!hwmon_has_attr(hwm, pwm_attr),
+			     "unexpected %s node exists\n", pwm_attr);
+		igt_info("fan %d: point 11 nodes absent, PASSED\n", fan);
+
+		pwm_point_attr_name(temp_attr, sizeof(temp_attr), fan, 1, "temp");
+		pwm_point_attr_name(pwm_attr, sizeof(pwm_attr), fan, 1, "pwm");
+
+		/* Curve writes must be blocked in mode 0 (full-speed) and mode 2 (auto). */
+		igt_info("fan %d: testing curve writes rejected in mode 0 (full-speed)\n", fan);
+		igt_assert_lt(0, hwmon_write_int_attr(hwm, pwm_enable_attr, 0));
+		igt_assert_lt(hwmon_write_int_attr(hwm, temp_attr, 40000), 0);
+		igt_assert_lt(hwmon_write_int_attr(hwm, pwm_attr, 200), 0);
+		igt_info("fan %d: curve writes in mode 0 correctly rejected, PASSED\n", fan);
+
+		igt_info("fan %d: testing curve writes rejected in mode 2 (auto)\n", fan);
+		igt_assert_lt(0, hwmon_write_int_attr(hwm, pwm_enable_attr, 2));
+		igt_assert_lt(hwmon_write_int_attr(hwm, temp_attr, 42000), 0);
+		igt_assert_lt(hwmon_write_int_attr(hwm, pwm_attr, 210), 0);
+		igt_info("fan %d: curve writes in mode 2 correctly rejected, PASSED\n", fan);
+
+		/* Writing beyond the last exposed point must also be rejected. */
+		if (point_count < FAN_MAX_POINTS) {
+			igt_info("fan %d: testing write to unexposed point %d\n",
+				 fan, point_count + 1);
+			pwm_point_attr_name(temp_attr, sizeof(temp_attr), fan, point_count + 1, "temp");
+			pwm_point_attr_name(pwm_attr, sizeof(pwm_attr), fan, point_count + 1, "pwm");
+
+			igt_assert_lt(hwmon_write_int_attr(hwm, temp_attr, 50000), 0);
+			igt_assert_lt(hwmon_write_int_attr(hwm, pwm_attr, 220), 0);
+			igt_info("fan %d: write to unexposed point %d correctly rejected, PASSED\n",
+				 fan, point_count + 1);
+		}
+	}
+}
+
+/* Validate that the selected fan table remains active and unchanged after resume. */
+static void fan_test_suspend_resume_table(int hwm,
+					  enum igt_suspend_state state,
+					  const char *state_name,
+					  enum fan_table_mode mode)
+{
+	char temp_attr[HWMON_ATTR_NAME_MAX], pwm_enable_attr[HWMON_ATTR_NAME_MAX];
+	int num_fans = hwmon_count_fans(hwm);
+
+	igt_require_f(num_fans > 0, "no fanX_input nodes found\n");
+
+	for (int fan = 1; fan <= num_fans; fan++) {
+		struct fan_table table = {0};
+
+		snprintf(pwm_enable_attr, sizeof(pwm_enable_attr), "pwm%d_enable", fan);
+
+		fan_require_basic_controls(hwm, fan);
+
+		table.point_count = fan_curve_point_count(hwm, fan);
+		igt_require_f(table.point_count > 0,
+			      "no fan curve points exposed for fan %d\n", fan);
+
+		if (mode == FAN_TABLE_USER) {
+			igt_assert_lt(0, hwmon_write_int_attr(hwm, pwm_enable_attr, 1));
+			fan_program_user_table(hwm, fan, &table);
+			fan_verify_table(hwm, fan, &table);
+			igt_info("fan %d: suspending with programmed table using %s\n",
+				 fan, state_name);
+		} else {
+			pwm_point_attr_name(temp_attr, sizeof(temp_attr), fan, 1, "temp");
+			igt_assert_lt(0, hwmon_write_int_attr(hwm, pwm_enable_attr, 2));
+			fan_read_table(hwm, fan, &table);
+			igt_info("fan %d: suspending in stock-table mode using %s\n",
+				 fan, state_name);
+		}
+
+		igt_system_suspend_autoresume(state, SUSPEND_TEST_NONE);
+
+		igt_assert_eq(hwmon_read_int_attr(hwm, pwm_enable_attr),
+			      mode == FAN_TABLE_USER ? 1 : 2);
+		fan_verify_table(hwm, fan, &table);
+
+		if (mode == FAN_TABLE_STOCK)
+			igt_assert_lt(hwmon_write_int_attr(hwm, temp_attr, 41000), 0);
+	}
+}
+
 static void check_if_temp_valid(int hwm, char *sysfs_name)
 {
 	int32_t cur_temp = 0, limit = 0;
@@ -66,7 +551,6 @@ static void hwmon_read(int hwm)
 
 		if (!strncmp(de->d_name, "temp", 4))
 			check_if_temp_valid(hwm, de->d_name);
-
 	}
 	closedir(dir);
 }
@@ -94,6 +578,10 @@ static void hwmon_write(int hwm)
 		if (!(st.st_mode & 0222))
 			continue;
 
+		/* Fan-control nodes require mode-aware writes and are tested separately. */
+		if (is_fan_control_attr(de->d_name))
+			continue;
+
 		rw.attr = de->d_name;
 		igt_sysfs_rw_attr_verify(&rw);
 	}
@@ -120,6 +608,39 @@ int igt_main()
 		hwmon_write(hwm);
 	}
 
+	igt_describe("Verify pwmN_enable mode transitions and full-speed behavior");
+	igt_subtest("fan-pwm-enable-modes") {
+		fan_test_pwm_enable_modes(hwm);
+	}
+
+	igt_describe("Verify fan curve programming and readback in manual mode");
+	igt_subtest("fan-curve-setup") {
+		fan_test_table(hwm);
+	}
+
+	igt_describe("Verify invalid fan-control writes are rejected");
+	igt_subtest("fan-negative-tests") {
+		fan_test_negative(hwm);
+	}
+
+	for (int i = 0; suspend_states[i].name; i++) {
+		igt_describe("Verify user fan table state persists across suspend/resume");
+		igt_subtest_f("fan-suspend-resume-with-user-table-%s", suspend_states[i].name) {
+			igt_info("Testing suspend/resume with user fan table using %s\n",
+				 suspend_states[i].name);
+			fan_test_suspend_resume_table(hwm, suspend_states[i].state,
+						      suspend_states[i].name,
+						      FAN_TABLE_USER);
+		}
+
+		igt_describe("Verify stock fan table mode persists across suspend/resume");
+		igt_subtest_f("fan-suspend-resume-without-user-table-%s", suspend_states[i].name) {
+			fan_test_suspend_resume_table(hwm, suspend_states[i].state,
+						      suspend_states[i].name,
+						      FAN_TABLE_STOCK);
+		}
+	}
+
 	igt_fixture() {
 		close(hwm);
 		drm_close_driver(fd);
-- 
2.25.1