Skip to content

Commit 6248924

Browse files
committed
Enable hot plugging temp and fan sources
* Fan and temp sources can be removed or added during execution * We handle this in the PR by marking removed sensors as N/A * Re-adding them will make the sensors appear back
1 parent 2431787 commit 6248924

8 files changed

Lines changed: 157 additions & 51 deletions

File tree

s_tui/s_tui.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -699,14 +699,14 @@ def _load_config(self, t_thresh):
699699
]
700700
for source in sources:
701701
try:
702-
options = list(self.conf.items(source + ",Graphs"))
703-
for option in options:
704-
# Returns tuples of values in order
705-
self.graphs_default_conf[source].append(str_to_bool(option[1]))
706-
options = list(self.conf.items(source + ",Summaries"))
707-
for option in options:
708-
# Returns tuples of values in order
709-
self.summary_default_conf[source].append(str_to_bool(option[1]))
702+
for option_name, option_value in self.conf.items(source + ",Graphs"):
703+
self.graphs_default_conf[source][option_name] = str_to_bool(
704+
option_value
705+
)
706+
for option_name, option_value in self.conf.items(source + ",Summaries"):
707+
self.summary_default_conf[source][option_name] = str_to_bool(
708+
option_value
709+
)
710710
except (
711711
AttributeError,
712712
ValueError,
@@ -752,8 +752,8 @@ def __init__(self, args):
752752

753753
self.smooth_graph_mode = False
754754

755-
self.summary_default_conf = defaultdict(list)
756-
self.graphs_default_conf = defaultdict(list)
755+
self.summary_default_conf = defaultdict(dict)
756+
self.graphs_default_conf = defaultdict(dict)
757757

758758
self.temp_thresh = None
759759

s_tui/sensors_menu.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222

2323
from __future__ import print_function
2424
from __future__ import absolute_import
25-
import copy
2625

2726
import urwid
2827
from s_tui.sturwid.ui_elements import ViListBox
@@ -52,15 +51,13 @@ def __init__(self, return_fn, source_list, default_source_conf):
5251
for source in source_list:
5352
source_name = source.get_source_name()
5453

55-
# get the saves sensor visibility list
56-
if default_source_conf[source_name]:
57-
# print(str(default_source_conf[source_name]))
58-
self.sensor_status_dict[source_name] = copy.deepcopy(
59-
default_source_conf[source_name]
60-
)
61-
else:
62-
self.sensor_status_dict[source_name] = [True] * len(
63-
source.get_sensor_list()
54+
# Build per-sensor visibility from config dict (keyed by name)
55+
conf = default_source_conf[source_name] # dict or empty dict
56+
self.sensor_status_dict[source_name] = []
57+
for sensor in source.get_sensor_list():
58+
# ConfigParser lowercases keys, so compare lowercase
59+
self.sensor_status_dict[source_name].append(
60+
conf.get(sensor.lower(), True)
6461
)
6562

6663
self.sensor_button_dict[source_name] = []

s_tui/sources/fan_source.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ def __init__(self):
6060
self.is_available = False
6161
return
6262

63+
self._sensor_lookup = {}
6364
for key, value in sensors_dict.items():
6465
sensor_name = key
6566
for sensor_idx, sensor in enumerate(value):
@@ -74,7 +75,9 @@ def __init__(self):
7475
logging.debug("Fan sensor name %s", full_name)
7576

7677
self.available_sensors.append(full_name)
78+
self._sensor_lookup[(key, sensor_idx)] = len(self.available_sensors) - 1
7779

80+
self.sensor_available = [True] * len(self.available_sensors)
7881
self.last_measurement = [0] * len(self.available_sensors)
7982

8083
def update(self):
@@ -90,13 +93,24 @@ def update(self):
9093
if sample is None:
9194
logging.debug("sensors_fans() returned None, keeping stale data")
9295
return
93-
self.last_measurement = []
94-
for sensor in sample.values():
95-
for minor_sensor in sensor:
96-
# Ignore unreasonable fan speeds
96+
97+
updated = set()
98+
for key, sensors in sample.items():
99+
for sensor_idx, minor_sensor in enumerate(sensors):
100+
idx = self._sensor_lookup.get((key, sensor_idx))
101+
if idx is None:
102+
continue # new sensor not in original list
97103
if minor_sensor.current > 10000:
104+
self.sensor_available[idx] = False
98105
continue
99-
self.last_measurement.append(int(minor_sensor.current))
106+
self.last_measurement[idx] = int(minor_sensor.current)
107+
self.sensor_available[idx] = True
108+
updated.add(idx)
109+
110+
# Mark sensors not seen in this sample as unavailable
111+
for idx in range(len(self.available_sensors)):
112+
if idx not in updated:
113+
self.sensor_available[idx] = False
100114

101115
def get_edge_triggered(self):
102116
return False

s_tui/sources/temp_source.py

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def __init__(self, temp_thresh=None):
7676
logging.debug("Unable to create sensors dict")
7777
self.is_available = False
7878
return
79+
self._sensor_lookup = {}
7980
multi_sensors = []
8081
for key, value in sensors_dict.items():
8182
sensor_name = "".join(key.title().split(" "))
@@ -95,6 +96,7 @@ def __init__(self, temp_thresh=None):
9596

9697
logging.debug("Temp sensor name %s", full_name)
9798
self.available_sensors.append(full_name)
99+
self._sensor_lookup[(key, sensor_idx)] = len(self.available_sensors) - 1
98100

99101
self.sensor_available = [True] * len(self.available_sensors)
100102
self.last_measurement = [0] * len(self.available_sensors)
@@ -125,25 +127,40 @@ def update(self):
125127
except IOError:
126128
return
127129

128-
self.last_measurement = []
129-
self.last_thresholds = []
130-
for sensor in sample:
131-
for minor_sensor in sample[sensor]:
130+
updated = set()
131+
for key, sensors in sample.items():
132+
for sensor_idx, minor_sensor in enumerate(sensors):
133+
idx = self._sensor_lookup.get((key, sensor_idx))
134+
if idx is None:
135+
continue # new sensor not in original list
132136
if minor_sensor.current <= 1.0 or minor_sensor.current >= 127.0:
137+
self.sensor_available[idx] = False
133138
continue
134-
self.last_measurement.append(minor_sensor.current)
139+
self.last_measurement[idx] = minor_sensor.current
135140
if (
136141
minor_sensor.high is not None
137142
and minor_sensor.high
138143
and minor_sensor.high < 127.0
139144
and self.temp_thresh_is_set is False
140145
):
141-
self.last_thresholds.append(minor_sensor.high)
146+
self.last_thresholds[idx] = minor_sensor.high
142147
else:
143-
self.last_thresholds.append(self.temp_thresh)
144-
145-
if self.last_measurement:
146-
self.max_last_temp = max(self.last_measurement)
148+
self.last_thresholds[idx] = self.temp_thresh
149+
self.sensor_available[idx] = True
150+
updated.add(idx)
151+
152+
# Mark sensors not seen in this sample as unavailable
153+
for idx in range(len(self.available_sensors)):
154+
if idx not in updated:
155+
self.sensor_available[idx] = False
156+
157+
available_temps = [
158+
self.last_measurement[i]
159+
for i in range(len(self.available_sensors))
160+
if self.sensor_available[i]
161+
]
162+
if available_temps:
163+
self.max_last_temp = max(available_temps)
147164
# Call check for hooks
148165
Source.update(self)
149166

tests/test_config_delimiter.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import os
1414
import pytest
15+
from collections import defaultdict
1516
from unittest.mock import MagicMock, patch
1617
import configparser
1718

@@ -153,3 +154,69 @@ def test_configparser_delimiter_consistency(self):
153154
)
154155
finally:
155156
os.unlink(temp_file)
157+
158+
159+
class TestConfigSensorNameMatching:
160+
"""Test that sensor config is matched by name, not position.
161+
162+
Verifies the fix for config loading that uses sensor names as keys
163+
instead of positional lists, so reordering/adding/removing sensors
164+
between restarts doesn't misalign visibility settings.
165+
"""
166+
167+
def test_config_fewer_sensors_than_system(self):
168+
"""Config has fewer sensors than currently on the system.
169+
170+
New sensors should default to visible (True).
171+
"""
172+
from s_tui.sensors_menu import SensorsMenu
173+
174+
# Create a mock source with 3 sensors
175+
source = MagicMock()
176+
source.get_source_name.return_value = "Temp"
177+
source.get_sensor_list.return_value = ["Core0,0", "Core1,0", "Core2,0"]
178+
179+
# Config only knows about 2 of them (dict keyed by lowercase name)
180+
default_conf = {"Temp": {"core0,0": True, "core1,0": False}}
181+
182+
menu = SensorsMenu(MagicMock(), [source], default_conf)
183+
active = menu.active_sensors["Temp"]
184+
assert active[0] is True # Core0,0 from config
185+
assert active[1] is False # Core1,0 from config
186+
assert active[2] is True # Core2,0 new, defaults visible
187+
188+
def test_config_more_sensors_than_system(self):
189+
"""Config has more sensors than currently on the system.
190+
191+
Extra config entries should be silently ignored.
192+
"""
193+
from s_tui.sensors_menu import SensorsMenu
194+
195+
# System has only 1 sensor
196+
source = MagicMock()
197+
source.get_source_name.return_value = "Temp"
198+
source.get_sensor_list.return_value = ["Core0,0"]
199+
200+
# Config has entries for 3 sensors
201+
default_conf = {
202+
"Temp": {"core0,0": False, "core1,0": True, "core2,0": True}
203+
}
204+
205+
menu = SensorsMenu(MagicMock(), [source], default_conf)
206+
active = menu.active_sensors["Temp"]
207+
assert len(active) == 1
208+
assert active[0] is False # Core0,0 from config
209+
210+
def test_config_empty_defaults_all_visible(self):
211+
"""No config for a source means all sensors default to visible."""
212+
from s_tui.sensors_menu import SensorsMenu
213+
214+
source = MagicMock()
215+
source.get_source_name.return_value = "Fan"
216+
source.get_sensor_list.return_value = ["fan0", "fan1"]
217+
218+
default_conf = defaultdict(dict)
219+
220+
menu = SensorsMenu(MagicMock(), [source], default_conf)
221+
active = menu.active_sensors["Fan"]
222+
assert active == [True, True]

tests/test_fan_source.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,11 @@ def test_filters_unreasonable_speeds(self, mocker):
4646
src = FanSource()
4747
src.update()
4848
readings = src.get_reading_list()
49-
assert len(readings) == 1
49+
# Both sensors are in the list, but the unreasonable one is marked unavailable
50+
assert len(readings) == 2
5051
assert readings[0] == 1200
52+
assert src.sensor_available[0] is True
53+
assert src.sensor_available[1] is False
5154

5255
def test_edge_triggered_always_false(self, mock_sensors_fans):
5356
src = FanSource()

tests/test_runtime_disruption.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,7 @@ class TestTempSensorChanges:
146146
def test_temp_sensor_disappears(self, mocker):
147147
"""sensors_temperatures() returns fewer sensors mid-run.
148148
149-
Current behaviour: no crash; sensor list retains original length,
150-
missing sensors may show stale data.
149+
Disappeared sensor is marked N/A via sensor_available.
151150
"""
152151
sensors_2 = [
153152
SensorTemperature(label="Core 0", current=55.0, high=80.0, critical=100.0),
@@ -168,11 +167,14 @@ def test_temp_sensor_disappears(self, mocker):
168167

169168
summary = src.get_sensors_summary()
170169
assert summary is not None
170+
assert len(src.get_sensor_list()) == 2
171+
# Core 0 still available, Core 1 disappeared
172+
assert src.sensor_available[0] is True
173+
assert src.sensor_available[1] is False
174+
# Summary shows N/A for disappeared sensor
175+
values = list(summary.values())
176+
assert values[1] == "N/A"
171177

172-
@pytest.mark.xfail(
173-
strict=True,
174-
reason="TempSource summary IndexError when new sensor appears mid-run",
175-
)
176178
def test_temp_sensor_appears(self, mocker):
177179
"""New sensor shows up in sensors_temperatures() mid-run."""
178180
sensors_1 = [
@@ -235,8 +237,7 @@ def test_fan_typeerror_during_update(self, mocker):
235237
def test_fan_sensor_disappears(self, mocker):
236238
"""sensors_fans() returns empty dict mid-run.
237239
238-
Current behaviour: no crash; sensor list retains original length,
239-
missing fans show stale data.
240+
Disappeared fan sensor is marked N/A via sensor_available.
240241
"""
241242
fans = make_fans_dict(count=1)
242243
mocker.patch("psutil.sensors_fans", return_value=fans)
@@ -249,6 +250,11 @@ def test_fan_sensor_disappears(self, mocker):
249250

250251
summary = src.get_sensors_summary()
251252
assert summary is not None
253+
assert len(src.get_sensor_list()) == 1
254+
assert src.sensor_available[0] is False
255+
# Summary shows N/A for disappeared sensor
256+
values = list(summary.values())
257+
assert values[0] == "N/A"
252258

253259

254260
# =====================================================================

tests/test_sensors_menu.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@ def simple_sources():
2626

2727
@pytest.fixture
2828
def default_conf():
29-
"""Default config: all sensors visible."""
29+
"""Default config: all sensors visible (dict keyed by lowercase sensor name)."""
3030
return {
31-
"CPU Util": [True, True],
32-
"Temp": [True, True, True],
31+
"CPU Util": {"avg": True, "core 0": True},
32+
"Temp": {"core 0": True, "core 1": True, "core 2": True},
3333
}
3434

3535

@@ -46,9 +46,9 @@ def menu(simple_sources, default_conf):
4646

4747
class TestSensorsMenuInit:
4848
def test_active_sensors_match_defaults(self, menu, default_conf):
49-
"""active_sensors should mirror the default config."""
50-
for name, states in default_conf.items():
51-
assert menu.active_sensors[name] == states
49+
"""active_sensors should mirror the default config values."""
50+
for name, conf_dict in default_conf.items():
51+
assert menu.active_sensors[name] == list(conf_dict.values())
5252

5353
def test_sensor_button_dict_populated(self, menu):
5454
"""Each source should have checkbox entries."""
@@ -72,7 +72,9 @@ def test_main_window_is_urwid_widget(self, menu):
7272

7373
def test_no_default_conf_defaults_to_all_true(self, simple_sources):
7474
"""When default_source_conf entry is falsy, all sensors default True."""
75-
conf = {"CPU Util": None, "Temp": None}
75+
from collections import defaultdict
76+
77+
conf = defaultdict(dict)
7678
return_fn = MagicMock()
7779
m = SensorsMenu(return_fn, simple_sources, conf)
7880
assert m.active_sensors["CPU Util"] == [True, True]

0 commit comments

Comments
 (0)