1
0
mirror of https://github.com/HorlogeSkynet/archey4 synced 2025-06-06 14:54:30 +02:00

Better temperature detection feature (closes #45)

On the idea of @EntityCuber in #45, this patch re-implements the **Temperature** entry using `lm-sensors` package (when available).
It also greatly improves the entry's testing suite and should be fully-backward compatible with previous versions of Archey 4.
This commit is contained in:
Samuel FORESTIER 2019-09-21 09:16:05 +00:00 committed by GitHub
parent 5abe72194a
commit d9bc6aec53
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 262 additions and 52 deletions

@ -42,6 +42,7 @@ The answer is [here](https://blog.samuel.domains/archey4).
| Environments | Packages | Reasons | Notes |
| :----------- | :--------: | :-----------------------------------: | :---: |
| All | `dnsutils` or `bind-tools` | **WAN_IP** would be detected faster | Would provide `dig` |
| All | `lm-sensors` or `lm_sensors` | **Temperature** would be more accurate | φ |
| Graphical | `pciutils` or `pciutils-ng` | **GPU** wouldn't be detected without it | Would provide `lspci` |
| Graphical | `wmctrl` | **WindowManager** would be more accurate | φ |
| Virtual | `virt-what` and `dmidecode` | **Model** would contain details about the hypervisor | `archey` would have to be run as **root** |

@ -1,5 +1,6 @@
"""Temperature detection class"""
import json
import re
from glob import glob
@ -10,38 +11,85 @@ from archey.configuration import Configuration
class Temperature:
"""
On Raspberry, retrieves temperature from the `vcgencmd` binary.
Anyway, retrieves values from system thermal zones files.
Tries to compute an average temperature from `sensors` (LM-Sensors).
If not available, falls back on system thermal zones files.
On Raspberry devices, retrieves temperature from the `vcgencmd` binary.
"""
def __init__(self):
# The configuration object is needed to retrieve some settings below.
configuration = Configuration()
self.temps = []
# Tries `sensors` at first.
self._run_sensors()
# On error (list still empty), checks for system thermal zones files.
if not self.temps:
self._poll_thermal_zones()
# Tries `vcgencmd` for Raspberry devices.
self._run_vcgencmd()
# No value could be fetched...
if not self.temps:
self.value = configuration.get('default_strings')['not_detected']
return
# Let's DRY some constants once.
use_fahrenheit = configuration.get('temperature')['use_fahrenheit']
char_before_unit = configuration.get('temperature')['char_before_unit']
temps = []
# Conversion to Fahrenheit if needed.
if use_fahrenheit:
for i in range(len(self.temps)):
self.temps[i] = self._convert_to_fahrenheit(self.temps[i])
try:
# Let's try to retrieve a value from the Broadcom chip on Raspberry
temp = float(
re.search(
r'\d+\.\d+',
check_output(
['/opt/vc/bin/vcgencmd', 'measure_temp'],
stderr=DEVNULL, universal_newlines=True
)
).group(0)
# Final average computation.
self.value = '{0}{1}{2}'.format(
str(round(sum(self.temps) / len(self.temps), 1)),
char_before_unit,
'F' if use_fahrenheit else 'C'
)
# Multiple values ? Show the hottest.
if len(self.temps) > 1:
self.value += ' (Max. {0}{1}{2})'.format(
str(round(max(self.temps), 1)),
char_before_unit,
'F' if use_fahrenheit else 'C'
)
temps.append(
self._convert_to_fahrenheit(temp)
if use_fahrenheit else temp
def _run_sensors(self):
# Uses the `sensors` program (from LM-Sensors) to interrogate thermal chip-sets.
try:
sensors_output = check_output(
['sensors', '-A', '-j'],
stderr=DEVNULL, universal_newlines=True
)
except (FileNotFoundError, CalledProcessError):
pass
return
# Now we just check for values within files present in the path below
try:
sensors_data = json.loads(sensors_output)
# For backward compatibility with Python versions prior to 3.5.0
# we use `ValueError` instead of `json.JSONDecodeError`.
except ValueError:
return
# Iterates over the chip-sets outputs to filter interesting values.
for _, chipset_data in sensors_data.items():
for _, values in chipset_data.items():
for key_name, value in values.items():
if key_name.endswith('_input') and value != 0.0:
self.temps.append(value)
# There is only one `*_input` field here, let's stop the current iteration.
break
def _poll_thermal_zones(self):
# We just check for values within files present in the path below.
for thermal_file in glob('/sys/class/thermal/thermal_zone*/temp'):
with open(thermal_file) as file:
try:
@ -51,28 +99,27 @@ class Temperature:
continue
if temp != 0.0:
temps.append(
self._convert_to_fahrenheit(temp)
if use_fahrenheit
else temp
)
self.temps.append(temp)
if temps:
self.value = '{0}{1}{2}'.format(
str(round(sum(temps) / len(temps), 1)),
char_before_unit,
'F' if use_fahrenheit else 'C'
def _run_vcgencmd(self):
# Let's try to retrieve a value from the Broadcom chip on Raspberry.
try:
vcgencmd_output = check_output(
['/opt/vc/bin/vcgencmd', 'measure_temp'],
stderr=DEVNULL, universal_newlines=True
)
if len(temps) > 1:
self.value += ' (Max. {0}{1}{2})'.format(
str(round(max(temps), 1)),
char_before_unit,
'F' if use_fahrenheit else 'C'
)
except (FileNotFoundError, CalledProcessError):
return
else:
self.value = configuration.get('default_strings')['not_detected']
self.temps.append(
float(
re.search(
r'\d+\.\d+',
vcgencmd_output
).group(0)
)
)
@staticmethod
def _convert_to_fahrenheit(temp):

@ -2,15 +2,19 @@
import os
import tempfile
from subprocess import CalledProcessError
import unittest
from unittest.mock import patch
from archey.entries.temperature import Temperature
class TestTemperatureEntry(unittest.TestCase):
"""
Based on `vcgencmd` and some sensor files, this module verifies temperature computations.
Based on `sensors`, `vcgencmd` and thermal files, this module verifies temperature computations.
"""
def setUp(self):
@ -35,7 +39,10 @@ class TestTemperatureEntry(unittest.TestCase):
@patch(
'archey.entries.temperature.check_output',
return_value='temp=42.8\'C\n'
side_effect=[
FileNotFoundError(),
'temp=42.8\'C\n'
]
)
@patch(
'archey.entries.temperature.glob',
@ -53,11 +60,14 @@ class TestTemperatureEntry(unittest.TestCase):
Test for `vcgencmd` output only (no sensor files).
Only one value is retrieved, so no maximum is displayed (see #39).
"""
self.assertRegex(Temperature().value, r'42\.8.?.?')
self.assertEqual(Temperature().value, '42.8 C')
@patch(
'archey.entries.temperature.check_output',
return_value='temp=40.0\'C\n'
side_effect=[
FileNotFoundError(),
'temp=40.0\'C\n'
]
)
@patch('archey.entries.temperature.glob')
@patch(
@ -70,11 +80,14 @@ class TestTemperatureEntry(unittest.TestCase):
def test_vcgencmd_and_files(self, _, glob_mock, __):
"""Tests `vcgencmd` output AND sensor files"""
glob_mock.return_value = [file.name for file in self.tempfiles]
self.assertRegex(Temperature().value, r'45\.0.?.? \(Max\. 50\.0.?.?\)')
self.assertEqual(Temperature().value, '45.0 C (Max. 50.0 C)')
@patch(
'archey.entries.temperature.check_output',
side_effect=FileNotFoundError() # No temperature from `vcgencmd` call
side_effect=[
FileNotFoundError(), # No temperature from `sensors` call
FileNotFoundError() # No temperature from `vcgencmd` call
]
)
@patch('archey.entries.temperature.glob')
@patch(
@ -84,34 +97,183 @@ class TestTemperatureEntry(unittest.TestCase):
{'char_before_unit': '@'}
]
)
def test_files_only_plus_fahrenheit(self, _, glob_mock, __):
def test_files_only_in_fahrenheit(self, _, glob_mock, __):
"""Test sensor files only, Fahrenheit (naive) conversion and special degree character"""
glob_mock.return_value = [file.name for file in self.tempfiles]
self.assertRegex(
self.assertEqual(
Temperature().value,
r'116\.0@F \(Max\. 122\.0@F\)' # 46.6 converted into Fahrenheit
'116.0@F (Max. 122.0@F)' # 46.7 and 50.0 converted into Fahrenheit.
)
@patch(
'archey.entries.temperature.check_output',
side_effect=FileNotFoundError() # No temperature from `vcgencmd` call
side_effect=[
FileNotFoundError(), # No temperature from `sensors` call.
FileNotFoundError() # No temperature from `vcgencmd` call.
]
)
@patch(
'archey.entries.temperature.glob',
return_value=[] # No temperature from file will be retrieved
return_value=[] # No temperature from file will be retrieved.
)
@patch(
'archey.entries.temperature.Configuration.get',
side_effect=[
{'use_fahrenheit': None}, # Needed key.
{'char_before_unit': None}, # Needed key.
{'not_detected': 'Not detected'}
]
return_value={'not_detected': 'Not detected'}
)
def test_no_output(self, _, __, ___):
"""Test when no value could be retrieved (anyhow)"""
self.assertEqual(Temperature().value, 'Not detected')
@patch(
'archey.entries.temperature.check_output', # Mock the `sensors` call.
side_effect=[
"""\
{
"who-care-about":{
"temp1":{
"temp1_input": 45.000,
"temp1_crit": 128.000
},
"temp2":{
"temp2_input": 0.000,
"temp2_crit": 128.000
},
"temp3":{
"temp3_input": 38.000,
"temp3_crit": 128.000
},
"temp4":{
"temp4_input": 39.000,
"temp4_crit": 128.000
},
"temp5":{
"temp5_input": 0.000,
"temp5_crit": 128.000
},
"temp6":{
"temp6_input": 114.000,
"temp6_crit": 128.000
}
},
"the-chipsets-names":{
"what-are":{
"temp1_input": 45.000,
"temp1_max": 100.000,
"temp1_crit": 100.000,
"temp1_crit_alarm": 0.000
},
"those":{
"temp2_input": 43.000,
"temp2_max": 100.000,
"temp2_crit": 100.000,
"temp2_crit_alarm": 0.000
},
"identifiers":{
"temp3_input": 44.000,
"temp3_max": 100.000,
"temp3_crit": 100.000,
"temp3_crit_alarm": 0.000
}
}
}
""",
FileNotFoundError() # No temperature from `vcgencmd` call.
]
)
@patch(
'archey.entries.temperature.Configuration.get',
side_effect=[
{'use_fahrenheit': True},
{'char_before_unit': ' '}
]
)
def test_sensors_only_in_fahrenheit(self, _, __):
"""Test computations around `sensors` output and Fahrenheit (naive) conversion"""
self.assertEqual(
Temperature().value,
'126.6 F (Max. 237.2 F)' # 52.6 and 114.0 converted into Fahrenheit.
)
@patch(
'archey.entries.temperature.check_output',
side_effect=[
CalledProcessError(1, 'sensors'), # `sensors` will hard fail.
FileNotFoundError() # No temperature from `vcgencmd` call
]
)
@patch('archey.entries.temperature.glob')
@patch(
'archey.entries.temperature.Configuration.get',
side_effect=[
{'use_fahrenheit': False},
{'char_before_unit': 'o'}
]
)
def test_sensors_error_1(self, _, glob_mock, ___):
"""Test `sensors` (hard) failure handling and polling from files in Celsius"""
glob_mock.return_value = [file.name for file in self.tempfiles]
self.assertEqual(
Temperature().value,
'46.7oC (Max. 50.0oC)'
)
@patch(
'archey.entries.temperature.check_output',
side_effect=[ # JSON decoding from `sensors` will fail..
"""\
{
"Is this JSON valid ?": [
"You", "should", "look", "twice.",
]
}
""",
FileNotFoundError() # No temperature from `vcgencmd` call
]
)
@patch('archey.entries.temperature.glob')
@patch(
'archey.entries.temperature.Configuration.get',
side_effect=[
{'use_fahrenheit': False},
{'char_before_unit': 'o'}
]
)
def test_sensors_error_2(self, _, glob_mock, ___):
"""Test `sensors` (hard) failure handling and polling from files in Celsius"""
glob_mock.return_value = [file.name for file in self.tempfiles]
self.assertEqual(
Temperature().value,
'46.7oC (Max. 50.0oC)'
)
@patch(
'archey.entries.temperature.check_output',
side_effect=[
FileNotFoundError(), # No temperature from `sensors` call.
FileNotFoundError() # No temperature from `vcgencmd` call.
]
)
@patch(
'archey.entries.temperature.glob',
return_value=[] # No temperature from file will be retrieved.
)
@patch(
'archey.entries.temperature.Configuration.get',
side_effect=[
{'not_detected': "Not detected"} # Needed key.
]
)
def test_celsius_to_fahrenheit_conversion(self, _, __, ___):
"""Simple tests for the `_convert_to_fahrenheit` static method"""
temperature = Temperature()
# pylint: disable=protected-access
self.assertAlmostEqual(temperature._convert_to_fahrenheit(-273.15), -459.67)
self.assertAlmostEqual(temperature._convert_to_fahrenheit(0.0), 32.0)
self.assertAlmostEqual(temperature._convert_to_fahrenheit(21.0), 69.8)
self.assertAlmostEqual(temperature._convert_to_fahrenheit(37.0), 98.6)
self.assertAlmostEqual(temperature._convert_to_fahrenheit(100.0), 212.0)
# pylint: enable=protected-access
if __name__ == '__main__':
unittest.main()