Skip to content

fix: honor FanResponseDelay in PWM readback verification; restore fan state after failed or interrupted CLI init - #512

Merged
markusressel merged 4 commits into
markusressel:masterfrom
epicfail:fix/init-regression-nct6687
Aug 5, 2026
Merged

fix: honor FanResponseDelay in PWM readback verification; restore fan state after failed or interrupted CLI init#512
markusressel merged 4 commits into
markusressel:masterfrom
epicfail:fix/init-regression-nct6687

Conversation

@epicfail

@epicfail epicfail commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #511.

Since 0.14.0 the two-phase adaptive sweep verifies the PWM readback before sampling RPM, but re-checks after only PwmSetDelay (5 ms by default). On hardware where the reported PWM converges slowly towards the requested value (the NCT6687 EC ramps DC outputs gradually; the nct6687d driver serves cached sysfs reads) the check can never pass, so every sample is skipped as a mismatch and boundary discovery fails in under a second with "unable to detect start boundary, no spinning PWM found" while the fans are audibly spinning. Full logs and analysis in the issue.

Three small fixes, one commit each:

  • measureAtPwm() now waits FanResponseDelay before each mismatch retry.
  • RunInitialization() captures the original fan state, so a failed init started from the CLI (which does not go through Run()) restores the previous control mode instead of applying the zero value of ControlMode. That zero value (disabled) was rejected by the driver, and the SetControlMode error fallback then left the fan in manual mode at PWM 255. restoreControlMode() also skips the restore when no state was ever captured.
  • fan init handles SIGINT/SIGTERM and restores the fan state when interrupted.

Verified on the affected hardware (MSI MPG B650 Carbon, NCT6687, three DC-controlled case fans): fan init completes in ~4 min per fan with real settle pauses and a full RPM curve, manual minPwm/startPwm/maxPwm are honored, and both Ctrl-C during init and daemon shutdown hand the channels back to the EC (pwm_enable returns to 2). Regression tests included for all three fixes.

Comment thread internal/controller/controller.go Outdated
}

// RestoreControlMode restores the fan state that was captured when the controller started.
func (f *DefaultFanController) RestoreControlMode() {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we simply expose this method like that we also need to make sure the caller stops the fan controller. Restoring the ControlMode and keeping the controller active will lead to a lot of error message.

TBH I am not sure we should expose this at all. The FanController should handle all of this correctly by itself. If the problem is caused by the RunInitialization method being used not only internally but also directly by the CLI command, maybe we should separate the internal "runInitialization" method from a public one, and do the error handling and fan restoration within the public one.

The CLI command code shouldn't need to know about restoring the fan control mode which is an internal property of the fan controller.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, the exported method is gone. runInitialization() is now the private worker, and the public RunInitialization(ctx) captures the fan state first and owns the restore on any failure. To keep the CLI out of fan state handling entirely, the initialization sequence is now context-aware: fan init just wraps its context with signal.NotifyContext, and cancellation aborts the sweep's waits and goes through the same restore path. This also fixes a small inconsistency in the old code, where the AttachFanRpmCurveData error path returned without restoring.

Verified on the affected hardware: Ctrl-C and SIGTERM during a sweep now hand the channels back to EC control on their own, and a full init still completes with the same boundaries as before.

Comment thread internal/controller/controller.go Outdated
// Note: this is the raw value read from the fan, no pwmMap is applied to it
originalPwmValue int
// whether storeCurrentFanState() has captured the two fields above
originalStateCaptured bool

@markusressel markusressel Aug 4, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we need this to distinguish presence and absence of data, we should probably wrap the "originalPwmValue" and "originalControlMode" properties into a SavedFanState/OriginalFanState/FanStateSnapshot struct, which can either be null or have a value. That way we don't need an additional boolean variable that can (in theory) get out of sync with the actual data.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — replaced the two fields with a nullable FanStateSnapshot and dropped the boolean. restoreControlMode() now skips the restore when the snapshot was never captured.

Comment thread internal/controller/fan_analysis.go Outdated
for attempt := 0; attempt <= pwmMismatchRetries; attempt++ {
if attempt > 0 {
// The reported PWM may lag behind the requested value on some hardware, so wait FanResponseDelay before retrying.
time.Sleep(time.Duration(configuration.CurrentConfig.FanResponseDelay) * time.Second)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 probably got lost in the rewrite

@markusressel markusressel added the bug Something isn't working label Aug 4, 2026
@markusressel

Copy link
Copy Markdown
Owner

Please rebase your branch to fix the "codespell" CI error.

…on retries

On hardware where the reported PWM value converges towards the requested one
gradually (e.g. the NCT6687 EC ramps DC-controlled fans, and the nct6687d
driver serves cached sysfs reads), the readback verification in measureAtPwm()
could never succeed: it re-checked after only PwmSetDelay (5ms by default),
so every sample was skipped as a mismatch (-1) and boundary discovery failed
in under a second with "unable to detect start boundary, no spinning PWM
found", while the fans were audibly spinning up towards the probed values.

Wait FanResponseDelay before each mismatch retry so the hardware gets the
same response time the rest of the measurement already grants it.
@epicfail
epicfail force-pushed the fix/init-regression-nct6687 branch from 4830bec to 87733dd Compare August 4, 2026 14:14
…ed via CLI

The `fan init` CLI command calls RunInitialization() directly, without going
through Run(), so storeCurrentFanState() was never called. When the
initialization failed, restoreControlMode() then applied the zero values of
the original-state fields: ControlMode 0 (disabled) was written to
pwm_enable, which some drivers reject (e.g. nct6687d returns EINVAL), and
the SetControlMode error path then set the fan to PWM 255 in manual mode,
leaving it at full speed with no controller attached.

Wrap the original state into a nullable FanStateSnapshot so its absence is
representable, capture it at the start of RunInitialization(), and skip the
restore entirely when no state was ever captured, since writing the zero
value of ControlMode would disable fan control.
The measurement leaves the fan in manual mode at whatever PWM value was
probed last. Interrupting the CLI initialization with SIGINT/SIGTERM killed
the process without any cleanup, leaving the fan stuck there.

Split RunInitialization() into a public wrapper that captures the fan state
and owns the restore on any failure, and a private worker holding the
sequence itself. The initialization sequence is context-aware, so the CLI
cancels the context via signal.NotifyContext on SIGINT/SIGTERM and the
controller restores the fan state on its own, without the CLI knowing
anything about fan state internals.
@epicfail
epicfail force-pushed the fix/init-regression-nct6687 branch from 87733dd to 00e9a4a Compare August 4, 2026 14:42
@epicfail

epicfail commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on master — codespell is green now. I also reworked the branch to address both review comments, see the inline replies.

@markusressel

Copy link
Copy Markdown
Owner

Thx I will check it out later ❤️

ControlMode fans.ControlMode
// the raw pwm value read from the fan before the controller started
// Note: this is the raw value, no pwmMap is applied to it
PwmValue int

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prefect 👍 :)

Comment thread internal/controller/controller.go Outdated

func (f *DefaultFanController) storeCurrentFanState() error {
if f.originalFanState != nil {
return nil

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, is this a good idea? The method name doesn't suggest that a once set snapshot will never be reassigned. It might be confusing for a dev to only find out about that later. If we need a way to store the fan state snapshot only once regardless of how often we call the method, I would suggest to create separate methods with that explicit purpose, something like "storeInitialFanState()", with an accompanying "clearInitialFanState()" (if that is something we need), which use the storeCurrentFanState under the hood.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated! I have separated storeCurrentFanState() (which now unconditionally captures and overwrites the snapshot) from storeInitialFanState() (which captures only if not set yet) and added clearInitialFanState(), along with unit tests for each.

…anState

Update storeCurrentFanState to unconditionally capture and overwrite the current fan state snapshot.
Introduce storeInitialFanState to preserve the initial snapshot on subsequent calls, and clearInitialFanState to reset it.
Add unit tests for storeInitialFanState, storeCurrentFanState, and clearInitialFanState.
@epicfail

epicfail commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a new commit to address the review comment: separated storeCurrentFanState() and storeInitialFanState(), added clearInitialFanState(), and added unit tests for all three.

@markusressel

Copy link
Copy Markdown
Owner

Looks good, will merge. Thx!

@markusressel
markusressel merged commit 5bbec14 into markusressel:master Aug 5, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

2 participants