Skip to content

feat(fan): read hwmon directly — dual-fan support and simulated device tests - #31

Closed
vietanhdev wants to merge 2 commits into
mainfrom
feat/multi-fan-hwmon
Closed

feat(fan): read hwmon directly — dual-fan support and simulated device tests#31
vietanhdev wants to merge 2 commits into
mainfrom
feat/multi-fan-hwmon

Conversation

@vietanhdev

Copy link
Copy Markdown
Owner

Dual-fan ThinkPads (P1, P15, X1 Extreme) had a second fan the app could not see. /proc/acpi/ibm/fan has a speed: field and no speed2:, so hwmon is the only way to read it.

Verified live on a P1 Gen 4i: two tachometers at 3813 and 3515 RPM, one control channel.

The structural point

Tachometers and control channels are not one-to-one. thinkpad_acpi writes the same level to both fans, so a dual-fan machine has 2 tachs and 1 PWM. Code assuming one fan per channel is wrong on every P-series — and anything offering per-fan control would be lying about the hardware.

Replaces the iwlwifi_1-virtual-0

Adapter: Virtual device
temp1: +46.0°C

thinkpad-isa-0000
Adapter: ISA adapter
fan1: 3486 RPM
fan2: 3238 RPM
CPU: +74.0°C
GPU: +58.0°C
temp3: +63.0°C
temp4: +1.0°C
temp5: +61.0°C
temp6: +56.0°C
temp7: +49.0°C
temp8: N/A
pwm1: 128%

ucsi_source_psy_USBC000:001-isa-0000
Adapter: ISA adapter
in0: 0.00 V (min = +0.00 V, max = +0.00 V)
curr1: 3.00 A (max = +0.00 A)

BAT0-acpi-0
Adapter: ACPI interface
in0: 16.67 V
power1: 0.00 W

coretemp-isa-0000
Adapter: ISA adapter
Package id 0: +77.0°C (high = +100.0°C, crit = +100.0°C)
Core 0: +70.0°C (high = +100.0°C, crit = +100.0°C)
Core 1: +70.0°C (high = +100.0°C, crit = +100.0°C)
Core 2: +66.0°C (high = +100.0°C, crit = +100.0°C)
Core 3: +64.0°C (high = +100.0°C, crit = +100.0°C)
Core 4: +58.0°C (high = +100.0°C, crit = +100.0°C)
Core 5: +77.0°C (high = +100.0°C, crit = +100.0°C)
Core 6: +70.0°C (high = +100.0°C, crit = +100.0°C)
Core 7: +74.0°C (high = +100.0°C, crit = +100.0°C)

ucsi_source_psy_USBC000:002-isa-0000
Adapter: ISA adapter
in0: 0.00 V (min = +0.00 V, max = +0.00 V)
curr1: 0.00 A (max = +0.00 A)

nvme-pci-0400
Adapter: PCI adapter
Composite: +57.9°C (low = -273.1°C, high = +81.8°C)
(crit = +84.8°C)
Sensor 1: +57.9°C (low = -273.1°C, high = +65261.8°C)
Sensor 2: +58.9°C (low = -273.1°C, high = +65261.8°C)

acpitz-acpi-0
Adapter: ACPI interface
temp1: +74.0°C subprocess

Three problems with shelling out:

  • Needed lm-sensors installed, so temperatures silently vanished without it
  • Hardcoded the chip name thinkpad-isa-0000
  • Double-counted the first fan — procfs contributed "Fan1", sensors contributed "fan1", so the UI listed one fan twice

Dropping it also removed the last use of the regex crate, now gone from the tree.

Simulated devices

The captured profile covers exactly one machine. tests/synthetic_hardware.rs builds sysfs trees in temp dirs for thirteen shapes and states — the ones most likely to break precisely because nobody develops on them:

Shape Why it matters
single-fan ThinkPad the common T/X case
four-fan reader must not cap at what we own
non-ThinkPad laptop a dell_smm fan and coretemp still read
no hwmon at all every container
fan_control=1 absent the most common broken state — differs from working by exactly three commands: lines
stopped fan at 0 RPM a reading, not a missing sensor; dropping it hides the most safety-relevant state
pwm1_enable=0 means FULL SPEED on thinkpad_acpi, not off — reading it as "disabled" inverts the most aggressive setting
malformed sysfs empty, non-numeric, out-of-range
chip with no name file not an hwmon chip
stable ordering UI must not reshuffle between polls

A real gap in the earlier fixture

The capture script resolved /sys/class/hwmon symlinks to their /sys/devices targets and stored only those — so the data was present but undiscoverable, because code walks /sys/class/hwmon, which was absent. It mirrors the class view now (as real directories; symlinks would point outside the profile at the real machine). Profile regenerated, redaction re-verified.

Suite: 85 → 99.

…d fan

Dual-fan ThinkPads -- P1, P15, X1 Extreme -- have a second fan the app
could not see. /proc/acpi/ibm/fan has a speed: field and no speed2:, so
the second tachometer is invisible there. hwmon is the only way to read
it.

Verified live on this machine: two thinkpad tachometers at 3813 and 3515
RPM, one control channel.

That one-channel-two-fans shape is the structural point. thinkpad_acpi
writes the same level to both fans, so they cannot be driven
independently -- anything offering per-fan control on these machines
would be lying about what the hardware does.

Replaces shelling out to iwlwifi_1-virtual-0
Adapter: Virtual device
temp1:        +46.0°C

thinkpad-isa-0000
Adapter: ISA adapter
fan1:        3491 RPM
fan2:        3236 RPM
CPU:          +62.0°C
GPU:          +58.0°C
temp3:        +63.0°C
temp4:         +1.0°C
temp5:        +64.0°C
temp6:        +60.0°C
temp7:        +51.0°C
temp8:            N/A
pwm1:            128%

ucsi_source_psy_USBC000:001-isa-0000
Adapter: ISA adapter
in0:           0.00 V  (min =  +0.00 V, max =  +0.00 V)
curr1:         3.00 A  (max =  +0.00 A)

BAT0-acpi-0
Adapter: ACPI interface
in0:          16.67 V
power1:        0.00 W

coretemp-isa-0000
Adapter: ISA adapter
Package id 0:  +69.0°C  (high = +100.0°C, crit = +100.0°C)
Core 0:        +68.0°C  (high = +100.0°C, crit = +100.0°C)
Core 1:        +67.0°C  (high = +100.0°C, crit = +100.0°C)
Core 2:        +67.0°C  (high = +100.0°C, crit = +100.0°C)
Core 3:        +68.0°C  (high = +100.0°C, crit = +100.0°C)
Core 4:        +65.0°C  (high = +100.0°C, crit = +100.0°C)
Core 5:        +67.0°C  (high = +100.0°C, crit = +100.0°C)
Core 6:        +69.0°C  (high = +100.0°C, crit = +100.0°C)
Core 7:        +67.0°C  (high = +100.0°C, crit = +100.0°C)

ucsi_source_psy_USBC000:002-isa-0000
Adapter: ISA adapter
in0:           0.00 V  (min =  +0.00 V, max =  +0.00 V)
curr1:         0.00 A  (max =  +0.00 A)

nvme-pci-0400
Adapter: PCI adapter
Composite:    +59.9°C  (low  = -273.1°C, high = +81.8°C)
                       (crit = +84.8°C)
Sensor 1:     +59.9°C  (low  = -273.1°C, high = +65261.8°C)
Sensor 2:     +62.9°C  (low  = -273.1°C, high = +65261.8°C)

acpitz-acpi-0
Adapter: ACPI interface
temp1:        +62.0°C  , which had three problems:

  It required lm-sensors, so temperatures silently vanished without it.
  It hardcoded the chip name thinkpad-isa-0000.
  It double-counted the first fan: procfs contributed "Fan1" and
  iwlwifi_1-virtual-0
Adapter: Virtual device
temp1:        +46.0°C

thinkpad-isa-0000
Adapter: ISA adapter
fan1:        3491 RPM
fan2:        3236 RPM
CPU:          +62.0°C
GPU:          +58.0°C
temp3:        +63.0°C
temp4:         +1.0°C
temp5:        +64.0°C
temp6:        +60.0°C
temp7:        +51.0°C
temp8:            N/A
pwm1:            128%

ucsi_source_psy_USBC000:001-isa-0000
Adapter: ISA adapter
in0:           0.00 V  (min =  +0.00 V, max =  +0.00 V)
curr1:         3.00 A  (max =  +0.00 A)

BAT0-acpi-0
Adapter: ACPI interface
in0:          16.67 V
power1:        0.00 W

coretemp-isa-0000
Adapter: ISA adapter
Package id 0:  +69.0°C  (high = +100.0°C, crit = +100.0°C)
Core 0:        +68.0°C  (high = +100.0°C, crit = +100.0°C)
Core 1:        +67.0°C  (high = +100.0°C, crit = +100.0°C)
Core 2:        +67.0°C  (high = +100.0°C, crit = +100.0°C)
Core 3:        +68.0°C  (high = +100.0°C, crit = +100.0°C)
Core 4:        +65.0°C  (high = +100.0°C, crit = +100.0°C)
Core 5:        +67.0°C  (high = +100.0°C, crit = +100.0°C)
Core 6:        +69.0°C  (high = +100.0°C, crit = +100.0°C)
Core 7:        +67.0°C  (high = +100.0°C, crit = +100.0°C)

ucsi_source_psy_USBC000:002-isa-0000
Adapter: ISA adapter
in0:           0.00 V  (min =  +0.00 V, max =  +0.00 V)
curr1:         0.00 A  (max =  +0.00 A)

nvme-pci-0400
Adapter: PCI adapter
Composite:    +59.9°C  (low  = -273.1°C, high = +81.8°C)
                       (crit = +84.8°C)
Sensor 1:     +59.9°C  (low  = -273.1°C, high = +65261.8°C)
Sensor 2:     +62.9°C  (low  = -273.1°C, high = +65261.8°C)

acpitz-acpi-0
Adapter: ACPI interface
temp1:        +62.0°C   contributed "fan1", so the UI listed one fan twice under two
  names. The procfs entry is dropped now in favour of the complete hwmon
  set rather than merged with it.

Dropping the subprocess also removed the last use of the regex crate,
which is gone from the dependency tree.

The capture script had a matching gap: it resolved /sys/class/hwmon
symlinks to their /sys/devices targets and stored only those, so a
profile contained the data but nothing could discover it -- code walks
/sys/class/hwmon, which was absent. It now mirrors the class view as real
directories (not symlinks, which would point outside the profile at the
real machine). The profile was regenerated; redaction re-verified clean.

Tests run against the captured P1 Gen 4i profile and cover both fans,
the single channel, no duplicate labels, temperatures without lm-sensors,
and a machine with no hwmon at all -- which is every container.
The captured profile covers exactly one machine -- a dual-fan P1 with
fan_control=1 already on. Every other shape the app must survive was
untested, and those are the ones most likely to break because nobody is
running the app on them during development.

tests/synthetic_hardware.rs builds sysfs trees in temp dirs and points
THINKUTILS_HARDWARE_ROOT at them, so the same code path a real machine
takes is exercised. Values follow the kernel's documented formats: RPM as
a plain integer, temperatures in millidegrees, pwmN_enable as 0/1/2.

Thirteen machines and states:

  single-fan ThinkPad, the common T/X case
  dual-fan, two tachometers and ONE control channel
  four-fan, so the reader is not quietly capped at what we own
  non-ThinkPad laptop -- a dell_smm fan and coretemp still read
  no hwmon at all, which is every container
  fan_control=1 absent, the most common broken state, distinguishable
  from the working one by exactly three commands: lines
  a stopped fan reporting 0 RPM, which is a reading and not a missing
  sensor -- dropping it would hide the most safety-relevant state there is
  pwm1_enable=0, which means FULL SPEED on thinkpad_acpi rather than off
  empty, non-numeric and out-of-range sysfs contents
  a chip directory with no name file
  a sensor with no label file
  stable ordering across repeated reads

Also fixes a real gap the earlier fixture had: the capture script
resolved /sys/class/hwmon symlinks to their /sys/devices targets and
stored only those, so the data was present but undiscoverable -- code
walks /sys/class/hwmon, which was absent. It mirrors the class view now.

Suite: 85 -> 99.
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
thinkutils 59a5969 Jul 19 2026, 03:48 PM

@vietanhdev

Copy link
Copy Markdown
Owner Author

Superseded — the PPA source build landed on main via #26.

@vietanhdev vietanhdev closed this Jul 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant