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:
parent
5abe72194a
commit
d9bc6aec53
@ -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()
|
||||
|
Loading…
x
Reference in New Issue
Block a user