Test Failed
Push — develop ( 6e844f...1e25b4 )
by Nicolas
02:48
created

SmartPlugin._add_device_stats()   A

Complexity

Conditions 3

Size

Total Lines 20
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 14
nop 5
dl 0
loc 20
rs 9.7
c 0
b 0
f 0
1
#
2
# This file is part of Glances.
3
#
4
# Copyright (C) 2018 Tim Nibert <[email protected]>
5
# Copyright (C) 2026 Github@Drake7707
6
#
7
# SPDX-License-Identifier: LGPL-3.0-only
8
#
9
10
"""
11
Hard disk SMART attributes plugin.
12
Depends on pySMART and smartmontools
13
Must execute as root
14
"usermod -a -G disk USERNAME" is not sufficient unfortunately
15
SmartCTL (/usr/sbin/smartctl) must be in system path for python2.
16
17
Regular PySMART is a python2 library.
18
We are using the pySMART.smartx updated library to support both python 2 and 3.
19
20
If we only have disk group access (no root):
21
$ smartctl -i /dev/sda
22
smartctl 6.6 2016-05-31 r4324 [x86_64-linux-4.15.0-30-generic] (local build)
23
Copyright (C) 2002-16, Bruce Allen, Christian Franke, www.smartmontools.org
24
25
26
Probable ATA device behind a SAT layer
27
Try an additional '-d ata' or '-d sat' argument.
28
29
This is not very hopeful: https://medium.com/opsops/why-smartctl-could-not-be-run-without-root-7ea0583b1323
30
31
So, here is what we are going to do:
32
Check for admin access.  If no admin access, disable SMART plugin.
33
34
If smartmontools is not installed, we should catch the error upstream in plugin initialization.
35
"""
36
37
from glances.globals import is_admin
38
from glances.logger import logger
39
from glances.main import disable
40
from glances.plugins.plugin.model import GlancesPluginModel
41
42
# Import plugin specific dependency
43
try:
44
    from pySMART import DeviceList
45
    from pySMART.interface.nvme import NvmeAttributes
46
except ImportError as e:
47
    import_error_tag = True
48
    logger.warning(f"Missing Python Lib ({e}), HDD Smart plugin is disabled")
49
else:
50
    import_error_tag = False
51
52
53
def convert_attribute_to_dict(attr):
54
    return {
55
        'name': attr.name,
56
        'key': attr.name,
57
        'num': attr.num,
58
        'flags': attr.flags,
59
        'raw': attr.raw,
60
        'value': attr.value,
61
        'worst': attr.worst,
62
        'threshold': attr.thresh,
63
        'type': attr.type,
64
        'updated': attr.updated,
65
        'when_failed': attr.when_failed,
66
    }
67
68
69
# Keys for attributes that should be formatted with auto_unit (large byte values)
70
LARGE_VALUE_KEYS = frozenset([
71
    "bytesWritten",
72
    "bytesRead",
73
    "dataUnitsRead",
74
    "dataUnitsWritten",
75
    "hostReadCommands",
76
    "hostWriteCommands",
77
])
78
79
NVME_ATTRIBUTE_LABELS = {
80
    "criticalWarning": "Number of critical warnings",
81
    "_temperature": "Temperature (°C)",
82
    "availableSpare": "Available spare (%)",
83
    "availableSpareThreshold": "Available spare threshold (%)",
84
    "percentageUsed": "Percentage used (%)",
85
    "dataUnitsRead": "Data units read",
86
    "bytesRead": "Bytes read",
87
    "dataUnitsWritten": "Data units written",
88
    "bytesWritten": "Bytes written",
89
    "hostReadCommands": "Host read commands",
90
    "hostWriteCommands": "Host write commands",
91
    "controllerBusyTime": "Controller busy time (min)",
92
    "powerCycles": "Power cycles",
93
    "powerOnHours": "Power-on hours",
94
    "unsafeShutdowns": "Unsafe shutdowns",
95
    "integrityErrors": "Integrity errors",
96
    "errorEntries": "Error log entries",
97
    "warningTemperatureTime": "Warning temperature time (min)",
98
    "criticalTemperatureTime": "Critical temperature time (min)",
99
    "_logical_sector_size": "Logical sector size",
100
    "_physical_sector_size": "Physical sector size",
101
    "errors": "Errors",
102
    "tests": "Self-tests",
103
}
104
105
106
def convert_nvme_attribute_to_dict(key, value):
107
    label = NVME_ATTRIBUTE_LABELS.get(key, key)
108
109
    return {
110
        'name': label,
111
        'key': key,
112
        'value': value,
113
        'flags': None,
114
        'raw': value,
115
        'worst': None,
116
        'threshold': None,
117
        'type': None,
118
        'updated': None,
119
        'when_failed': None,
120
    }
121
122
123
def _process_standard_attributes(device_stats, attributes, hide_attributes):
124
    """Process standard SMART attributes and add them to device_stats."""
125
    for attribute in attributes:
126
        if attribute is None or attribute.name in hide_attributes:
127
            continue
128
129
        attrib_dict = convert_attribute_to_dict(attribute)
130
        num = attrib_dict.pop('num', None)
131
        if num is None:
132
            logger.debug(f'Smart plugin error - Skip attribute with no num: {attribute}')
133
            continue
134
135
        device_stats[num] = attrib_dict
136
137
138
def _process_nvme_attributes(device_stats, if_attributes, hide_attributes):
139
    """Process NVMe-specific attributes and add them to device_stats."""
140
    if not isinstance(if_attributes, NvmeAttributes):
141
        return
142
143
    for idx, (attr, value) in enumerate(vars(if_attributes).items(), start=1):
144
        attrib_dict = convert_nvme_attribute_to_dict(attr, value)
145
        if attrib_dict['name'] in hide_attributes:
146
            continue
147
148
        # Verify the value is serializable to prevent rendering errors
149
        if value is not None:
150
            try:
151
                str(value)
152
            except Exception:
153
                logger.debug(f'Unable to serialize attribute {attr} from NVME')
154
                attrib_dict['value'] = None
155
                attrib_dict['raw'] = None
156
157
        device_stats[idx] = attrib_dict
158
159
160
def get_smart_data(hide_attributes):
161
    """Get SMART attribute data.
162
163
    Returns a list of dictionaries, each containing:
164
    - 'DeviceName': Device identification string
165
    - Numeric keys: SMART attribute dictionaries with flags, raw values, etc.
166
    """
167
    stats = []
168
    try:
169
        devlist = DeviceList()
170
    except TypeError as e:
171
        logger.debug(f'Smart plugin error - Can not grab device list ({e})')
172
        global import_error_tag
173
        import_error_tag = True
174
        return stats
175
176
    for dev in devlist.devices:
177
        device_stats = {'DeviceName': f'{dev.name} {dev.model}'}
178
        _process_standard_attributes(device_stats, dev.attributes, hide_attributes)
179
        _process_nvme_attributes(device_stats, dev.if_attributes, hide_attributes)
180
        stats.append(device_stats)
181
182
    return stats
183
184
185
class SmartPlugin(GlancesPluginModel):
186
    """Glances' HDD SMART plugin."""
187
188
    def __init__(self, args=None, config=None, stats_init_value=[]):
189
        """Init the plugin."""
190
        if not is_admin() and args:
191
            disable(args, "smart")
192
            logger.debug("Current user is not admin, HDD SMART plugin disabled.")
193
194
        super().__init__(args=args, config=config)
195
196
        self.display_curse = True
197
        self.hide_attributes = self._parse_hide_attributes(config)
198
199
    def _parse_hide_attributes(self, config):
200
        """Parse and return the list of attributes to hide from config."""
201
        smart_config = config.as_dict().get('smart', {})
202
        hide_attr_str = smart_config.get('hide_attributes', '')
203
        if hide_attr_str:
204
            logger.info(f'Following SMART attributes will not be displayed: {hide_attr_str}')
205
            return hide_attr_str.split(',')
206
        return []
207
208
    @property
209
    def hide_attributes(self):
210
        """Set hide_attributes list"""
211
        return self._hide_attributes
212
213
    @hide_attributes.setter
214
    def hide_attributes(self, attr_list):
215
        """Set hide_attributes list"""
216
        self._hide_attributes = list(attr_list)
217
218
    @GlancesPluginModel._check_decorator
219
    @GlancesPluginModel._log_result_decorator
220
    def update(self):
221
        """Update SMART stats using the input method."""
222
        # Init new stats
223
        stats = self.get_init_value()
224
225
        if import_error_tag:
226
            return self.stats
227
228
        if self.input_method == 'local':
229
            # Update stats and hide some sensors(#2996)
230
            stats = [s for s in get_smart_data(self.hide_attributes) if self.is_display(s[self.get_key()])]
231
        elif self.input_method == 'snmp':
232
            pass
233
234
        # Update the stats
235
        self.stats = stats
236
237
        return self.stats
238
239
    def get_key(self):
240
        """Return the key of the list."""
241
        return 'DeviceName'
242
243
    def _format_raw_value(self, stat):
244
        """Format a raw SMART attribute value for display."""
245
        raw = stat['raw']
246
        if raw is None:
247
            return ""
248
        if stat['key'] in LARGE_VALUE_KEYS:
249
            return self.auto_unit(raw)
250
        return str(raw)
251
252
    def _get_sorted_stat_keys(self, device_stat):
253
        """Get sorted attribute keys from device stats, excluding DeviceName."""
254
        keys = [k for k in device_stat if k != 'DeviceName']
255
        try:
256
            return sorted(keys, key=int)
257
        except ValueError:
258
            # Some keys may not be numeric (see #2904)
259
            return keys
260
261
    def _add_device_stats(self, ret, device_stat, max_width, name_max_width):
262
        """Add a device's SMART stats to the curse output."""
263
        ret.append(self.curse_new_line())
264
        ret.append(self.curse_add_line(f'{device_stat["DeviceName"][:max_width]:{max_width}}'))
265
266
        for key in self._get_sorted_stat_keys(device_stat):
267
            stat = device_stat[key]
268
            ret.append(self.curse_new_line())
269
270
            # Attribute name
271
            name = stat['name'][: name_max_width - 1].replace('_', ' ')
272
            ret.append(self.curse_add_line(f' {name:{name_max_width - 1}}'))
273
274
            # Attribute value
275
            try:
276
                value_str = self._format_raw_value(stat)
277
                ret.append(self.curse_add_line(f'{value_str:>8}'))
278
            except Exception:
279
                logger.debug(f"Failed to serialize {key}")
280
                ret.append(self.curse_add_line(""))
281
282
    def msg_curse(self, args=None, max_width=None):
283
        """Return the dict to display in the curse interface."""
284
        ret = []
285
286
        if import_error_tag or not self.stats or self.is_disabled():
287
            return ret
288
289
        if not max_width:
290
            logger.debug(f"No max_width defined for the {self.plugin_name} plugin, it will not be displayed.")
291
            return ret
292
293
        name_max_width = max_width - 6
294
295
        # Header
296
        ret.append(self.curse_add_line(f'{"SMART disks":{name_max_width}}', "TITLE"))
297
298
        # Device data
299
        for device_stat in self.stats:
300
            self._add_device_stats(ret, device_stat, max_width, name_max_width)
301
302
        return ret
303