diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 3efe9610..1f8182cb 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -5048,6 +5048,34 @@ def _lvm_device_args(): return lvm_device_args(_hdd_in_standby) +def _smart_identity_payload(disk_name): + """The empty payload with what the host knows of the disk without sending + it any command: its model, its serial and whether it spins.""" + base = _smart_default_payload() + try: + model = Path(f'/sys/block/{disk_name}/device/model').read_text().strip() + if model: + base['model'] = model + except OSError: + pass + try: + device = Path(f'/sys/block/{disk_name}/dev').read_text().strip() + for line in Path(f'/run/udev/data/b{device}').read_text().splitlines(): + key, _, value = line.partition('=') + if key == 'E:ID_MODEL' and value.strip(): + base['model'] = value.strip().replace('_', ' ') + elif key == 'E:ID_SERIAL_SHORT' and value.strip(): + base['serial'] = value.strip() + except OSError: + pass + try: + if Path(f'/sys/block/{disk_name}/queue/rotational').read_text().strip() == '1': + base['rotation_rate'] = -1 # HDD, RPM unknown + except OSError: + pass + return base + + def get_smart_data(disk_name): """Cached wrapper around the underlying smartctl probe. @@ -5086,7 +5114,15 @@ def get_smart_data(disk_name): except Exception: policy = 'read' if policy != 'read': - base = dict(cached[1]) if cached else _smart_default_payload() + if not cached and policy == 'idle' and not _hdd_in_standby(disk_name): + # Nothing is known of it yet, as after a restart: a disk that is + # spinning is read once; a parked one is not woken. + result = _get_smart_data_uncached(disk_name) + _dth.settle_own_reads([disk_name]) + if _smart_data_useful(result): + _smart_result_cache[disk_name] = (now, dict(result)) + cached = _smart_result_cache[disk_name] + base = dict(cached[1]) if cached else _smart_identity_payload(disk_name) base['temperature'] = 0 base['excluded'] = policy == 'excluded' base['idle'] = policy == 'idle' @@ -5098,7 +5134,7 @@ def get_smart_data(disk_name): # the disk sleeps. The Storage view paints its own Standby badge # from the temperature poller's flag. Don't refresh the cache # timestamp: the moment the disk is active again we re-probe. - base = dict(cached[1]) if cached else _smart_default_payload() + base = dict(cached[1]) if cached else _smart_identity_payload(disk_name) base['standby'] = True base['temperature'] = 0 return base diff --git a/tests/test_lvm_disk_read_policy.py b/tests/test_lvm_disk_read_policy.py index 30495950..26fd72df 100644 --- a/tests/test_lvm_disk_read_policy.py +++ b/tests/test_lvm_disk_read_policy.py @@ -4,6 +4,7 @@ import json from pathlib import Path import subprocess import sys +import tempfile import types import unittest from unittest.mock import Mock, patch @@ -147,5 +148,65 @@ class LvmReaderTests(unittest.TestCase): self.assertIn('cp "$SCRIPT_DIR/lvm_device_scope.py" "$APP_DIR/usr/bin/"', (SCRIPTS / "build_appimage.sh").read_text()) +class QuietDiskIdentityTests(unittest.TestCase): + """A quiet rotational disk the Monitor knows nothing of yet, as after a + restart, is still shown by its model, serial and type.""" + + def setUp(self): + folder = tempfile.TemporaryDirectory() + self.addCleanup(folder.cleanup) + root = Path(folder.name) + for name, value in (("sys/block/sdb/device/model", "WDC WD40EFRX-68N\n"), ("sys/block/sdb/dev", "8:16\n"), + ("sys/block/sdb/queue/rotational", "1\n"), + ("run/udev/data/b8:16", "E:ID_MODEL=WDC_WD40EFRX-68N32N0\nE:ID_SERIAL_SHORT=WD-WCC7K4FV37J0\n")): + (root / name).parent.mkdir(parents=True, exist_ok=True) + (root / name).write_text(value) + self.policy = "idle" + self.disk_module = types.ModuleType("disk_temperature_history") + self.disk_module.disk_read_policy = lambda disk: self.policy + self.disk_module.settle_own_reads = Mock() + modules = patch.dict(sys.modules, disk_temperature_history=self.disk_module) + modules.start() + self.addCleanup(modules.stop) + self.standby = Mock(return_value=False) + self.read = Mock(return_value={"model": "WDC WD40EFRX-68N32N0", "serial": "WD-WCC7K4FV37J0", + "rotation_rate": 5400, "health": "healthy", "temperature": 31}) + self.cache = {} + namespace = {"Path": lambda path: root / path.lstrip("/"), "time": __import__("time"), + "_smart_result_cache": self.cache, "_SMART_RESULT_TTL": 30, "_hdd_in_standby": self.standby, + "_get_smart_data_uncached": self.read, "_smart_data_useful": lambda data: data.get("model") != "Unknown", + "_smart_fail_backoff": {}, "_smart_fail_counts": {}, "_smart_probe_cache": {}, + "_SMART_FAIL_THRESHOLD": 3, "_SMART_FAIL_BACKOFF_SEC": 3600} + tree = ast.parse((SCRIPTS / "flask_server.py").read_text()) + nodes = [n for n in tree.body if isinstance(n, ast.FunctionDef) + and n.name in ("_smart_default_payload", "_smart_identity_payload", "get_smart_data")] + exec(compile(ast.Module(body=nodes, type_ignores=[]), "flask_server.py", "exec"), namespace) + self.get = namespace["get_smart_data"] + + def test_a_spinning_disk_is_read_once(self): + data = self.get("sdb") + self.assertEqual((data["model"], data["rotation_rate"], data["health"], data["idle"]), + ("WDC WD40EFRX-68N32N0", 5400, "healthy", True)) + self.disk_module.settle_own_reads.assert_called_once_with(["sdb"]) + self.cache["sdb"] = (0, self.cache["sdb"][1]) + self.get("sdb") + self.read.assert_called_once() + self.assertEqual(self.standby.call_count, 1) + + def test_a_parked_disk_is_not_woken_and_keeps_its_identity(self): + self.standby.return_value = True + data = self.get("sdb") + self.read.assert_not_called() + self.assertEqual((data["model"], data["serial"], data["rotation_rate"], data["idle"]), + ("WDC WD40EFRX-68N32N0", "WD-WCC7K4FV37J0", -1, True)) + + def test_an_excluded_disk_receives_no_command(self): + self.policy = "excluded" + data = self.get("sdb") + self.standby.assert_not_called() + self.read.assert_not_called() + self.assertEqual((data["model"], data["rotation_rate"], data["excluded"]), ("WDC WD40EFRX-68N32N0", -1, True)) + + if __name__ == "__main__": unittest.main()