Add support for network and cooling fan

Add support for a network display, and for a coolin fan.  Note that at
the moment we support one and only one coolin fan, along with the CPU
fan.
This commit is contained in:
Jeff Curless
2025-12-10 18:22:38 -05:00
parent adab150746
commit 65774dbeaa
4 changed files with 181 additions and 45 deletions

46
monitor/fanspeed.py Normal file
View File

@@ -0,0 +1,46 @@
import RPi.GPIO as GPIO
import time
WAIT_TIME = 1
class GetCaseFanSpeed:
TACH = 18
PULSE = 2
def __init__( self, tachPin = TACH):
self._tachPin = tachPin
self._rpm = 0
self._t = time.time()
self.GPIO = GPIO
self.GPIO.setmode( self.GPIO.BCM )
self.GPIO.setwarnings( False )
self.GPIO.setup( self._tachPin, self.GPIO.IN, pull_up_down=self.GPIO.PUD_UP )
self.GPIO.add_event_detect( self._tachPin, self.GPIO.FALLING, self._calcRPM )
def __del__( self ):
self.GPIO.cleanup()
def _calcRPM( self, n ):
dt = time.time() - self._t
if dt < 0.005: return # Reject spuriously short pulses
freq = 1 / dt
self._rpm = (freq / GetCaseFanSpeed.PULSE) * 60
self._t = time.time()
@property
def RPM( self ):
return self._rpm
if __name__ == "__main__":
fanSpeed = GetCaseFanSpeed()
try:
for i in range( 10 ):
print( f"{fanSpeed.RPM:.0f} RPM" )
time.sleep( 2 )
except KeyboardInterrupt:
GPIO.cleanup()

View File

@@ -3,13 +3,14 @@
""" """
Application that monitors current CPU and Drive temp, along with fan speed and IO utilization Application that monitors current CPU and Drive temp, along with fan speed and IO utilization
Requires: PyQt5 (including QtCharts) Requires: PyQt6 (including QtCharts)
""" """
import sys import sys
from systemsupport import CPUInfo, CPULoad, multiDriveStat, CaseFan from systemsupport import CPUInfo, CPULoad, multiDriveStat, NetworkLoad
from configfile import ConfigClass from configfile import ConfigClass
from fanspeed import GetCaseFanSpeed
# -------------------------- # --------------------------
# Globals # Globals
@@ -22,11 +23,11 @@ MIN_HEIGHT = 800
# UI # UI
# -------------------------- # --------------------------
from PyQt5.QtCore import Qt, QTimer from PyQt6.QtCore import Qt, QTimer
from PyQt5.QtGui import QPainter from PyQt6.QtGui import QPainter
from PyQt5.QtWidgets import QApplication, QMainWindow, QWidget, QGridLayout from PyQt6.QtWidgets import QApplication, QMainWindow, QWidget, QGridLayout
from PyQt5.QtChart import QChart, QChartView, QLineSeries, QValueAxis from PyQt6.QtCharts import QChart, QChartView, QLineSeries, QValueAxis
from PyQt5 import QtGui from PyQt6 import QtGui
class RollingChart(QWidget): class RollingChart(QWidget):
''' '''
@@ -47,7 +48,7 @@ class RollingChart(QWidget):
self.chart.setTitle(title) self.chart.setTitle(title)
self.chart.legend().setVisible(len(series_defs) > 1) self.chart.legend().setVisible(len(series_defs) > 1)
self.chart.legend().setAlignment(Qt.AlignBottom) self.chart.legend().setAlignment(Qt.AlignmentFlag.AlignBottom)
self.series:list[QLineSeries] = [] self.series:list[QLineSeries] = []
for name, color in series_defs: for name, color in series_defs:
@@ -73,8 +74,8 @@ class RollingChart(QWidget):
self.axis_y.setRange(y_min, y_max) self.axis_y.setRange(y_min, y_max)
self.axis_y.setLabelFormat( "%d" ) self.axis_y.setLabelFormat( "%d" )
self.chart.addAxis(self.axis_x, Qt.AlignBottom) self.chart.addAxis(self.axis_x, Qt.AlignmentFlag.AlignBottom)
self.chart.addAxis(self.axis_y, Qt.AlignLeft) self.chart.addAxis(self.axis_y, Qt.AlignmentFlag.AlignLeft)
for s in self.series: for s in self.series:
s.attachAxis(self.axis_x) s.attachAxis(self.axis_x)
@@ -116,7 +117,7 @@ class RollingChart(QWidget):
for s in self.series: for s in self.series:
# Efficient trim: remove points with x < min_x_to_keep # Efficient trim: remove points with x < min_x_to_keep
# QLineSeries doesn't provide O(1) pop from front, so we rebuild if large # QLineSeries doesn't provide O(1) pop from front, so we rebuild if large
points = s.pointsVector() points = s.points()
if points and points[0].x() < min_x_to_keep: if points and points[0].x() < min_x_to_keep:
# binary search for first index >= min_x_to_keep # binary search for first index >= min_x_to_keep
lo, hi = 0, len(points) lo, hi = 0, len(points)
@@ -225,7 +226,7 @@ class RollingChartDynamic(RollingChart):
maxV = 0 maxV = 0
for s in self.series: for s in self.series:
drop = 0 drop = 0
points = s.pointsVector() points = s.points()
for index, point in enumerate(points): for index, point in enumerate(points):
if point.x() < min_x_to_keep: if point.x() < min_x_to_keep:
drop = index drop = index
@@ -243,7 +244,7 @@ class RollingChartDynamic(RollingChart):
self.scale.prevScale() self.scale.prevScale()
self.chart.setTitle( self.title + f" ({self.scale.name})") self.chart.setTitle( self.title + f" ({self.scale.name})")
for s in self.series: for s in self.series:
points = s.pointsVector() points = s.points()
for point in points: for point in points:
point.setY( self.scale.scaleUp(point.y())) point.setY( self.scale.scaleUp(point.y()))
s.replace(points) s.replace(points)
@@ -264,14 +265,17 @@ class MonitorWindow(QMainWindow):
# Get all the filters loaded # Get all the filters loaded
self.config = ConfigClass("/etc/sysmon.ini") self.config = ConfigClass("/etc/sysmon.ini")
self.driveTempFilter = self.config.getValueAsList( 'temperature', 'ignore' ) self.driveTempFilter = self.config.getValueAsList( 'drive', 'temp_ignore' )
self.drivePerfFilter = self.config.getValueAsList( 'performance', 'ignore' ) self.drivePerfFilter = self.config.getValueAsList( 'drive', 'perf_ignore' )
# Get supporting objects # Get supporting objects
self.cpuinfo = CPUInfo() self.cpuinfo = CPUInfo()
self.cpuload = CPULoad() self.cpuload = CPULoad()
self.casefan = CaseFan() self.caseFanPin = self.config.getValue( 'cooling', 'casefan',None )
self.caseFanPin = None if self.caseFanPin is None :
self.caseFan = None
else:
self.caseFan = GetCaseFanSpeed( int(self.caseFanPin) )
self.multiDrive = multiDriveStat() self.multiDrive = multiDriveStat()
self.setWindowTitle("System Monitor") self.setWindowTitle("System Monitor")
@@ -309,7 +313,6 @@ class MonitorWindow(QMainWindow):
if self.caseFanPin is None: if self.caseFanPin is None:
series = [("CPU",None)] series = [("CPU",None)]
else: else:
self.casefan.setTACHPin( self.caseFanPin)
series = [("CPU",None),("CaseFan",None)] series = [("CPU",None),("CaseFan",None)]
self.fan_chart = RollingChart( self.fan_chart = RollingChart(
@@ -330,18 +333,33 @@ class MonitorWindow(QMainWindow):
self.io_chart = RollingChartDynamic( self.io_chart = RollingChartDynamic(
title="Disk I/O", title="Disk I/O",
series_defs=series, series_defs=series,
range_y=[("Bytes/s", 1),("KiB/s",1024),("MiB/s", 1024*1024),("GiB/s",1024*1024*1024)], range_y=[("Bytes/s", 1),("KiB/s", 1024),("MiB/s", 1024*1024),("GiB/s", 1024*1024*1024)],
window=window,
)
self.networkFilter = self.config.getValueAsList( 'network', 'device_ignore')
self.network = NetworkLoad(self.networkFilter)
series = []
for name in self.network.names:
series.append( (f"{name} Read", None) )
series.append( (f"{name} Write", None) )
self.network_chart = RollingChartDynamic(
title="Network I/O",
series_defs=series,
range_y=[("Bytes/s", 1),("KiB/s", 1024),("MiB/s", 1024*1024),("GiB/s", 1024*1024*1024)],
window=window, window=window,
) )
# Layout: 2x2 grid (CPU, NVMe on top; IO full width bottom) # Layout: 2x2 grid (CPU, NVMe on top; IO full width bottom)
grid.addWidget(self.use_chart, 0, 0, 1, 2 ) grid.addWidget(self.use_chart, 0, 0, 1, 2 )
grid.addWidget(self.io_chart, 1, 0, 1, 2 ) grid.addWidget(self.io_chart, 1, 0, 1, 2 )
grid.addWidget(self.network_chart, 2, 0, 1, 2 )
if self.fan_chart: if self.fan_chart:
grid.addWidget(self.cpu_chart, 2, 0, 1, 1 ) grid.addWidget(self.cpu_chart, 3, 0, 1, 1 )
grid.addWidget(self.fan_chart, 2, 1, 1, 1 ) grid.addWidget(self.fan_chart, 3, 1, 1, 1 )
else: else:
grid.addWidget(self.cpu_chart, 2, 0, 1, 2 ) grid.addWidget(self.cpu_chart, 3, 0, 1, 2 )
# Get the initial information from the syste # Get the initial information from the syste
self.refresh_metrics() self.refresh_metrics()
@@ -363,10 +381,11 @@ class MonitorWindow(QMainWindow):
if self.cpuinfo.model == 5: if self.cpuinfo.model == 5:
try: try:
if self.caseFanPin: if self.caseFanPin:
fan_speed = [self.cpuinfo.CPUFanSpeed,self.casefan.speed] fan_speed = [self.cpuinfo.CPUFanSpeed,self.caseFan.RPM]
else: else:
fan_speed = [self.cpuinfo.CPUFanSpeed] fan_speed = [self.cpuinfo.CPUFanSpeed]
except Exception: except Exception as e:
print( f"error getting fan speed: {e}" )
fan_speed = [None,None] fan_speed = [None,None]
else: else:
fan_speed = [None,None] fan_speed = [None,None]
@@ -398,6 +417,16 @@ class MonitorWindow(QMainWindow):
except Exception : except Exception :
rwData = [ None, None ] rwData = [ None, None ]
# obtain network device read and writes rates
try:
netData = []
networks = self.network.stats
for network in networks:
netData.append( float( networks[network][0]))
netData.append( float( networks[network][1]))
except Exception:
netData = [None,None]
# Get the CPU load precentages # Get the CPU load precentages
try: try:
p = self.cpuload.getPercentages() p = self.cpuload.getPercentages()
@@ -410,13 +439,14 @@ class MonitorWindow(QMainWindow):
if self.fan_chart: if self.fan_chart:
self.fan_chart.append( fan_speed ) self.fan_chart.append( fan_speed )
self.io_chart.append( rwData ) self.io_chart.append( rwData )
self.network_chart.append( netData )
self.use_chart.append( values ) self.use_chart.append( values )
def main(): def main():
app = QApplication(sys.argv) app = QApplication(sys.argv)
w = MonitorWindow(refresh_ms=1000) w = MonitorWindow(refresh_ms=1000)
w.show() w.show()
sys.exit(app.exec_()) sys.exit(app.exec())
if __name__ == "__main__": if __name__ == "__main__":
main() main()

33
monitor/sysmon.ini Normal file
View File

@@ -0,0 +1,33 @@
#
# For drives, you can ignore a device from collecting the temperature,
# or the performance data. A good device to ignore for temperature
# collection is the mmcblk0 device, as no temp available. Also
# in a system using RAID, the temperature is not available.
#
[drive]
temp_ignore = mmcblk
perf_ignore = mmcblk
#
# When monitorin the network, you can ignore speciic devices. On
# the Argon40 OneUP there is an eth0 thats available but not connected
# to anything, so that an lo are good to ignore
#
[network]
device_ignore = "lo,eth0"
#
# if your system has a separate case fan, you can montor the speed if
# it has a tachometer attached to a GPIO pin
#
#[cooling]
# casefan = 18
#
# In the rare case there is an additional smartctl command option needed
# for a speciic drive, you can add that here.
#
#[smartctl]
# drive = extra_smartctl_command

View File

@@ -413,20 +413,47 @@ class CPULoad:
''' '''
return len(self._previousData) return len(self._previousData)
class CaseFan: class NetworkLoad:
''' def __init__(self, networkIgnoreList : list[str]=[]):
Class used to monitor a TACH from a case fan. self._networks = []
''' with os.popen( 'ls -1 /sys/class/net') as command:
def __init__( self, pin=None ): net_raw = command.read()
self.tach = pin for l in net_raw.split('\n'):
self.rpm = 0 if len(l) == 0:
continue
if not l in networkIgnoreList:
self._networks.append( l )
self.prevStats = {}
for net in self._networks:
self.prevStats[net] = self._getData(net)
@property
def names( self ):
return self._networks
def setTACHPin( self, pin = int): def _getData( self, name : str ) -> tuple[int,int]:
self.tach = pin readData = 0
writeData = 0
try:
with open( f"/sys/class/net/{name}/statistics/rx_bytes" ) as f:
readData = f.read().strip()
with open( f"/sys/class/net/{name}/statistics/tx_bytes" ) as f:
writeData = f.read().strip()
except Exception as e:
print( f"Error {e}" )
#print( f"{readData} {writeData}" )
return (int(readData), int(writeData))
@property @property
def speed( self ) -> float: def stats(self) -> dict[tuple[int,int]]:
return self.rpm data = {}
curstats = {}
for net in self._networks:
curstats[net] = self._getData( net )
data[net] = ((curstats[net][0] - self.prevStats[net][0]),
(curstats[net][1] - self.prevStats[net][1]))
self.prevStats = curstats
return data
if __name__ == "__main__": if __name__ == "__main__":
@@ -444,14 +471,14 @@ if __name__ == "__main__":
print( f"CPU Fan Speed = {cpuinfo.CPUFanSpeed}" ) print( f"CPU Fan Speed = {cpuinfo.CPUFanSpeed}" )
print( f"CPU Model = {cpuinfo.model}" ) print( f"CPU Model = {cpuinfo.model}" )
caseFan = CaseFan( 18 )
print( f"RPM = {caseFan.speed}" )
time.sleep(1)
print( f"RPM = {caseFan.speed}" )
test = multiDriveStat() test = multiDriveStat()
print( test.drives ) print( test.drives )
for drive in test.drives: for drive in test.drives:
print( f"Drive {drive} size is {test.driveSize( drive )}" ) print( f"Drive {drive} size is {test.driveSize( drive )}" )
print( test.readWriteSectors() ) print( test.readWriteSectors() )
network = NetworkLoad( ['lo','eth0'])
print( f"Networks Available: {network.names}" )
while True:
print( f"Stats = {network.stats}" )
time.sleep(1)