diff --git a/pyproject.toml b/pyproject.toml index b290e86..75bf41c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ ] dependencies = [ - "pybluez2", + "pybluez", "bluepy>=1.3.0", "paho-mqtt>=1.6.0", "textual>=0.40.0", diff --git a/scripts/dev-remote.sh b/scripts/dev-remote.sh new file mode 100755 index 0000000..faf230b --- /dev/null +++ b/scripts/dev-remote.sh @@ -0,0 +1,198 @@ +#!/bin/bash +set -e + +# Configuration +REMOTE_HOST="10.0.0.1" +REMOTE_USER="pi" +REMOTE_DIR="~/sensorpajen-dev" +REMOTE_VENV="$REMOTE_DIR/.venv" + +# Colors +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +log() { + echo -e "${BLUE}[DEV-REMOTE]${NC} $1" +} + +warn() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +cleanup() { + log "Cleaning up..." + # Kill the background python process if we stored its PID + if [ ! -z "$BG_PID" ]; then + log "Stopping remote dev backend (PID: $BG_PID)..." + ssh -t $REMOTE_USER@$REMOTE_HOST "sudo kill $BG_PID 2>/dev/null || true" + fi + + log "Restarting production service..." + ssh -t $REMOTE_USER@$REMOTE_HOST "sudo systemctl start sensorpajen" + log "Done." +} + +# Trap cleanup on exit +trap cleanup EXIT + +# Resolve script directory and project root +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +# 1. Sync Code +log "Syncing code from $PROJECT_ROOT to $REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR..." +rsync -avz --exclude '.venv' --exclude '__pycache__' --exclude '*.egg-info' \ + "$PROJECT_ROOT/src" "$PROJECT_ROOT/scripts" "$PROJECT_ROOT/pyproject.toml" "$PROJECT_ROOT/config" \ + "$REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR/" + +# 2. Setup Remote Environment (Initial setup) +log "Ensuring remote environment is ready..." +ssh -t $REMOTE_USER@$REMOTE_HOST " + mkdir -p $REMOTE_DIR + cd $REMOTE_DIR + + # Install system dependencies (always check) + echo 'Checking system dependencies...' + # Only run apt-get update if we need to install something to save time? + # Or just run it. Let's run install, it's safer. + if ! dpkg -s libbluetooth-dev >/dev/null 2>&1; then + echo 'Installing libbluetooth-dev...' + sudo apt-get update + sudo apt-get install -y libbluetooth-dev python3-dev + fi + + # Create venv if missing + if [ ! -d .venv ]; then + echo 'Creating virtual environment...' + python3 -m venv .venv + fi + + # Install/Update dependencies + echo 'Installing dependencies (forcing reinstall to ensure code update)...' + .venv/bin/pip install --upgrade pip + .venv/bin/pip install --force-reinstall --no-deps . + + # Verify code sync + echo 'Verifying code sync...' + if grep -q "sensor_config=self.sensor_config" src/sensorpajen/main.py; then + echo "✅ main.py has latest changes." + else + echo "❌ main.py does NOT have latest changes! Rsync might have failed." + fi + + # Ensure config exists (copy from system or examples if totally missing) + if [ ! -d config ]; then mkdir config; fi + + # Rename DB to match default expectation in config.py (sensorpajen.db) if it was copied as discovered_sensors.db + # Blindly try to copy with sudo since we can't check existence in restricted dir + echo 'Copying config files from /etc/sensorpajen (blindly due to permissions)...' + + # DB + # Only copy if they don't exist locally to preserve dev data + if [ ! -f config/sensorpajen.db ]; then + echo 'Copying database from system...' + # Try discovered_sensors.db first, then sensorpajen.db + if [ -f /etc/sensorpajen/discovered_sensors.db ]; then + sudo cp /etc/sensorpajen/discovered_sensors.db config/sensorpajen.db 2>/dev/null || true + elif [ -f /etc/sensorpajen/sensorpajen.db ]; then + sudo cp /etc/sensorpajen/sensorpajen.db config/sensorpajen.db 2>/dev/null || true + fi + # Ensure correct ownership immediately after copy + sudo chown $REMOTE_USER:$REMOTE_USER config/sensorpajen.db 2>/dev/null || true + else + echo 'Preserving existing database config/sensorpajen.db' + fi + + # Configs + # Only copy if they don't exist locally to preserve dev changes + if [ ! -f config/sensors.json ]; then + echo 'Copying sensors.json from system...' + sudo cp /etc/sensorpajen/sensors.json config/sensors.json 2>/dev/null || true + else + echo 'Preserving existing config/sensors.json' + fi + + if [ ! -f config/sensorpajen.env ]; then + echo 'Copying sensorpajen.env from system...' + sudo cp /etc/sensorpajen/sensorpajen.env config/sensorpajen.env 2>/dev/null || true + else + echo 'Preserving existing config/sensorpajen.env' + fi + + # ALWAYS sanitize sensorpajen.env to ensure we don't use system paths + if [ -f config/sensorpajen.env ]; then + echo 'Sanitizing config/sensorpajen.env...' + sudo sed -i '/^SENSOR_CONFIG_FILE/d' config/sensorpajen.env + sudo sed -i '/^DATABASE_FILE/d' config/sensorpajen.env + sudo sed -i '/^DISCOVERED_SENSORS_FILE/d' config/sensorpajen.env + fi + + # Examples (if real config missing) + # We don't need to do anything here, the rsync already brought examples if they exist locally + + # FIX OWNERSHIP recursively + echo 'Fixing permissions...' + sudo chown -R $REMOTE_USER:$REMOTE_USER $REMOTE_DIR +" + +# 3. Stop Production Service +log "Stopping production service to free up Bluetooth..." +ssh -t $REMOTE_USER@$REMOTE_HOST "sudo systemctl stop sensorpajen" + +# 4. Run Dev Backend in Background +log "Starting DEV backend on remote..." + +# Grant capabilities to the venv python +ssh -t $REMOTE_USER@$REMOTE_HOST " + cd $REMOTE_DIR + PYTHON_BIN=\$(readlink -f .venv/bin/python3) + # Install libcap2-bin if missing + if ! command -v setcap &> /dev/null; then + sudo apt-get install -y libcap2-bin + fi + sudo setcap cap_net_raw,cap_net_admin+eip \"\$PYTHON_BIN\" +" + +# Run backend as USER (not sudo) so DB files are owned by user +# Source env vars if available +ssh $REMOTE_USER@$REMOTE_HOST " + cd $REMOTE_DIR + if [ -f config/sensorpajen.env ]; then + set -a + source config/sensorpajen.env + set +a + fi + .venv/bin/python3 -m sensorpajen.main > dev_backend.log 2>&1 & + echo \$! +" > .remote_pid + +BG_PID=$(cat .remote_pid) +rm .remote_pid +log "Dev backend started with PID: $BG_PID" + +# Wait a moment for backend to initialize DB +sleep 3 + +# 5. Run TUI +log "Launching TUI..." +log "${GREEN}Disconnecting will stop the dev backend and restart the service.${NC}" +ssh -t $REMOTE_USER@$REMOTE_HOST " + cd $REMOTE_DIR + source .venv/bin/activate + if [ -f config/sensorpajen.env ]; then + set -a + source config/sensorpajen.env + set +a + fi + # Run TUI + python3 -m sensorpajen.tui.app +" + +# Cleanup happens via trap diff --git a/src/sensorpajen/config.py b/src/sensorpajen/config.py index d06d039..a1a80a0 100644 --- a/src/sensorpajen/config.py +++ b/src/sensorpajen/config.py @@ -14,18 +14,28 @@ from typing import Dict, List, Optional logger = logging.getLogger(__name__) # Determine project root and config directory -# Check if running from system installation (/opt/sensorpajen) or development -_opt_sensorpajen_exists = Path('/opt/sensorpajen').exists() -_var_lib_exists = Path('/var/lib/sensorpajen').exists() +# Check if running from system installation (/opt/sensorpajen) +current_file = Path(__file__).resolve() +is_system_install = str(current_file).startswith('/opt/sensorpajen') -if _opt_sensorpajen_exists: +if is_system_install: # System installation PROJECT_ROOT = Path('/opt/sensorpajen') CONFIG_DIR = Path('/etc/sensorpajen') STATE_DIR = Path('/var/lib/sensorpajen') else: - # Development installation (3 levels up from this file: src/sensorpajen/config.py) - PROJECT_ROOT = Path(__file__).parent.parent.parent + # Development installation + # Enforce using directory explicitly for dev-remote case + # In dev-remote, we run from ~/sensorpajen-dev context + + # Check if we are in sensorpajen-dev environment + cwd = Path.cwd() + if 'sensorpajen-dev' in str(cwd) or (cwd / 'config').exists(): + PROJECT_ROOT = cwd + else: + # Fallback logic + PROJECT_ROOT = Path(__file__).parent.parent.parent + CONFIG_DIR = PROJECT_ROOT / "config" STATE_DIR = CONFIG_DIR @@ -221,6 +231,50 @@ class SensorConfig: except Exception as e: logger.error(f"Error saving sensor config: {e}") + raise e + + +def save_env_var(key: str, value: str): + """ + Update a value in the sensorpajen.env file. + + Args: + key: Environment variable name + value: New value + """ + env_file = CONFIG_DIR / "sensorpajen.env" + + if not env_file.exists(): + raise FileNotFoundError(f"Env file not found: {env_file}") + + try: + lines = [] + with open(env_file, 'r') as f: + lines = f.readlines() + + new_lines = [] + found = False + for line in lines: + if line.strip().startswith(f"{key}="): + new_lines.append(f"{key}={value}\n") + found = True + else: + new_lines.append(line) + + if not found: + # Add to end if not found + if new_lines and not new_lines[-1].endswith('\n'): + new_lines.append('\n') + new_lines.append(f"{key}={value}\n") + + with open(env_file, 'w') as f: + f.writelines(new_lines) + + logger.info(f"Updated {key} in {env_file}") + + except Exception as e: + logger.error(f"Error updating env file: {e}") + raise e def validate_config(): diff --git a/src/sensorpajen/discovery_manager.py b/src/sensorpajen/discovery_manager.py index ecddeb6..03312fc 100644 --- a/src/sensorpajen/discovery_manager.py +++ b/src/sensorpajen/discovery_manager.py @@ -35,15 +35,18 @@ class DiscoveredSensor: class DiscoveryManager: """Manages discovered sensors and their approval status using SQLite.""" - def __init__(self, db_path: str = config.DATABASE_FILE): + + def __init__(self, db_path: str = config.DATABASE_FILE, sensor_config: Optional[config.SensorConfig] = None): """ Initialize discovery manager. Args: db_path: Path to SQLite database file + sensor_config: Optional reference to SensorConfig to filter pending list """ self.db = DatabaseManager(db_path) self.db.initialize() + self.sensor_config = sensor_config def _row_to_sensor(self, row: Dict) -> DiscoveredSensor: """Convert database row to DiscoveredSensor object.""" @@ -90,9 +93,18 @@ class DiscoveryManager: if not existing: logger.info(f"New sensor discovered: {mac} ({name})") - # Send notification for new sensors - sensor = self._row_to_sensor(self.db.get_sensor(mac)) - self.send_ntfy_notification(sensor) + + # Send notification for new sensors ONLY if they are not already configured + is_configured = False + if self.sensor_config: + is_configured = mac in self.sensor_config.get_all_macs() + + if not is_configured: + sensor = self._row_to_sensor(self.db.get_sensor(mac)) + self.send_ntfy_notification(sensor) + else: + logger.debug(f"Sensor {mac} is configured, skipping new sensor notification") + return True return False @@ -123,7 +135,15 @@ class DiscoveryManager: def get_pending(self) -> List[DiscoveredSensor]: """Get list of sensors with status 'pending'.""" rows = self.db.get_sensors(status="pending") - return [self._row_to_sensor(r) for r in rows] + sensors = [self._row_to_sensor(r) for r in rows] + + if self.sensor_config: + # Filter out sensors that are already configured + # (Use MAC from DB row vs keys in sensor_config) + configured_macs = self.sensor_config.get_all_macs() + return [s for s in sensors if s.mac not in configured_macs] + + return sensors def get_new_pending(self) -> List[DiscoveredSensor]: """Get list of pending sensors that haven't been reviewed yet.""" diff --git a/src/sensorpajen/main.py b/src/sensorpajen/main.py index 1fefb81..925b77f 100644 --- a/src/sensorpajen/main.py +++ b/src/sensorpajen/main.py @@ -131,7 +131,7 @@ class Sensorpajen: # Initialize discovery manager self.logger.info("Initializing discovery manager...") - self.discovery_manager = DiscoveryManager() + self.discovery_manager = DiscoveryManager(sensor_config=self.sensor_config) # Initialize MQTT publisher self.logger.info("Initializing MQTT publisher...") diff --git a/src/sensorpajen/sensor_reader.py b/src/sensorpajen/sensor_reader.py index 91d6add..d2d8ba6 100644 --- a/src/sensorpajen/sensor_reader.py +++ b/src/sensorpajen/sensor_reader.py @@ -196,6 +196,19 @@ class SensorReader: # Create measurement for known sensor sensor_name = self.sensor_config.get_name(mac_with_colons) + + # --- PHASE 2: Update live data in DB for TUI --- + self.discovery_manager.add_or_update( + mac_with_colons, + sensor_name, + rssi, + temperature, + humidity, + battery_percent, + battery_voltage + ) + # ----------------------------------------------- + measurement = Measurement( temperature=temperature, humidity=humidity, @@ -254,9 +267,8 @@ class SensorReader: ) if is_new: - logger.info(f"New sensor discovered: {mac} ({device_name})") - sensor = self.discovery_manager.sensors[mac] - self.discovery_manager.send_ntfy_notification(sensor) + # Notification is handled by DiscoveryManager + pass def _parse_atc_data(self, data_str: str) -> Optional[tuple]: """ diff --git a/src/sensorpajen/tui/app.py b/src/sensorpajen/tui/app.py index 06f012b..9c0be7f 100644 --- a/src/sensorpajen/tui/app.py +++ b/src/sensorpajen/tui/app.py @@ -1,10 +1,10 @@ from textual.app import App, ComposeResult -from textual.widgets import Header, Footer, TabbedContent, TabPane, DataTable -from textual.containers import Container +from textual.widgets import Header, Footer, TabbedContent, TabPane, DataTable, Static, Button +from textual.containers import Container, Horizontal from textual import on from ..discovery_manager import DiscoveryManager -from ..config import SensorConfig +from ..config import SensorConfig, save_env_var from .modals import InputModal class SensorpajenApp(App): @@ -38,6 +38,31 @@ class SensorpajenApp(App): #modal-buttons Button { margin: 0 1; } + + /* Dashboard specific CSS */ + .dashboard-row { + height: auto; + margin: 1; + } + + .dash-stat { + width: 1fr; + height: auto; + border: solid $accent; + padding: 1; + margin: 1; + text-align: center; + } + + .dashboard-controls { + height: auto; + align: center middle; + margin-top: 2; + } + + .dashboard-controls Button { + margin: 0 2; + } """ BINDINGS = [ @@ -53,8 +78,9 @@ class SensorpajenApp(App): def __init__(self, **kwargs): super().__init__(**kwargs) - self.discovery_manager = DiscoveryManager() self.sensor_config = SensorConfig() + # Pass sensor_config to discovery manager for filtering + self.discovery_manager = DiscoveryManager(sensor_config=self.sensor_config) def compose(self) -> ComposeResult: """Create child widgets for the app.""" @@ -66,8 +92,46 @@ class SensorpajenApp(App): yield DataTable(id="configured-table", cursor_type="row") with TabPane("Ignored", id="ignored"): yield DataTable(id="ignored-table", cursor_type="row") + with TabPane("Settings", id="settings"): + yield DataTable(id="settings-table", cursor_type="row") + with TabPane("Dashboard", id="dashboard"): + with Horizontal(classes="dashboard-row"): + yield Static("CPU Temp: ...", id="dash-cpu-temp", classes="dash-stat") + yield Static("Load Avg: ...", id="dash-load", classes="dash-stat") + with Horizontal(classes="dashboard-row"): + yield Static("Memory: ...", id="dash-memory", classes="dash-stat") + yield Static("Disk: ...", id="dash-disk", classes="dash-stat") + with Horizontal(classes="dashboard-controls"): + yield Button("Restart Service", id="btn-restart-service", variant="warning") + yield Button("Stop Service", id="btn-stop-service", variant="error") yield Footer() + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button presses.""" + if event.button.id == "btn-restart-service": + self.action_restart_service() + elif event.button.id == "btn-stop-service": + self.action_stop_service() + + def action_restart_service(self) -> None: + """Restart the system service.""" + import subprocess + try: + subprocess.run(["sudo", "systemctl", "restart", "sensorpajen"], check=False) + self.notify("Service restart triggered", severity="information") + except Exception as e: + self.notify(f"Failed to restart: {e}", severity="error") + + def action_stop_service(self) -> None: + """Stop the system service.""" + import subprocess + try: + subprocess.run(["sudo", "systemctl", "stop", "sensorpajen"], check=False) + self.notify("Service stop triggered", severity="warning") + except Exception as e: + self.notify(f"Failed to stop: {e}", severity="error") + + def on_mount(self) -> None: """Handle app mount event.""" self.refresh_data() @@ -91,10 +155,13 @@ class SensorpajenApp(App): name = await self.push_screen(InputModal("Enter sensor name", initial_value=default_name)) if name: - self.sensor_config.add_sensor(mac, name) - self.discovery_manager.approve(mac) - self.notify(f"Approved {mac} as {name}") - self.refresh_data() + try: + self.sensor_config.add_sensor(mac, name) + self.discovery_manager.approve(mac) + self.notify(f"Approved {mac} as {name}") + self.refresh_data() + except Exception as e: + self.notify(f"Error approving sensor: {e}", severity="error") async def action_ignore(self) -> None: """Ignore the selected discovered sensor.""" @@ -110,28 +177,55 @@ class SensorpajenApp(App): reason = await self.push_screen(InputModal("Enter ignore reason (optional)")) if reason is not None: # Allow empty string but not None (Cancel) - self.discovery_manager.ignore(mac, reason if reason else None) - self.notify(f"Ignored {mac}") - self.refresh_data() + try: + self.discovery_manager.ignore(mac, reason if reason else None) + self.notify(f"Ignored {mac}") + self.refresh_data() + except Exception as e: + self.notify(f"Error ignoring sensor: {e}", severity="error") async def action_edit(self) -> None: - """Edit the selected configured sensor.""" - if self.query_one(TabbedContent).active != "configured": - return - - table = self.query_one("#configured-table", DataTable) - if table.cursor_row is None: - return - - row = table.get_row_at(table.cursor_row) - mac = row[0] - current_name = row[1] + """Edit the selected item (sensor or setting).""" + active_tab = self.query_one(TabbedContent).active - name = await self.push_screen(InputModal("Edit sensor name", initial_value=current_name)) - if name: - self.sensor_config.add_sensor(mac, name) - self.notify(f"Updated {mac} to {name}") - self.refresh_data() + if active_tab == "configured": + table = self.query_one("#configured-table", DataTable) + if table.cursor_row is None: + return + + row = table.get_row_at(table.cursor_row) + mac = row[0] + current_name = row[1] + + name = await self.push_screen(InputModal("Edit sensor name", initial_value=current_name)) + if name: + try: + self.sensor_config.add_sensor(mac, name) + self.notify(f"Updated {mac} to {name}") + self.refresh_data() + except Exception as e: + self.notify(f"Error updating sensor: {e}", severity="error") + + elif active_tab == "settings": + table = self.query_one("#settings-table", DataTable) + if table.cursor_row is None: + return + + row = table.get_row_at(table.cursor_row) + key = row[0] + current_value = row[1] + + new_value = await self.push_screen(InputModal(f"Edit {key}", initial_value=str(current_value))) + if new_value is not None: + try: + save_env_var(key, new_value) + self.notify(f"Updated {key}. Restart required!", severity="warning") + # Temporarily update the view although it won't take effect until restart + import os + os.environ[key] = new_value # Update current runtime env for display + self.refresh_data() + except Exception as e: + self.notify(f"Error saving setting: {e}", severity="error") def action_remove(self) -> None: """Remove the selected configured sensor.""" @@ -145,9 +239,16 @@ class SensorpajenApp(App): row = table.get_row_at(table.cursor_row) mac = row[0] - self.sensor_config.remove_sensor(mac) - self.notify(f"Removed {mac}") - self.refresh_data() + try: + self.sensor_config.remove_sensor(mac) + + # Also need to reset its status in DiscoveryManager to make it show up in Discovery again + self.discovery_manager.unignore(mac) # unignore sets status to 'pending' + + self.notify(f"Removed {mac}") + self.refresh_data() + except Exception as e: + self.notify(f"Error removing sensor: {e}", severity="error") def action_unignore(self) -> None: """Unignore the selected sensor.""" @@ -170,6 +271,8 @@ class SensorpajenApp(App): self._update_discovery_table() self._update_configured_table() self._update_ignored_table() + self._update_settings_table() + self._update_dashboard() def _update_discovery_table(self) -> None: table = self.query_one("#discovery-table", DataTable) @@ -191,10 +294,35 @@ class SensorpajenApp(App): def _update_configured_table(self) -> None: table = self.query_one("#configured-table", DataTable) table.clear(columns=True) - table.add_columns("MAC", "Name") + table.add_columns("MAC", "Name", "Temp", "Humidity", "Battery", "RSSI", "Last Seen") for mac, name in self.sensor_config.sensors.items(): - table.add_row(mac, name) + sensor_data = self.discovery_manager.db.get_sensor(mac) + + temp = "N/A" + humidity = "N/A" + battery = "N/A" + rssi = "N/A" + last_seen = "N/A" + + if sensor_data: + # sensor_data is a Row/dict + if sensor_data['last_temp'] is not None: + temp = f"{sensor_data['last_temp']:.1f}°C" + if sensor_data['last_humidity'] is not None: + humidity = f"{sensor_data['last_humidity']}%" + if sensor_data['last_battery_percent'] is not None: + battery = f"{sensor_data['last_battery_percent']}%" + if sensor_data['rssi'] is not None: + rssi = str(sensor_data['rssi']) + if sensor_data['last_seen']: + try: + # Extract time only: 2025-12-27T14:30:00 -> 14:30:00 + last_seen = sensor_data['last_seen'].split("T")[1].split(".")[0] + except IndexError: + last_seen = sensor_data['last_seen'] + + table.add_row(mac, name, temp, humidity, battery, rssi, last_seen) def _update_ignored_table(self) -> None: table = self.query_one("#ignored-table", DataTable) @@ -210,6 +338,71 @@ class SensorpajenApp(App): s.ignore_reason or "" ) + def _update_settings_table(self) -> None: + import os + table = self.query_one("#settings-table", DataTable) + table.clear(columns=True) + table.add_columns("Key", "Value") + + # Relevant keys to show + relevant_prefixes = ["MQTT_", "WATCHDOG_", "ENABLE_BATTERY", "LOG_LEVEL", "NTFY_", "SKIP_IDENTICAL"] + + for key, value in sorted(os.environ.items()): + if any(key.startswith(p) for p in relevant_prefixes): + table.add_row(key, value) + + def _update_dashboard(self) -> None: + """Update dashboard statistics.""" + try: + # CPU Temp + cpu_temp = "N/A" + try: + with open("/sys/class/thermal/thermal_zone0/temp", "r") as f: + temp_raw = int(f.read().strip()) + cpu_temp = f"{temp_raw / 1000.0:.1f}°C" + except: + pass + self.query_one("#dash-cpu-temp", Static).update(f"CPU Temp\n{cpu_temp}") + + # Load Avg + import os + load_avg = os.getloadavg() + self.query_one("#dash-load", Static).update(f"Load Avg\n{load_avg[0]:.2f}, {load_avg[1]:.2f}, {load_avg[2]:.2f}") + + # Memory + mem_used_pct = "N/A" + try: + mem_info = {} + with open("/proc/meminfo", "r") as f: + for line in f: + parts = line.split(":") + if len(parts) == 2: + val = int(parts[1].strip().split()[0]) + mem_info[parts[0]] = val + + if "MemTotal" in mem_info and "MemAvailable" in mem_info: + total = mem_info["MemTotal"] + avail = mem_info["MemAvailable"] + used = total - avail + mem_used_pct = f"{(used / total) * 100:.1f}%" + except: + pass + self.query_one("#dash-memory", Static).update(f"Memory Used\n{mem_used_pct}") + + # Disk + import shutil + disk_used_pct = "N/A" + try: + total, used, free = shutil.disk_usage("/") + disk_used_pct = f"{(used / total) * 100:.1f}%" + except: + pass + self.query_one("#dash-disk", Static).update(f"Disk Usage\n{disk_used_pct}") + + except Exception as e: + # Don't crash TUI on stat failure + pass + def main(): app = SensorpajenApp() app.run()