Initial battery monitoring solution added.
This commit is contained in:
@@ -5,6 +5,7 @@ Dates are given for the image. All items listed are available for the listed ima
|
|||||||
|
|
||||||
## September 1, 2025
|
## September 1, 2025
|
||||||
|
|
||||||
|
- Initial battery monitoring setup
|
||||||
- Make image size for x86_64 slightly smaller (whew, scary!)
|
- Make image size for x86_64 slightly smaller (whew, scary!)
|
||||||
- Remove safety check for install to disk
|
- Remove safety check for install to disk
|
||||||
- Fix bug with resizing disk in x86_64
|
- Fix bug with resizing disk in x86_64
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ systemctl disable bluetooth.service
|
|||||||
systemctl disable fstrim.timer
|
systemctl disable fstrim.timer
|
||||||
systemctl disable sshd.service
|
systemctl disable sshd.service
|
||||||
|
|
||||||
|
# Enable battery monitoring service
|
||||||
|
systemctl enable battery-monitor.service
|
||||||
|
|
||||||
# Restore defaults
|
# Restore defaults
|
||||||
cp /etc/speech-dispatcher/speechd.conf.bak /etc/speech-dispatcher/speechd.conf
|
cp /etc/speech-dispatcher/speechd.conf.bak /etc/speech-dispatcher/speechd.conf
|
||||||
touch /home/stormux/.firstboot
|
touch /home/stormux/.firstboot
|
||||||
|
|||||||
@@ -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
|
||||||
Executable
+220
@@ -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()
|
||||||
@@ -32,7 +32,8 @@ class VoicedMenu:
|
|||||||
'D L N A Server': 'minidlna.service',
|
'D L N A Server': 'minidlna.service',
|
||||||
'Fenrir Screen Reader': 'fenrirscreenreader-tty.service',
|
'Fenrir Screen Reader': 'fenrirscreenreader-tty.service',
|
||||||
'Bluetooth': 'bluetooth.service',
|
'Bluetooth': 'bluetooth.service',
|
||||||
'SSH': 'sshd.service'
|
'SSH': 'sshd.service',
|
||||||
|
'Battery Monitoring': 'battery-monitor.service'
|
||||||
}
|
}
|
||||||
|
|
||||||
# Config settings
|
# Config settings
|
||||||
|
|||||||
Reference in New Issue
Block a user