hddfancontrol 2.1.2

Daemon to regulate fan speed according to hard drive temperature on Linux
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
//! Block device drive

use std::{
    ffi::OsStr,
    fmt, fs,
    io::BufRead as _,
    os::unix::prelude::FileTypeExt as _,
    path::{Path, PathBuf},
    process::{Command, Stdio},
};

use anyhow::Context as _;

/// Drive runtime state
#[derive(strum::EnumString, strum::Display)]
#[strum(serialize_all = "lowercase")]
pub(crate) enum State {
    /// Suspended by kernel power management
    PmSuspended,
    /// Active/idle
    #[strum(serialize = "active/idle")]
    ActiveIdle,
    /// Standby
    Standby,
    /// Sleeping (power saving mode)
    Sleeping,
    /// Error occured while querying drive state
    Unknown,
}

/// How to probe for drive state
#[derive(strum::Display)]
#[strum(serialize_all = "lowercase")]
enum StateProbingMethod {
    /// Use `hdparm`
    Hdparm,
    /// Use `sdparm`
    Sdparm,
}

impl State {
    /// Can we probe drive temperature?
    pub(crate) fn can_probe_temp(&self, supports_probing_when_asleep: bool) -> bool {
        match self {
            Self::PmSuspended => false,
            Self::Standby | Self::Sleeping => supports_probing_when_asleep,
            Self::ActiveIdle | Self::Unknown => true,
        }
    }
}

/// Block device drive
pub(crate) struct Drive {
    /// Normalized (under /dev) device filepath
    pub dev_path: PathBuf,
    /// Pretty name for display
    name: String,
    /// How to probe for state, or `None` for non-rotational drives that need none
    state_probing_method: Option<StateProbingMethod>,
}

impl fmt::Display for Drive {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
        write!(f, "{}", self.name)
    }
}

impl Drive {
    /// Build a drive from its device path
    pub(crate) fn new(path: &Path) -> anyhow::Result<Self> {
        let dev_path = path.canonicalize()?;
        anyhow::ensure!(
            dev_path.metadata()?.file_type().is_block_device(),
            "Path {dev_path:?} is not a block device",
        );
        let name = format!(
            "{} {}",
            dev_path
                .file_name()
                .and_then(|p| p.to_str())
                .ok_or_else(|| anyhow::anyhow!("Invalid drive path"))?,
            Self::model(&dev_path)?,
        );
        let rotational_path: PathBuf = [
            OsStr::new("/sys/class/block"),
            dev_path
                .file_name()
                .ok_or_else(|| anyhow::anyhow!("Invalid device path"))?,
            OsStr::new("queue/rotational"),
        ]
        .into_iter()
        .collect();
        let state_probing_method = if Self::is_rotational(&rotational_path)? {
            let method = if Self::state_hdparm(&dev_path).is_ok() {
                StateProbingMethod::Hdparm
            } else if Self::state_sdparm(&dev_path).is_ok() {
                StateProbingMethod::Sdparm
            } else {
                anyhow::bail!("Unable to probe for drive state");
            };
            log::debug!("{name}: Will use {method} state probing method");
            Some(method)
        } else {
            log::debug!("{name}: Non-rotational drive, skipping state probing");
            None
        };
        Ok(Self {
            dev_path,
            name,
            state_probing_method,
        })
    }

    /// Build a drive with hdparm state probing, bypassing device checks
    #[cfg(test)]
    pub(crate) fn fake(dev_path: &str) -> Self {
        Self {
            dev_path: PathBuf::from(dev_path),
            name: dev_path.to_owned(),
            state_probing_method: Some(StateProbingMethod::Hdparm),
        }
    }

    /// Read whether the drive has rotating platters from its sysfs attribute file
    fn is_rotational(rotational_path: &Path) -> anyhow::Result<bool> {
        let content = fs::read_to_string(rotational_path)
            .with_context(|| format!("Failed to read {rotational_path:?}"))?;
        match content.trim_end() {
            "1" => Ok(true),
            "0" => Ok(false),
            other => anyhow::bail!("Unexpected {rotational_path:?} content: {other:?}"),
        }
    }

    /// Get drive model name
    fn model(path: &Path) -> anyhow::Result<String> {
        let dev = path
            .to_str()
            .ok_or_else(|| anyhow::anyhow!("Invalid device path"))?;
        let cmds = [["hdparm", "-I", dev], ["smartctl", "-i", dev]];
        for cmd in cmds {
            log::trace!("{}", cmd.join(" "));
            let output = Command::new(cmd[0])
                .args(&cmd[1..])
                .stdin(Stdio::null())
                .stderr(Stdio::null())
                .env("LANG", "C")
                .output()?;
            if !output.status.success() {
                log::trace!("{}", output.status);
                continue;
            }
            // log::trace!("{}", std::str::from_utf8(&output.stdout).unwrap());
            if let Some(line) = output.stdout.lines().map_while(Result::ok).find_map(|l| {
                let l = l.trim_start();
                l.strip_prefix("Model Number:")
                    .or_else(|| l.strip_prefix("Product:"))
                    .map(ToOwned::to_owned)
            }) {
                return Ok(line.trim().to_owned());
            }
        }
        anyhow::bail!("Unable to get drive {path:?} model name");
    }

    /// Get drive runtime state using `hdparm`
    fn state_hdparm(path: &Path) -> anyhow::Result<State> {
        let output = Command::new("hdparm")
            .args([
                "-C",
                path.to_str()
                    .ok_or_else(|| anyhow::anyhow!("Invalid device path"))?,
            ])
            .stdin(Stdio::null())
            .env("LANG", "C")
            .output()?;
        anyhow::ensure!(
            output.status.success(),
            "hdparm failed with code {}",
            output.status
        );
        let lines: Vec<_> = output
            .stdout
            .lines()
            .chain(output.stderr.lines())
            .collect::<Result<_, _>>()?;
        anyhow::ensure!(
            !lines
                .iter()
                .any(|l| l.starts_with("SG_IO: ") && l.contains("sense data")),
            "hdparm returned soft error",
        );
        let state = lines
            .iter()
            .find_map(|l| l.trim_start().strip_prefix("drive state is: "))
            .and_then(|l| {
                l.split_ascii_whitespace()
                    .next_back()
                    .map(ToOwned::to_owned)
            })
            .ok_or_else(|| anyhow::anyhow!("Failed to parse hdparm drive state output"))?
            .parse()
            .unwrap_or(State::Unknown);
        Ok(state)
    }

    /// Get drive runtime state using `sdparm`
    fn state_sdparm(path: &Path) -> anyhow::Result<State> {
        let output = Command::new("sdparm")
            .args([
                "--command=ready",
                path.to_str()
                    .ok_or_else(|| anyhow::anyhow!("Invalid device path"))?,
            ])
            .stdin(Stdio::null())
            .stderr(Stdio::null())
            .env("LANG", "C")
            .output()?;
        anyhow::ensure!(
            output.status.success(),
            "sdparm failed with code {}",
            output.status
        );
        let state = output
            .stdout
            .lines()
            .map_while(Result::ok)
            .filter_map(|l| {
                let nl = l.trim();
                (!nl.is_empty()).then(|| nl.to_owned())
            })
            .last()
            .map(|l| match l.as_str() {
                "Ready" => State::ActiveIdle,
                "Not ready" => State::Sleeping,
                _ => State::Unknown,
            })
            .ok_or_else(|| anyhow::anyhow!("Failed to parse sdparm drive state output"))?;
        Ok(state)
    }

    /// Get drive runtime state
    pub(crate) fn state(&self) -> anyhow::Result<State> {
        const SUSPENDED_PM_STATUS: [&str; 2] = ["suspended", "suspending"];
        let Some(method) = &self.state_probing_method else {
            return Ok(State::ActiveIdle);
        };
        let pm_status_path: PathBuf = [
            OsStr::new("/sys/class/block"),
            #[expect(clippy::unwrap_used)]
            self.dev_path.file_name().unwrap(),
            OsStr::new("device/power/runtime_status"),
        ]
        .into_iter()
        .collect();
        if fs::read_to_string(pm_status_path)
            .is_ok_and(|s| SUSPENDED_PM_STATUS.contains(&s.trim_end()))
        {
            Ok(State::PmSuspended)
        } else {
            match method {
                StateProbingMethod::Hdparm => Self::state_hdparm(&self.dev_path),
                StateProbingMethod::Sdparm => Self::state_sdparm(&self.dev_path),
            }
        }
    }
}

#[cfg(test)]
#[expect(clippy::shadow_unrelated)]
mod tests {
    use tempfile::NamedTempFile;

    use super::*;
    use crate::tests::BinaryMock;

    #[serial_test::serial]
    #[test]
    fn model_hdd() {
        let _ = simple_logger::init_with_level(log::Level::Trace);

        let _hdparm_mock = BinaryMock::new("hdparm", "\n/dev/_sdX:\n\nATA device, with non-removable media\n\tModel Number:       WDC WD4003FZEX-00Z4SA0                  \n\tSerial Number:      WD-WMC5D0D4YY1K\n\tFirmware Revision:  01.01A01\n\tTransport:          Serial, SATA 1.0a, SATA II Extensions, SATA Rev 2.5, SATA Rev 2.6, SATA Rev 3.0\nStandards:\n\tSupported: 9 8 7 6 5 \n\tLikely used: 9\nConfiguration:\n\tLogical\t\tmax\tcurrent\n\tcylinders\t16383\t16383\n\theads\t\t16\t16\n\tsectors/track\t63\t63\n\t--\n\tCHS current addressable sectors:   16514064\n\tLBA    user addressable sectors:  268435455\n\tLBA48  user addressable sectors: 7814037168\n\tLogical  Sector size:                   512 bytes\n\tPhysical Sector size:                  4096 bytes\n\tLogical Sector-0 offset:                  0 bytes\n\tdevice size with M = 1024*1024:     3815447 MBytes\n\tdevice size with M = 1000*1000:     4000787 MBytes (4000 GB)\n\tcache/buffer size  = unknown\n\tNominal Media Rotation Rate: 7200\nCapabilities:\n\tLBA, IORDY(can be disabled)\n\tQueue depth: 32\n\tStandby timer values: spec'd by Standard, with device specific minimum\n\tR/W multiple sector transfer: Max = 16\tCurrent = 0\n\tDMA: mdma0 mdma1 mdma2 udma0 udma1 udma2 udma3 udma4 udma5 *udma6 \n\t     Cycle time: min=120ns recommended=120ns\n\tPIO: pio0 pio1 pio2 pio3 pio4 \n\t     Cycle time: no flow control=120ns  IORDY flow control=120ns\nCommands/features:\n\tEnabled\tSupported:\n\t   *\tSMART feature set\n\t    \tSecurity Mode feature set\n\t   *\tPower Management feature set\n\t   *\tWrite cache\n\t   *\tLook-ahead\n\t   *\tHost Protected Area feature set\n\t   *\tWRITE_BUFFER command\n\t   *\tREAD_BUFFER command\n\t   *\tNOP cmd\n\t   *\tDOWNLOAD_MICROCODE\n\t    \tPower-Up In Standby feature set\n\t   *\tSET_FEATURES required to spinup after power up\n\t    \tSET_MAX security extension\n\t   *\t48-bit Address feature set\n\t   *\tMandatory FLUSH_CACHE\n\t   *\tFLUSH_CACHE_EXT\n\t   *\tSMART error logging\n\t   *\tSMART self-test\n\t   *\tGeneral Purpose Logging feature set\n\t   *\t64-bit World wide name\n\t   *\t{READ,WRITE}_DMA_EXT_GPL commands\n\t   *\tSegmented DOWNLOAD_MICROCODE\n\t   *\tGen1 signaling speed (1.5Gb/s)\n\t   *\tGen2 signaling speed (3.0Gb/s)\n\t   *\tGen3 signaling speed (6.0Gb/s)\n\t   *\tNative Command Queueing (NCQ)\n\t   *\tHost-initiated interface power management\n\t   *\tPhy event counters\n\t   *\tNCQ priority information\n\t   *\tREAD_LOG_DMA_EXT equivalent to READ_LOG_EXT\n\t   *\tDMA Setup Auto-Activate optimization\n\t   *\tSoftware settings preservation\n\t   *\tSMART Command Transport (SCT) feature set\n\t   *\tSCT Write Same (AC2)\n\t   *\tSCT Features Control (AC4)\n\t   *\tSCT Data Tables (AC5)\n\t    \tunknown 206[12] (vendor specific)\n\t    \tunknown 206[13] (vendor specific)\n\t    \tunknown 206[14] (vendor specific)\nSecurity: \n\tMaster password revision code = 65534\n\t\tsupported\n\tnot\tenabled\n\tnot\tlocked\n\tnot\tfrozen\n\tnot\texpired: security count\n\t\tsupported: enhanced erase\n\t424min for SECURITY ERASE UNIT. 424min for ENHANCED SECURITY ERASE UNIT. \nLogical Unit WWN Device Identifier: 50014ee0593d4632\n\tNAA\t\t: 5\n\tIEEE OUI\t: 0014ee\n\tUnique ID\t: 0593d4632\nChecksum: correct\n".as_bytes(), &[], 0).unwrap();
        let _smartctl_mock = BinaryMock::new("smartctl", &[], &[], 1).unwrap();
        assert_eq!(
            Drive::model(Path::new("/dev/_sdX")).unwrap().as_str(),
            "WDC WD4003FZEX-00Z4SA0"
        );
    }

    #[serial_test::serial]
    #[test]
    fn model_ssd() {
        let _ = simple_logger::init_with_level(log::Level::Trace);

        let _hdparm_mock = BinaryMock::new("hdparm", "\n/dev/_sdX:".as_bytes(), &[], 0).unwrap();
        let _smartctl_mock = BinaryMock::new("smartctl", "smartctl 7.3 2022-02-28 r5338 [x86_64-linux-6.1.53-1-lts] (local build)\nCopyright (C) 2002-22, Bruce Allen, Christian Franke, www.smartmontools.org\n\n=== START OF INFORMATION SECTION ===\nModel Number:                       WD_BLACK SN850 2TB\nFirmware Version:\n                   611100WD\nPCI Vendor/Subsystem ID:            0x15b7\nIEEE OUI Identifier:                0x001b44\nTotal NVM Capacity:                 2 000 398 934 016 [2,00 TB]\nUnallocated NVM Capacity:           0\nController ID:                      8224\nNVMe Version:                       1.4\nNumber of Namespaces:               1\nNamespace 1 Size/Capacity:          2 000 398 934 016 [2,00 TB]\nNamespace 1 Formatted LBA Size:     512\nNamespace 1 IEEE EUI-64:            001b44 8b492d482c\n\n".as_bytes(), &[], 0).unwrap();
        assert_eq!(
            Drive::model(Path::new("/dev/_sdX")).unwrap().as_str(),
            "WD_BLACK SN850 2TB"
        );
    }

    #[serial_test::serial]
    #[test]
    fn state_hdparm() {
        let _ = simple_logger::init_with_level(log::Level::Trace);

        let _hdparm_mock = BinaryMock::new(
            "hdparm",
            "\n/dev/_sdX:\n drive state is:  active/idle\n".as_bytes(),
            &[],
            0,
        )
        .unwrap();
        assert!(matches!(
            Drive::state_hdparm(Path::new("/dev/_sdX")).unwrap(),
            State::ActiveIdle
        ));

        let _hdparm_mock = BinaryMock::new(
            "hdparm",
            "\n/dev/_sdX:\n drive state is:  standby\n".as_bytes(),
            &[],
            0,
        )
        .unwrap();
        assert!(matches!(
            Drive::state_hdparm(Path::new("/dev/_sdX")).unwrap(),
            State::Standby
        ));

        let _hdparm_mock = BinaryMock::new(
            "hdparm",
            "\n/dev/_sdX:\n drive state is:  sleeping\n".as_bytes(),
            &[],
            0,
        )
        .unwrap();
        assert!(matches!(
            Drive::state_hdparm(Path::new("/dev/_sdX")).unwrap(),
            State::Sleeping
        ));

        let _hdparm_mock = BinaryMock::new(
            "hdparm",
            "\n/dev/_sdX:\n drive state is:  NVcache_spindown\n".as_bytes(),
            &[],
            0,
        )
        .unwrap();
        assert!(matches!(
            Drive::state_hdparm(Path::new("/dev/_sdX")).unwrap(),
            State::Unknown
        ));

        let _hdparm_mock = BinaryMock::new(
            "hdparm",
            "\n/dev/_sdX:\n drive state is:  unknown\n".as_bytes(),
            &[],
            0,
        )
        .unwrap();
        assert!(matches!(
            Drive::state_hdparm(Path::new("/dev/_sdX")).unwrap(),
            State::Unknown
        ));

        let _hdparm_mock = BinaryMock::new(
            "hdparm",
            "\n/dev/_sdX: No such file or directory\n".as_bytes(),
            &[],
            0,
        )
        .unwrap();
        assert!(Drive::state_hdparm(Path::new("/dev/_sdX")).is_err());

        let _hdparm_mock = BinaryMock::new(
            "hdparm",
            "\n/dev/_sdX:\n drive state is:  standby\n".as_bytes(),
            "SG_IO: bad/missing sense data, sb[]:  70 00 05 00 00 00 00 0a 00 00 00 00 20 00 01 cf 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00\n".as_bytes(),
            0,
        );
        assert!(Drive::state_hdparm(Path::new("/dev/_sdX")).is_err());
    }

    #[serial_test::serial]
    #[test]
    fn state_sdparm() {
        let _ = simple_logger::init_with_level(log::Level::Trace);

        let _sdparm_mock = BinaryMock::new(
            "sdparm",
            "    /dev/_sdX: SEAGATE   ST2000NM0001      0002\nReady\n".as_bytes(),
            &[],
            0,
        )
        .unwrap();
        assert!(matches!(
            Drive::state_sdparm(Path::new("/dev/_sdX")).unwrap(),
            State::ActiveIdle
        ));

        let _sdparm_mock = BinaryMock::new(
            "sdparm",
            "    /dev/_sdX: SEAGATE   ST2000NM0001      0002\nNot ready\n".as_bytes(),
            &[],
            0,
        )
        .unwrap();
        assert!(matches!(
            Drive::state_sdparm(Path::new("/dev/_sdX")).unwrap(),
            State::Sleeping
        ));
    }

    #[test]
    fn state_non_rotational_is_active_idle() {
        let drive = Drive {
            dev_path: PathBuf::from("/dev/_sdX"),
            name: "/dev/_sdX".to_owned(),
            state_probing_method: None,
        };
        assert!(matches!(drive.state().unwrap(), State::ActiveIdle));
    }

    #[test]
    fn is_rotational_hdd() {
        let file = NamedTempFile::new().unwrap();
        fs::write(file.path(), "1\n").unwrap();
        assert!(Drive::is_rotational(file.path()).unwrap());
    }

    #[test]
    fn is_rotational_ssd() {
        let file = NamedTempFile::new().unwrap();
        fs::write(file.path(), "0\n").unwrap();
        assert!(!Drive::is_rotational(file.path()).unwrap());
    }

    #[test]
    fn is_rotational_unexpected_content() {
        let file = NamedTempFile::new().unwrap();
        fs::write(file.path(), "2\n").unwrap();
        assert!(Drive::is_rotational(file.path()).is_err());
    }

    #[test]
    fn is_rotational_missing_file() {
        assert!(Drive::is_rotational(Path::new("/nonexistent/queue/rotational")).is_err());
    }
}