From 9ef02e4110cf7813bab339d414acd8c98d5c737f Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 2 Feb 2021 10:31:02 +0100 Subject: [PATCH] Add eMMC life-time estimate support (#2413) * Add eMMC life-time estimate support Expose life time estimate as Host Info property "ssd_life_time" * Fix pytest * Fix path to helper * Allow protected access in tests * Apply suggestions from code review Rename SSD to disk. Co-authored-by: Pascal Vizeli * Rename functions as well * Update host.py Co-authored-by: Pascal Vizeli --- supervisor/api/host.py | 2 + supervisor/const.py | 1 + supervisor/hardware/helper.py | 77 +++++++++++++++++++++++++++++++++++ supervisor/host/info.py | 7 ++++ tests/hardware/test_helper.py | 27 ++++++++++++ 5 files changed, 114 insertions(+) diff --git a/supervisor/api/host.py b/supervisor/api/host.py index ad77e2f26..6da299e27 100644 --- a/supervisor/api/host.py +++ b/supervisor/api/host.py @@ -11,6 +11,7 @@ from ..const import ( ATTR_DEPLOYMENT, ATTR_DESCRIPTON, ATTR_DISK_FREE, + ATTR_DISK_LIFE_TIME, ATTR_DISK_TOTAL, ATTR_DISK_USED, ATTR_FEATURES, @@ -43,6 +44,7 @@ class APIHost(CoreSysAttributes): ATTR_DISK_FREE: self.sys_host.info.free_space, ATTR_DISK_TOTAL: self.sys_host.info.total_space, ATTR_DISK_USED: self.sys_host.info.used_space, + ATTR_DISK_LIFE_TIME: self.sys_host.info.disk_life_time, ATTR_FEATURES: self.sys_host.features, ATTR_HOSTNAME: self.sys_host.info.hostname, ATTR_KERNEL: self.sys_host.info.kernel, diff --git a/supervisor/const.py b/supervisor/const.py index dda5ffb8b..b053217b8 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -236,6 +236,7 @@ ATTR_SNAPSHOTS = "snapshots" ATTR_SOURCE = "source" ATTR_SQUASH = "squash" ATTR_SSID = "ssid" +ATTR_DISK_LIFE_TIME = "disk_life_time" ATTR_SSL = "ssl" ATTR_STAGE = "stage" ATTR_STARTUP = "startup" diff --git a/supervisor/hardware/helper.py b/supervisor/hardware/helper.py index 55a0443d3..5bdb3e856 100644 --- a/supervisor/hardware/helper.py +++ b/supervisor/hardware/helper.py @@ -19,6 +19,10 @@ _RE_BOOT_TIME: re.Pattern = re.compile(r"btime (\d+)") _RE_HIDE_SYSFS: re.Pattern = re.compile(r"/sys/devices/virtual/(?:tty|block|vc)/.*") +_MOUNTINFO: Path = Path("/proc/self/mountinfo") +_BLOCK_DEVICE_CLASS = "/sys/class/block/{}" +_BLOCK_DEVICE_EMMC_LIFE_TIME = "/sys/block/{}/device/life_time" + class HwHelper(CoreSysAttributes): """Representation of an interface to procfs, sysfs and udev.""" @@ -78,3 +82,76 @@ class HwHelper(CoreSysAttributes): """Return free space (GiB) on disk for path.""" _, _, free = shutil.disk_usage(path) return round(free / (1024.0 ** 3), 1) + + def _get_mountinfo(self, path: str) -> str: + mountinfo = _MOUNTINFO.read_text() + for line in mountinfo.splitlines(): + mountinfoarr = line.split() + if mountinfoarr[4] == path: + return mountinfoarr + return None + + def _get_mount_source(self, path: str) -> str: + mountinfoarr = self._get_mountinfo(path) + + if mountinfoarr is None: + return None + + # Find optional field separator + optionsep = 6 + while mountinfoarr[optionsep] != "-": + optionsep += 1 + return mountinfoarr[optionsep + 2] + + def _try_get_emmc_life_time(self, device_name: str) -> float: + # Get eMMC life_time + life_time_path = Path(_BLOCK_DEVICE_EMMC_LIFE_TIME.format(device_name)) + + if not life_time_path.exists(): + return None + + # JEDEC health status DEVICE_LIFE_TIME_EST_TYP_A/B + emmc_life_time = life_time_path.read_text().split() + + if len(emmc_life_time) < 2: + return None + + # Type B life time estimate represents the user partition. + life_time_value = int(emmc_life_time[1], 16) + + # 0=Not defined, 1-10=0-100% device life time used, 11=Exceeded + if life_time_value == 0: + return None + + if life_time_value == 11: + logging.warning( + "eMMC reports that its estimated life-time has been exceeded!" + ) + return 100.0 + + # Return the pessimistic estimate (0x02 -> 10%-20%, return 20%) + return life_time_value * 10.0 + + def get_disk_life_time(self, path: Union[str, Path]) -> float: + """Return life time estimate of the underlying SSD drive.""" + mount_source = self._get_mount_source(str(path)) + if mount_source == "overlay": + return None + + mount_source_path = Path(mount_source) + if not mount_source_path.is_block_device(): + return None + + # This looks a bit funky but it is more or less what lsblk is doing to get + # the parent dev reliably + + # Get class device... + mount_source_device_part = Path( + _BLOCK_DEVICE_CLASS.format(mount_source_path.name) + ) + + # ... resolve symlink and get parent device from that path. + mount_source_device_name = mount_source_device_part.resolve().parts[-2] + + # Currently only eMMC block devices supported + return self._try_get_emmc_life_time(mount_source_device_name) diff --git a/supervisor/host/info.py b/supervisor/host/info.py index b0eaf6445..d6cacddfc 100644 --- a/supervisor/host/info.py +++ b/supervisor/host/info.py @@ -72,6 +72,13 @@ class InfoCenter(CoreSysAttributes): self.coresys.config.path_supervisor ) + @property + def disk_life_time(self) -> float: + """Return the estimated life-time usage (in %) of the SSD storing the data directory.""" + return self.sys_hardware.helper.get_disk_life_time( + self.coresys.config.path_supervisor + ) + async def get_dmesg(self) -> bytes: """Return host dmesg output.""" proc = await asyncio.create_subprocess_shell( diff --git a/tests/hardware/test_helper.py b/tests/hardware/test_helper.py index 65b938057..f05a45541 100644 --- a/tests/hardware/test_helper.py +++ b/tests/hardware/test_helper.py @@ -1,4 +1,5 @@ """Test hardware utils.""" +# pylint: disable=protected-access from pathlib import Path from unittest.mock import MagicMock, patch @@ -98,3 +99,29 @@ def test_used_space(coresys): used = coresys.hardware.helper.get_disk_used_space("/data") assert used == 8.0 + + +def test_get_mountinfo(coresys): + """Test mountinfo helper.""" + mountinfo = coresys.hardware.helper._get_mountinfo("/proc") + assert mountinfo[4] == "/proc" + + +def test_get_mount_source(coresys): + """Test mount source helper.""" + # For /proc the mount source is known to be "proc"... + mount_source = coresys.hardware.helper._get_mount_source("/proc") + assert mount_source == "proc" + + +def test_try_get_emmc_life_time(coresys, tmp_path): + """Test eMMC life time helper.""" + fake_life_time = tmp_path / "fake-mmcblk0-lifetime" + fake_life_time.write_text("0x01 0x02\n") + + with patch( + "supervisor.hardware.helper._BLOCK_DEVICE_EMMC_LIFE_TIME", + str(tmp_path / "fake-{}-lifetime"), + ): + value = coresys.hardware.helper._try_get_emmc_life_time("mmcblk0") + assert value == 20.0