From e1434f6423d1f23bc2eaff7b2dbbdedec6057c5c Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Thu, 7 Aug 2025 20:54:20 -0400 Subject: [PATCH] Initial battery monitoring solution added. --- home/stormux/Documents/change_log.md | 1 + root/sanitize.sh | 3 + .../systemd/system/battery-monitor.service | 32 +++ usr/local/bin/battery_monitor.py | 220 ++++++++++++++++++ usr/local/bin/game_launcher.py | 3 +- 5 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 usr/lib/systemd/system/battery-monitor.service create mode 100755 usr/local/bin/battery_monitor.py diff --git a/home/stormux/Documents/change_log.md b/home/stormux/Documents/change_log.md index 1a22add..7cfd958 100644 --- a/home/stormux/Documents/change_log.md +++ b/home/stormux/Documents/change_log.md @@ -5,6 +5,7 @@ Dates are given for the image. All items listed are available for the listed ima ## September 1, 2025 +- Initial battery monitoring setup - Make image size for x86_64 slightly smaller (whew, scary!) - Remove safety check for install to disk - Fix bug with resizing disk in x86_64 diff --git a/root/sanitize.sh b/root/sanitize.sh index acbc3c5..a00262d 100644 --- a/root/sanitize.sh +++ b/root/sanitize.sh @@ -57,6 +57,9 @@ systemctl disable bluetooth.service systemctl disable fstrim.timer systemctl disable sshd.service +# Enable battery monitoring service +systemctl enable battery-monitor.service + # Restore defaults cp /etc/speech-dispatcher/speechd.conf.bak /etc/speech-dispatcher/speechd.conf touch /home/stormux/.firstboot diff --git a/usr/lib/systemd/system/battery-monitor.service b/usr/lib/systemd/system/battery-monitor.service new file mode 100644 index 0000000..63d0143 --- /dev/null +++ b/usr/lib/systemd/system/battery-monitor.service @@ -0,0 +1,32 @@ +[Unit] +Description=Battery Monitor for Stormux Gaming Image +Documentation=man:battery_monitor.py(1) +After=multi-user.target +ConditionPathExists=/sys/class/power_supply + +[Service] +Type=simple +ExecStart=/usr/local/bin/battery_monitor.py +Restart=always +RestartSec=10 +User=root +Group=root + +# Security settings +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +ReadWritePaths=/var/log +ReadOnlyPaths=/sys/class/power_supply + +# Allow access to speech and audio +SupplementaryGroups=audio + +# Environment +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/usr/local/bin/battery_monitor.py b/usr/local/bin/battery_monitor.py new file mode 100755 index 0000000..303ac2d --- /dev/null +++ b/usr/local/bin/battery_monitor.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +""" +Battery Monitor for Stormux Gaming Image + +Monitors battery levels and provides warnings at 10% and 5%, +with automatic shutdown at 3% to prevent data loss. + +Only activates if a real battery is detected to avoid false alarms. +""" + +import os +import sys +import time +import subprocess +import configparser +import logging +from pathlib import Path + +# Set up logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('/var/log/battery_monitor.log'), + logging.StreamHandler() + ] +) +logger = logging.getLogger(__name__) + +class BatteryMonitor: + def __init__(self): + self.config_dir = Path.home() / '.config' / 'stormux' + self.config_file = self.config_dir / 'battery_monitor.conf' + self.warned_10 = False + self.warned_5 = False + self.load_config() + + def load_config(self): + """Load configuration with defaults""" + self.config = configparser.ConfigParser() + + # Default configuration + defaults = { + 'enabled': 'true', + 'warning_10_percent': 'true', + 'warning_5_percent': 'true', + 'shutdown_3_percent': 'true', + 'check_interval': '30', + 'speech_enabled': 'true' + } + + self.config['DEFAULT'] = defaults + + if self.config_file.exists(): + try: + self.config.read(self.config_file) + logger.info(f"Loaded config from {self.config_file}") + except Exception as e: + logger.warning(f"Error reading config: {e}, using defaults") + else: + self.save_config() + + def save_config(self): + """Save current configuration""" + self.config_dir.mkdir(parents=True, exist_ok=True) + with open(self.config_file, 'w') as f: + self.config.write(f) + logger.info(f"Saved config to {self.config_file}") + + def has_battery(self): + """Check if system has a real battery""" + power_supply_dir = Path('/sys/class/power_supply') + if not power_supply_dir.exists(): + return False + + for item in power_supply_dir.iterdir(): + type_file = item / 'type' + if type_file.exists(): + try: + if type_file.read_text().strip() == 'Battery': + logger.info(f"Battery detected: {item.name}") + return True + except Exception: + continue + + logger.info("No battery detected") + return False + + def get_battery_level(self): + """Get current battery percentage""" + power_supply_dir = Path('/sys/class/power_supply') + + for item in power_supply_dir.iterdir(): + type_file = item / 'type' + capacity_file = item / 'capacity' + + if (type_file.exists() and capacity_file.exists()): + try: + if type_file.read_text().strip() == 'Battery': + capacity = int(capacity_file.read_text().strip()) + return capacity + except Exception: + continue + + return None + + def is_on_ac_power(self): + """Check if system is plugged into AC power""" + power_supply_dir = Path('/sys/class/power_supply') + + for item in power_supply_dir.iterdir(): + type_file = item / 'type' + online_file = item / 'online' + + if (type_file.exists() and online_file.exists()): + try: + device_type = type_file.read_text().strip() + if device_type in ['ADP', 'Mains', 'AC']: + online = int(online_file.read_text().strip()) + return online == 1 + except Exception: + continue + + return False + + def speak(self, message): + """Use speech-dispatcher to speak a message""" + if not self.config.getboolean('DEFAULT', 'speech_enabled'): + return + + try: + subprocess.run(['spd-say', '-w', message], check=True) + except Exception as e: + logger.error(f"Speech error: {e}") + + def play_urgent_sound(self): + """Play urgent warning sound using SoX""" + try: + # Blocking sound command as specified + cmd = [ + 'play', '-n', 'synth', '2', 'sine', '700:1000', 'sine', '900:1200', + 'fade', '0', '2', '0', 'remix', '-', 'norm', '-12', 'repeat', '3', + 'overdrive', 'reverb', 'speed', '4' + ] + subprocess.run(cmd, check=True) + except Exception as e: + logger.error(f"Sound error: {e}") + + def handle_low_battery(self, level): + """Handle low battery warnings and actions""" + if level <= 3: + if self.config.getboolean('DEFAULT', 'shutdown_3_percent'): + logger.critical(f"Battery at {level}% - initiating shutdown") + self.speak("Critical battery level. System shutting down now.") + time.sleep(2) # Give speech time to complete + subprocess.run(['sudo', 'poweroff'], check=True) + return + + elif level <= 5 and not self.warned_5: + if self.config.getboolean('DEFAULT', 'warning_5_percent'): + logger.warning(f"Battery at {level}% - urgent warning") + self.play_urgent_sound() + self.speak("Extremely low battery. Computer will shut down soon.") + self.warned_5 = True + + elif level <= 10 and not self.warned_10: + if self.config.getboolean('DEFAULT', 'warning_10_percent'): + logger.warning(f"Battery at {level}% - first warning") + self.speak("Low battery warning. Please connect power adapter.") + self.warned_10 = True + + def reset_warnings_if_charging(self): + """Reset warning flags if system is charging""" + if self.is_on_ac_power(): + if self.warned_10 or self.warned_5: + logger.info("AC power connected - resetting warning flags") + self.warned_10 = False + self.warned_5 = False + + def monitor(self): + """Main monitoring loop""" + if not self.config.getboolean('DEFAULT', 'enabled'): + logger.info("Battery monitoring disabled in config") + return + + if not self.has_battery(): + logger.info("No battery detected - monitoring disabled") + return + + logger.info("Starting battery monitoring") + check_interval = self.config.getint('DEFAULT', 'check_interval') + + while True: + try: + level = self.get_battery_level() + if level is not None: + logger.debug(f"Battery level: {level}%") + self.reset_warnings_if_charging() + self.handle_low_battery(level) + else: + logger.warning("Could not read battery level") + + time.sleep(check_interval) + + except KeyboardInterrupt: + logger.info("Battery monitoring stopped by user") + break + except Exception as e: + logger.error(f"Monitoring error: {e}") + time.sleep(check_interval) + +def main(): + if os.geteuid() != 0: + print("Warning: Running as non-root user. Shutdown functionality will require sudo.") + + monitor = BatteryMonitor() + monitor.monitor() + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/usr/local/bin/game_launcher.py b/usr/local/bin/game_launcher.py index 9a02baf..537c174 100755 --- a/usr/local/bin/game_launcher.py +++ b/usr/local/bin/game_launcher.py @@ -32,7 +32,8 @@ class VoicedMenu: 'D L N A Server': 'minidlna.service', 'Fenrir Screen Reader': 'fenrirscreenreader-tty.service', 'Bluetooth': 'bluetooth.service', - 'SSH': 'sshd.service' + 'SSH': 'sshd.service', + 'Battery Monitoring': 'battery-monitor.service' } # Config settings