Lyrion Music Server auf dem Raspberry Pi 5 im Retro Gehäuse
Kürzlich habe ich auf einem Flohmarkt ein altes Messgerät entdeckt – ein analoges Temperaturanzeigegerät der Firma Ph. Schenk Wien, vermutlich aus den 1950er oder 60er Jahren. Eingefasst in ein massives Holzgehäuse mit Trageriemen und klassischem Drehspul-Messwerk.
Schon beim ersten Anblick kam mir die Idee, dass sich das Gehäuse gut als neue Heimat für meinen Lyrion Music Server (ehemals Logitech Media Server) eignen könnte. Und der Zeiger könnte die CPU-Last oder einen ähnlichen Wert anzeigen.
Raspberry 5 mit NVMe M.2-SSD einrichten
Mein bisheriger Squeezebox Server ist ein alter Raspberry Pi 3B mit einer SSD, die über USB angeschlossen ist. Leider ist das Holzgehäuse für diese SSD zu klein, daher habe ich beschlossen, die komplette Hardware zu erneuern. Ich habe mich für den Raspberry Pi 5 in der 8GB Variante entschieden, was für einen Squeezebox Server mehr als ausreichend ist. Der neue Raspberry Pi 5 hat den entscheidenden Vorteil, dass er eine PCIe 2.0-Schnittstelle bietet, an der über eine kleine Zusatzplatine eine NVMe M.2-SSD angeschlossen werden kann. Abgesehen von der deutlich höheren Geschwindigkeit ist das vor allem sehr platzsparend. Ein weiterer Vorteil ist die Lüftersteurung. Im Gegensatz zum Raspberry Pi 4 bietet der Raspberry Pi 5 eine vollwertige PWM-Steuerung für den Lüfter.
Im Detail habe ich diese Komponenten verwendet (Amazon Affiliate-Links):
- Raspberry Pi 5 8GB
- SD-Karte mit 32 GB
- Raspberry Pi 5 USB-C Netzteil 45W
- Geekworm X1001 PCIe to M.2 NVMe Key-M SSD Shield
- Geekworm H505 Active Cooler Fan
- Samsung 990 EVO Plus NVMe M.2 SSD 2 TB
Zusammengebaut sieht das kleine Kraftpaket dann so aus:
Nach dem Zusammenbau erfolgt die Installation des Raspberry Pi OS. Dazu wird zunächst mit dem Raspberry Pi Imager eine SD-Karte mit dem Betriebssystem erstellt, das alle wichtigen Grundeinstellungen wie WLAN-Zugang, Benutzerkonto, Hostname und Spracheinstellungen enthält. Als Betriebssystem für den Lyrion Media Server verwende ich das Raspberry OS Lite (64-Bit).
PuTTY) per SSH auf dem Raspberry mit dem konfigurierten User einloggen. Die IP-Adresse des Raspberry Pi lässt sich im Router einsehen, alternativ kann man den im Setup konfigurierten Hostnamen verwenden.
Anschließend steckt man die SD-Karte in den Slot des Raspberry und schließt das Netzteil an. Am Router kann man kontrollieren, ob sich der Raspberry im WLAN anmelden konnte. Wenn alles funktioniert hat, kann man sich (am einfachsten mitNach dem Login wird zunächst das Betriebssystem aktualisiert:
pi@LyrionServer:~ $ sudo apt update && sudo apt full-upgrade -y pi@LyrionServer:~ $ sudo reboot now
Im nächsten Schritt wird der Bootloader des Raspberry aktualisiert:
sudo raspi-config
Der Menüpunkt ist 6 Advanced Options -> A5 Bootloader Version -> E1 Latest
Die Ausgabe sollte folgendermaßen aussehen:
*** CREATED UPDATE /usr/lib/firmware/raspberrypi/bootloader-2712//latest/pieeprom-2025-05-08.bin *** CURRENT: Mon 23 Sep 13:02:56 UTC 2024 (1727096576) UPDATE: Thu 8 May 14:13:17 UTC 2025 (1746713597) BOOTFS: /boot/firmware '/tmp/tmp.8vZyAiT5rb' -> '/boot/firmware/pieeprom.upd' UPDATING bootloader. This could take up to a minute. Please wait *** Do not disconnect the power until the update is complete *** If a problem occurs then the Raspberry Pi Imager may be used to create a bootloader rescue SD card image which restores the default bootloader image. flashrom -p linux_spi:dev=/dev/spidev10.0,spispeed=16000 -w /boot/firmware/pieeprom.upd Verifying update VERIFY: SUCCESS UPDATE SUCCESSFUL Broadcast message from root@LyrionServer on pts/1 (Fri 2025-05-09 18:49:09 CEST): The system will reboot now!
Die Version des Bootloaders kann mit diesem Befehl kontrolliert werden. Current und Latest müssen identisch sein:
pi@LyrionServer:~ $ sudo rpi-eeprom-update BOOTLOADER: up to date CURRENT: Thu 8 May 14:13:17 UTC 2025 (1746713597) LATEST: Thu 8 May 14:13:17 UTC 2025 (1746713597) RELEASE: latest (/usr/lib/firmware/raspberrypi/bootloader-2712/latest) Use raspi-config to change the release.
Danach wird mit diesen Befehlen kontrolliert, ob der Raspberry den Controller der NVMe SSD erkannt hat:
pi@LyrionServer:~ $ sudo lspci 0001:00:00.0 PCI bridge: Broadcom Inc. and subsidiaries BCM2712 PCIe Bridge (rev 30) 0001:01:00.0 Non-Volatile memory controller: Samsung Electronics Co Ltd NVMe SSD Controller PM9C1a (DRAM-less) 0002:00:00.0 PCI bridge: Broadcom Inc. and subsidiaries BCM2712 PCIe Bridge (rev 30) 0002:01:00.0 Ethernet controller: Raspberry Pi Ltd RP1 PCIe 2.0 South Bridge
und
pi@LyrionServer:~ $ lsblk NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS mmcblk0 179:0 0 14.6G 0 disk ├─mmcblk0p1 179:1 0 512M 0 part /boot/firmware └─mmcblk0p2 179:2 0 14.1G 0 part / nvme0n1 259:0 0 1.8T 0 disk
Die beiden fett markierten Zeilen zeigen, dass sowohl der Controller als auch die SSD korrekt erkannt wurden. Ich kann leider nicht sagen, wie sich der Raspberry bei NVMe SSDs von anderen Herstellern verhält. Bei der Samsung 990 EVO war jedenfalls keinerlei Konfiguration nötig.
Zu diesem Zeitpunkt ist das Betriebssystem immer noch auf der SD-Karte. Damit der Raspberry auch ohne SD-Karte arbeiten kann, muss jetzt das Betriebssystem von der SD-Karte auf die SSD kopiert werden. Dazu wird das Tool rpi-clone mit diesem Befehl installiert:
curl https://raw.githubusercontent.com/geerlingguy/rpi-clone/master/install | sudo bash
Jetzt kann das Betriebssystem auf die SSD kopiert werden:
pi@LyrionServer:~ $ sudo rpi-clone nvme0n1 Error: /dev/nvme0n1: unrecognised disk label Booted disk: mmcblk0 15.6GB Destination disk: nvme0n1 2.0TB --------------------------------------------------------------------------- Part Size FS Label Part Size FS Label 1 /boot/firmware 512.0M fat32 bootfs 2 root 14.1G ext4 rootfs --------------------------------------------------------------------------- == Initialize: IMAGE partition table - partition number mismatch: 2 -> 0 == 1 /boot/firmware (65.0M used) : MKFS SYNC to nvme0n1p1 2 root (2.1G used) : RESIZE MKFS SYNC to nvme0n1p2 --------------------------------------------------------------------------- Run setup script : no. Verbose mode : no. -----------------------: ** WARNING ** : All destination disk nvme0n1 data will be overwritten! -----------------------: Initialize and clone to the destination disk nvme0n1? (yes/no): yes Optional destination ext type file system label (16 chars max): Initializing Imaging past partition 1 start. => dd if=/dev/mmcblk0 of=/dev/nvme0n1 bs=1M count=12 ... Resizing destination disk last partition ... Resize success. Changing destination Disk ID ... => mkfs -t vfat -F 32 -n bootfs /dev/nvme0n1p1 ... => mkfs -t ext4 -L rootfs /dev/nvme0n1p2 ... Syncing file systems (can take a long time) Syncing mounted partitions: e2label /dev/nvme0n1p2 rootfs Mounting /dev/nvme0n1p2 on /mnt/clone => rsync // /mnt/clone with-root-excludes ... fatlabel /dev/nvme0n1p1 bootfs fatlabel: warning - lowercase labels might not work properly on some systems Mounting /dev/nvme0n1p1 on /mnt/clone/boot/firmware => rsync /boot/firmware/ /mnt/clone/boot/firmware ... Editing /mnt/clone/boot/firmware/cmdline.txt PARTUUID to use c33f61cd Editing /mnt/clone/etc/fstab PARTUUID to use c33f61cd =============================== Done with clone to /dev/nvme0n1 Start - 19:01:23 End - 19:02:59 Elapsed Time - 1:36 Cloned partitions are mounted on /mnt/clone for inspection or customizing. Hit Enter when ready to unmount the /dev/nvme0n1 partitions ... unmounting /mnt/clone/boot/firmware unmounting /mnt/clone ===============================
Zuletzt muss noch die Bootreihenfolge geändert werden:
pi@LyrionServer:~ $ sudo raspi-config
Der Menüpunkt ist 6 – Advanced Options -> A4 – Boot Order -> B2 – NVMe/USB Boot -> OK – Finish – NO (kein reboot)
Danach kann der Raspberry heruntergefahren werden:
sudo shutdown now
Jetzt die SD-Karte entfernen und den Raspberry mit dem Power Button wieder einschalten. Nachdem der Raspberry gebootet hat, erhält man mit diesem Befehl einen Überblick über das Dateisystem:
pi@LyrionServer:~ $ df -h Filesystem Size Used Avail Use% Mounted on udev 3.9G 0 3.9G 0% /dev tmpfs 807M 14M 794M 2% /run /dev/nvme0n1p2 1.8T 2.2G 1.7T 1% / tmpfs 4.0G 0 4.0G 0% /dev/shm tmpfs 5.0M 48K 5.0M 1% /run/lock /dev/nvme0n1p1 511M 66M 446M 13% /boot/firmware tmpfs 807M 0 807M 0% /run/user/1000
Damit hat man ein System, das nun vollständig auf der schnellen NVMe-SSD läuft, wodurch die SD-Karte für den Betrieb überflüssig geworden ist.
Mit einem NVMe M.2 USB Adapter könnte man sich das Kopieren der SD-Karte auch sparen und mit dem Raspberry Pi Imager direkt auf die SSD schreiben. Da ich so einen Adapter aber nur einmal brauchen würde, habe ich die etwas umständlichere Variante gewählt.
Samba installieren
Damit die Musikdateien vom PC aus auf den Lyrion Media Server kopiert werden können, ist es hilfreich, einen freigegebenen Ordner einzurichten. Dazu muss zunächst das Samba Paket installiert werden.
pi@LyrionServer:~ $ sudo apt-get install samba
Danach wird in der Datei /etc/samba/smb.conf der freigegebene Ordner konfiguriert:
pi@LyrionServer:~ $ sudo nano /etc/samba/smb.conf
Am Ende der Datei wird die folgende Konfiguration angefügt:
[Lyrion] comment=Raspberry Pi Share path=/media/lyrion browseable=Yes writeable=Yes only guest=no create mask=0644 directory mask=0755 public=no
Ich verwende bei meiner Installation das Verzeichnis /media/lyrion für den Lyrion Media Server. Damit der User pi in dieses Verzeichnis schreiben darf, müssen die Berechtigungen entsprechend geändert werden:
pi@LyrionServer:~ $ sudo mkdir /media/lyrion pi@LyrionServer:~ $ sudo chown pi:pi /media/lyrion/
Jetzt muss noch ein Samba User angelegt werden. Ich verwende dazu wieder den User pi mit dem gleichen Passwort wie oben.
pi@LyrionServer:~ $ sudo smbpasswd -a pi
Nach einem Neustart des Samba Service mit
pi@LyrionServer:~ $ sudo service smbd restart
kann das Netzlaufwerk auf dem PC eingebunden werden. Beim Login gibt man den User pi mit dem entsprechenden Passwort an, der gerade angelegt wurde. Jetzt kann die Musiksammlung ganz bequem auf die Festplatte des Lyrion Media Servers kopiert werden.
Lyrion Media Server auf dem Raspberry Pi 5 installieren
Jetzt folgt die Installation des eigentlichen Servers. Inzwischen hat sich wieder einmal der Name geändert. Nach Logitech Media Server, SlimServer, SqueezeCenter, Squeezebox Server heißt er nun Lyrion Media Server. Leider hat Logitech im März 2024 die Unterstützung für das Squeezebox System komplett eingestellt, daher wird jetzt die Squeezebox-Community wohl noch weiter schrumpfen. Ein positiver Nebeneffekt ist aber, dass es die gebrauchten Geräte inzwischen sehr günstig gibt.
Für den Raspberry Pi muss die Version für Arm-Architektur heruntergeladen werden. Aktuell ist es die Version 9.0.2. Der Download des Pakets erfolgt mit
wget https://downloads.lms-community.org/LyrionMusicServer_v9.0.2/lyrionmusicserver_9.0.2_arm.deb
Dann wird die Installation gestartet:
pi@LyrionServer:~ $ sudo dpkg -i lyrionmusicserver_9.0.2_arm.deb Selecting previously unselected package lyrionmusicserver. (Reading database ... 58884 files and directories currently installed.) Preparing to unpack lyrionmusicserver_9.0.2_arm.deb ... Unpacking lyrionmusicserver (9.0.2) ... dpkg: dependency problems prevent configuration of lyrionmusicserver: lyrionmusicserver depends on libio-socket-ssl-perl; however: Package libio-socket-ssl-perl is not installed. lyrionmusicserver depends on libcrypt-openssl-rsa-perl; however: Package libcrypt-openssl-rsa-perl is not installed. dpkg: error processing package lyrionmusicserver (--install): dependency problems - leaving unconfigured Errors were encountered while processing: lyrionmusicserver
Die Ausgabe zeigt an, dass einige Pakete fehlen. Das kann mit diesem Befehl behoben werden:
pi@LyrionServer:~ $ sudo apt --fix-broken install
Jetzt sollte die Installation beendet werden und der Service des Lyrion Media Servers starten. Das Logfile muss ungefähr so aussehen:
pi@LyrionServer:~ $ tail /var/log/squeezeboxserver/server.log [25-05-09 19:56:53.9765] main::init (387) Starting Lyrion Music Server (v9.0.2, 1741501853, Thu Mar 13 14:57:35 UTC 2025) perl 5.036000 - aarch64-linux-gnu-thread-multi [25-05-09 19:56:54.4422] main::init (661) Server done init: http://192.168.0.67:9000 [25-05-09 19:56:55.5454] Slim::Web::Cometd::handler (437) errorNeedsClient: 192.168.0.43, 00:04:20:2b:05:91, status, -, 10, menu:menu, useContextMenu:1, subscribe:600
Damit ist die Installation abgeschlossen und man hat einen neuen, extrem schnellen Lyrion Music Server. Die Weboberfläche wird entweder über die IP-Adresse des Servers oder mit dem konfigurierten Hostnamen aufgerufen. Bei mir sind das diese beiden Adressen:
http://192.168.0.67:9000 oder http://LyrionServer:9000
In der Weboberfläche erfolgt das erste Setup des Servers:
Damit ist die Installation und Konfiguration des Lyrion Music Servers abgeschlossen. Nach dem Setup beginnt der Server mit der Indizierung der Musikdateien, was bei mir für knapp über 20.000 Titel nicht mal eine Minute gedauert hat. Die Performance der Raspberry Pi 5 in Kombination mit der NVMe SSD ist hier wirklich sehr beeindruckend.
Da der Raspberry Pi Tag und Nacht laufen wird, ist der Stromverbrauch für mich ein entscheidender Faktor. Mithilfe eines einfachen USB-C-Testers lässt sich dieser Wert schnell ermitteln. Wie das Foto zeigt, ist der Energiebedarf mit etwa 2 Watt äußerst gering. Der kleine Server wird also in der Stromrechnung nicht weiter auffallen.
Einbau ins Gehäuse
Im Grunde wäre der Lyrion Music Server damit fertig. Noch ein kleines Gehäuse drumherum und schon könnte man mit dem Musikhören beginnen. Wie eingangs schon erwähnt, wollte ich aber ein schickes Retro-Gehäuse, wofür noch einige Schritte notwendig waren.
Zunächst habe ich das empfindliche Drehspuhl-Messerk ausgebaut, damit es nicht beschädigt wird. Anschließend das Glas, den Spiegel und die Skala vorsichtig gereinigt. Damit zumindest ein wenig Luft zirkulieren kann, habe ich ein paar Löcher in das Gehäuse gebohrt. Ich hatte leider nichts Besseres zur Hand, sonst hätte man das sicher auch viel hübscher machen können.
Für die Befestigung des Raspberry Pi in dem Gehäuse habe ich eine einfache Halterung konstruiert, auf der der Raspberry geschraubt werden kann und die dann mit kurzen Schrauben an die Rückseite geschraubt wird. Etwas problematisch war die Stromversorgung. Ursprünglich wollte ich einen USB-C Stecker in das Gehäuse einbauen und dann den Raspberry über die GPIO-Pins mit Strom versorgen. Allerdings hat dieser USB-C Stecker nicht mit dem Raspberry Netzteil funktioniert. Scheinbar gibt es einen Unterschied zwischen normalen Ladegeräten, wo dieser Stecker funktioniert, und einem richtigen Netzteil. Stattdessen habe ich jetzt einfach das ganze Kabel durch das Gehäuse gesteckt. Nicht optimal, aber es funktioniert.
Dann werden die beiden Leitungen des Messgeräts mit einem Ground Pin und GPIO 19 des Raspberry verbunden. Da die Stromstärke bei 3,3 Volt viel zu hoch ist, muss noch ein Vorwiderstand dazwischengeschaltet werden. Den Wert kann man sich leicht errechnen. Das analoge Messgerät hat folgende Kenndaten:
- Vollauslenkung bei 48,2 mV
- Innenwiderstand: 580 Ω
Es soll mit einer Versorgungsspannung von 3,3 V betrieben werden.
Der Strom, bei dem das Messgerät voll ausschlägt, ergibt sich nach dem Ohmschen Gesetz:
Damit bei 3,3 V nur 83,1 µA fließen, muss der Gesamtwiderstand so groß sein:
Der Vorwiderstand ergibt sich durch Abzug des Innenwiderstands:
Ich habe daher ein 50 KΩ Potentiometer verwendet und es so eingestellt, dass der Zeiger bei 3,3 Volt leicht über dem Vollausschlag ist.
Am Ende wird alles wieder zusammengeschraubt. Die Wärmeentwicklung innerhalb des Gehäuses hält sich nach den ersten Tests in Grenzen. Bei normalem Musikhören erwärmt sich die CPU auf etwa 48 °C, ich denke das ist ok, allerdings werde ich das im Sommer noch beobachten müssen.
Lastanzeige mit dem Messgerät
Natürlich soll das Messgerät auch irgendwas anzeigen. Die CPU-Last wäre naheliegend, aber die ist die meiste Zeit so gering, dass sich der Zeiger kaum bewegen würde. Ich habe mich daher für eine Kombination aus CPU-Last, Disk I/O, Netzwerk I/O und Temperatur entschieden. Diese Werte können mit dem Package psutil sehr einfach ausgelesen werden. Allerdings gibt es noch ein grundlegendes Problem. Das Messgerät muss mit einem analogen Signal angesteuert werden, der Raspberry hat jedoch keinen Digital/Analog Wandler. Da das Messgerät aber sehr träge ist, kann man mit einem PWM-Signal ein analoges Signal simulieren. Dazu verwende ich das Package rpi-hardware-pwm, das auch mit dem Raspberry Pi 5 funktioniert.
Die beiden Packages müssen zunächst in einem Virtual Environment installiert werden:
pi@LyrionServer:~ $ python3 -m venv ~/pwm-venv pi@LyrionServer:~ $ source ~/pwm-venv/bin/activate pi@LyrionServer:~ $ pip3 install rpi-hardware-pwm pi@LyrionServer:~ $ pip3 install psutil
Zusätzlich ist noch eine kleine Konfiguration in /boot/firmware/config.txt nötig. Am Ende nach [all] trägt man einfach diese Zeilen ein, damit alle PWM-Kanäle zur Verfügung stehen:
# PWM dtoverlay=pwm-2chan
Mit diesem kleinen Skript kann man Testen, ob die PWM Ausgabe funktioniert. Das Signal wird über GPIO 19 ausgegeben.
fromrpi_hardware_pwmimport HardwarePWM import time pwm = HardwarePWM(pwm_channel=3, hz=100) pwm.start(0) # Start with 0% Duty Cycle try: while True: for dc in range(0, 101, 1): pwm.change_duty_cycle(dc) time.sleep(0.1) time.sleep(2) for dc in reversed(range(0, 101, 1)): pwm.change_duty_cycle(dc) time.sleep(0.1) time.sleep(2) except KeyboardInterrupt: pass finally: pwm.stop()
Wie man sieht, wird ein sauberes Rechtecksignal ausgegeben und der Zeiger bewegt sich mehr oder weniger entsprechend dem Duty Cycle:
Man sieht in dem Video auch, dass der Zeiger ein wenig klebt. Das Gerät hat auf der Rückseite Gummifüße, es ist also eigentlich dafür gedacht, liegend betrieben zu werden. Und es gibt noch zwei Probleme, die ich jedoch per Software lösen konnte:
- Bei größeren Änderungen des Signals kann es passieren, dass der Zeiger recht heftig nachschwingt oder auf der rechten bzw. linken Seite anschlägt. Dazu habe einen Glättungsfunktion eingebaut, die wie ein Tiefpassfilter wirkt. Damit bewegt sich der Zeiger schön langsam.
- Der Ausschlag der Zeiger ist einerseits nicht sehr linear, andererseits möchte ich niedrige Werte sogar etwas verstärken, damit ich bei niedriger Last etwas mehr Ausschlag habe. Dazu gibt es ein Mapping, mit dem von einem Ist- zu einem Soll-Wert umgerechnet wird.
Der gesamte Code des Python-Skripts sieht folgendermaßen aus:
# -*- coding: utf-8 -*- import psutil from rpi_hardware_pwm import HardwarePWM import time import sys # --- Configuration --- # PWM configuration for rpi_hardware_pwm RPI_PWM_CHANNEL = 3 # For GPIO 19. https://pypi.org/project/rpi-hardware-pwm PWM_PIN_BCM_INFO = 19 # For informational purposes only, which BCM pin is expected for RPI_PWM_CHANNEL=1 (adjust!) PWM_FREQ = 100 # PWM frequency in Hz # System load metrics configuration UPDATE_INTERVAL_SEC = 1.0 # How often raw data is fetched and the pointer is updated TARGET_DISK_DEVICE = 'nvme0n1p2' TARGET_NET_INTERFACE = 'wlan0' MAX_DISK_IO_BPS = 1 * 1024 * 1024 MAX_NET_IO_BPS = 1 * 1024 * 1024 # weights WEIGHT_CPU = 0.20 WEIGHT_DISK = 0.30 WEIGHT_NET = 0.30 WEIGHT_TEMP = 0.20 MIN_TEMP_C = 40.0 # Temperature (Celsius) that corresponds to 0% contribution to temp load (e.g., Idle) MAX_TEMP_C = 75.0 # Temperature (Celsius) that corresponds to 100% contribution to temp load SMOOTHING_FACTOR_ALPHA = 0.1 # For EMA smoothing (smaller = slower/smoother) # Calibration points for the pointer instrument's scale # Format: (desired_pointer_percentage_on_scale, required_PWM_percentage_output_for_that) # The "desired_pointer_percentage_on_scale" should increase linearly from 0 to 100. # The "required_PWM_percentage_output_for_that" are the values you need to set # for the pointer to physically reach this position. CALIBRATION_POINTS = [ (0, 0), # Example: For 0% pointer deflection -> 0% PWM (10, 25), # Example: To see 10% on scale -> 20% PWM needed (25, 55), # Example: To see 25% on scale -> 55% PWM needed (50, 75), # Example: To see 50% on scale -> 75% PWM needed (75, 90), # Example: To see 75% on scale -> 90% PWM needed (100, 100) # Example: For 100% pointer deflection -> 100% PWM needed ] # ============================== # Global variables last_time = 0 # Will be set correctly the first time last_disk_io_specific = None last_net_io_specific = None smoothed_overall_load = 0.0 # This is the "linear" target value for the scale (0-100%) first_run_ema = True pwm_device = None # Global variable for the rpi_hardware_pwm object # --- Helper functions --- # Helper function to print warnings only once _printed_warnings = set() def print_once(message, key): if key not in _printed_warnings: print(message, file=sys.stderr) _printed_warnings.add(key) def get_cpu_temperature(): try: temps = psutil.sensors_temperatures() if 'cpu_thermal' in temps and temps['cpu_thermal']: return temps['cpu_thermal'][0].current for sensor_name, entries in temps.items(): # Fallback if entries and ("temp" in sensor_name.lower() or "thermal" in sensor_name.lower()): return entries[0].current print_once("W: CPU temperature sensor not clearly identifiable.", "temp_sensor_id_warn") return None except Exception as e: print_once(f"E: Error reading CPU temperature: {e}", "temp_sensor_read_error") return None def normalize(value, max_value): if max_value <= 0: return 0.0 value = max(0.0, value) return min(value / max_value, 1.0) def calculate_combined_load(cpu_percent, disk_bps, net_bps, normalized_temp): norm_cpu = cpu_percent / 100.0 norm_disk = normalize(disk_bps, MAX_DISK_IO_BPS) norm_net = normalize(net_bps, MAX_NET_IO_BPS) # normalized_temp is already passed as a 0.0-1.0 value combined = (norm_cpu * WEIGHT_CPU + norm_disk * WEIGHT_DISK + norm_net * WEIGHT_NET + normalized_temp * WEIGHT_TEMP) final_load_fraction = min(max(0.0, combined), 1.0) return final_load_fraction * 100.0 def get_calibrated_pwm(linear_load_percent, points): sorted_points = sorted(points, key=lambda p: p[0]) if not sorted_points: return 0 if linear_load_percent <= sorted_points[0][0]: return sorted_points[0][1] if linear_load_percent >= sorted_points[-1][0]: return sorted_points[-1][1] for i in range(len(sorted_points) - 1): p1_load, p1_pwm = sorted_points[i] p2_load, p2_pwm = sorted_points[i + 1] if p1_load <= linear_load_percent < p2_load: load_range = p2_load - p1_load pwm_range = p2_pwm - p1_pwm if load_range == 0: return p1_pwm calibrated_pwm = p1_pwm + (linear_load_percent - p1_load) * pwm_range / load_range return calibrated_pwm return sorted_points[-1][1] # Should not be reached if 100 is in the input # --- Main program --- try: print( f"Initializing rpi_hardware_pwm on PWM channel {RPI_PWM_CHANNEL} (exp. GPIO {PWM_PIN_BCM_INFO}) with {PWM_FREQ} Hz...") pwm_device = HardwarePWM(pwm_channel=RPI_PWM_CHANNEL, hz=PWM_FREQ) pwm_device.start(0) # Starts with 0% duty cycle print("Hardware PWM initialized.") print(f"Monitoring Disk: '{TARGET_DISK_DEVICE}', Network: '{TARGET_NET_INTERFACE}', Temp: CPU") print("Calibration points for scale:", CALIBRATION_POINTS) print("Starting load measurement... Press CTRL+C to exit.") # Get initial counter readings for the target devices try: initial_disk_stats = psutil.disk_io_counters(perdisk=True) if TARGET_DISK_DEVICE in initial_disk_stats: last_disk_io_specific = initial_disk_stats[TARGET_DISK_DEVICE] else: print_once(f"W: Initial disk device '{TARGET_DISK_DEVICE}' not found.", "init_disk_warn") initial_net_stats = psutil.net_io_counters(pernic=True) if TARGET_NET_INTERFACE in initial_net_stats: last_net_io_specific = initial_net_stats[TARGET_NET_INTERFACE] else: print_once(f"W: Initial network interface '{TARGET_NET_INTERFACE}' not found.", "init_net_warn") last_time = time.monotonic() except Exception as init_e: print(f"E: Error during initial reading of counters: {init_e}", file=sys.stderr) last_time = time.monotonic() # Initialize variables for output in case the first data collection cycle fails cpu_load, disk_total_bps, net_total_bps, norm_temp = 0.0, 0.0, 0.0, 0.0 cpu_temp_c = None # For temperature display raw_overall_load, duty_cycle_percent_calibrated = 0.0, 0 while True: current_time = time.monotonic() time_delta = current_time - last_time if time_delta >= UPDATE_INTERVAL_SEC: # Temporary variables for current cycle current_cpu_load = 0.0 current_disk_total_bps = 0.0 current_net_total_bps = 0.0 current_norm_temp = 0.0 current_cpu_temp_c = None try: current_cpu_load = psutil.cpu_percent(interval=0.1) current_cpu_temp_c = get_cpu_temperature() if current_cpu_temp_c is not None: clamped_temp = max(MIN_TEMP_C, min(current_cpu_temp_c, MAX_TEMP_C)) if (MAX_TEMP_C - MIN_TEMP_C) > 0: current_norm_temp = (clamped_temp - MIN_TEMP_C) / (MAX_TEMP_C - MIN_TEMP_C) else: current_norm_temp = 0.0 if clamped_temp <= MIN_TEMP_C else 1.0 current_norm_temp = max(0.0, min(1.0, current_norm_temp)) else: print_once("W: CPU temperature not available, temp contribution is 0.", "no_temp_loop") disk_io_all = psutil.disk_io_counters(perdisk=True) current_disk_io_target = disk_io_all.get(TARGET_DISK_DEVICE) if current_disk_io_target and last_disk_io_specific and time_delta > 0: disk_read_bps = max(0, ( current_disk_io_target.read_bytes - last_disk_io_specific.read_bytes)) / time_delta disk_write_bps = max(0, ( current_disk_io_target.write_bytes - last_disk_io_specific.write_bytes)) / time_delta current_disk_total_bps = disk_read_bps + disk_write_bps elif not current_disk_io_target and last_disk_io_specific is not None: # Only warn if it was there before print_once(f"W: Disk '{TARGET_DISK_DEVICE}' no longer found.", "disk_lost_warn") net_io_all = psutil.net_io_counters(pernic=True) current_net_io_target = net_io_all.get(TARGET_NET_INTERFACE) if current_net_io_target and last_net_io_specific and time_delta > 0: net_sent_bps = max(0, ( current_net_io_target.bytes_sent - last_net_io_specific.bytes_sent)) / time_delta net_recv_bps = max(0, ( current_net_io_target.bytes_recv - last_net_io_specific.bytes_recv)) / time_delta current_net_total_bps = net_sent_bps + net_recv_bps elif not current_net_io_target and last_net_io_specific is not None: # Only warn if it was there before print_once(f"W: Interface '{TARGET_NET_INTERFACE}' no longer found.", "net_lost_warn") # Apply values for this cycle cpu_load, disk_total_bps, net_total_bps, norm_temp, cpu_temp_c = \ current_cpu_load, current_disk_total_bps, current_net_total_bps, current_norm_temp, current_cpu_temp_c raw_overall_load = calculate_combined_load(cpu_load, disk_total_bps, net_total_bps, norm_temp) if first_run_ema: smoothed_overall_load = raw_overall_load first_run_ema = False else: smoothed_overall_load = (SMOOTHING_FACTOR_ALPHA * raw_overall_load) + \ ((1 - SMOOTHING_FACTOR_ALPHA) * smoothed_overall_load) calibrated_pwm_value = get_calibrated_pwm(smoothed_overall_load, CALIBRATION_POINTS) duty_cycle_percent_calibrated = int(round(calibrated_pwm_value)) duty_cycle_percent_calibrated = max(0, min(100, duty_cycle_percent_calibrated)) if pwm_device: pwm_device.change_duty_cycle(duty_cycle_percent_calibrated) # Save values for the next iteration last_time = current_time if current_disk_io_target: last_disk_io_specific = current_disk_io_target else: last_disk_io_specific = None # Explicitly set to None if target disappears if current_net_io_target: last_net_io_specific = current_net_io_target else: last_net_io_specific = None # Explicitly set to None if target disappears except Exception as loop_e: print(f"E: Error in main loop (data acquisition/processing): {loop_e}", file=sys.stderr) # In case of an error in this cycle, keep the old values for output # and pause to avoid immediate re-failure. time.sleep(UPDATE_INTERVAL_SEC) # Wait before trying again # Debug output with the last successfully read or initialized values disk_mbps_str = f"{disk_total_bps / (1024 * 1024):5.2f}" net_mbps_str = f"{net_total_bps / (1024 * 1024):5.2f}" temp_str = f"{cpu_temp_c:.1f}°C" if cpu_temp_c is not None else " N/A" # Fixed width for N/A print( f"CPU:{cpu_load:5.1f}% | T:{temp_str:6s} | D:{disk_mbps_str}MB/s | N:{net_mbps_str}MB/s | RawL:{raw_overall_load:5.1f}% | SmoothL:{smoothed_overall_load:5.1f}% | CalPWM%:{duty_cycle_percent_calibrated:3d}") # Adjust sleep time to minimize CPU load # Ensures the loop doesn't run faster than necessary # and also sleeps if time_delta < UPDATE_INTERVAL_SEC # Calculate remaining time until the next UPDATE_INTERVAL_SEC slot time_to_next_update = last_time + UPDATE_INTERVAL_SEC - time.monotonic() actual_sleep_time = max(0.01, time_to_next_update) # Sleep at least 10ms, or what's left time.sleep(actual_sleep_time) except KeyboardInterrupt: print("\nProgram terminated by user.") except Exception as e: print(f"\nA serious error occurred: {e}") import traceback traceback.print_exc() finally: if pwm_device: print("Cleaning up: Stopping PWM...") try: pwm_device.stop() print("Hardware PWM stopped.") except Exception as cleanup_pwm_e: print(f"W: Error stopping hardware PWM: {cleanup_pwm_e}", file=sys.stderr) else: print("No rpi_hardware_pwm object to clean up.") print("Script finished.")
Das Skript läuft auf dem Raspberry in einem Screen-Terminal. Da ich den Server nur selten neu starte, brauche ich kein eigenes Startup-Skript.
Es ist schon witzig, wie sich jetzt der Zeiger beim Musikhören hin und her bewegt. Am Ausschlag kann ich sogar erkennen, ob ich gerade ein mp3, FLAC oder HiRes FLAC höre. Ob sinnvoll oder nicht, das kleine Projekt hat wieder viel Spaß gemacht. Bei Fragen oder Anregungen, hinterlasst mir einfach eine Nachricht