Switching editors

This commit is contained in:
2025-12-29 12:22:44 +01:00
parent cfa24d1fa5
commit 54d55cf0f6
7 changed files with 525 additions and 48 deletions

View File

@@ -26,7 +26,7 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"pybluez2", "pybluez",
"bluepy>=1.3.0", "bluepy>=1.3.0",
"paho-mqtt>=1.6.0", "paho-mqtt>=1.6.0",
"textual>=0.40.0", "textual>=0.40.0",

198
scripts/dev-remote.sh Executable file
View File

@@ -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

View File

@@ -14,18 +14,28 @@ from typing import Dict, List, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Determine project root and config directory # Determine project root and config directory
# Check if running from system installation (/opt/sensorpajen) or development # Check if running from system installation (/opt/sensorpajen)
_opt_sensorpajen_exists = Path('/opt/sensorpajen').exists() current_file = Path(__file__).resolve()
_var_lib_exists = Path('/var/lib/sensorpajen').exists() is_system_install = str(current_file).startswith('/opt/sensorpajen')
if _opt_sensorpajen_exists: if is_system_install:
# System installation # System installation
PROJECT_ROOT = Path('/opt/sensorpajen') PROJECT_ROOT = Path('/opt/sensorpajen')
CONFIG_DIR = Path('/etc/sensorpajen') CONFIG_DIR = Path('/etc/sensorpajen')
STATE_DIR = Path('/var/lib/sensorpajen') STATE_DIR = Path('/var/lib/sensorpajen')
else: else:
# Development installation (3 levels up from this file: src/sensorpajen/config.py) # Development installation
PROJECT_ROOT = Path(__file__).parent.parent.parent # 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" CONFIG_DIR = PROJECT_ROOT / "config"
STATE_DIR = CONFIG_DIR STATE_DIR = CONFIG_DIR
@@ -221,6 +231,50 @@ class SensorConfig:
except Exception as e: except Exception as e:
logger.error(f"Error saving sensor config: {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(): def validate_config():

View File

@@ -35,15 +35,18 @@ class DiscoveredSensor:
class DiscoveryManager: class DiscoveryManager:
"""Manages discovered sensors and their approval status using SQLite.""" """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. Initialize discovery manager.
Args: Args:
db_path: Path to SQLite database file db_path: Path to SQLite database file
sensor_config: Optional reference to SensorConfig to filter pending list
""" """
self.db = DatabaseManager(db_path) self.db = DatabaseManager(db_path)
self.db.initialize() self.db.initialize()
self.sensor_config = sensor_config
def _row_to_sensor(self, row: Dict) -> DiscoveredSensor: def _row_to_sensor(self, row: Dict) -> DiscoveredSensor:
"""Convert database row to DiscoveredSensor object.""" """Convert database row to DiscoveredSensor object."""
@@ -90,9 +93,18 @@ class DiscoveryManager:
if not existing: if not existing:
logger.info(f"New sensor discovered: {mac} ({name})") logger.info(f"New sensor discovered: {mac} ({name})")
# Send notification for new sensors
sensor = self._row_to_sensor(self.db.get_sensor(mac)) # Send notification for new sensors ONLY if they are not already configured
self.send_ntfy_notification(sensor) 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 True
return False return False
@@ -123,7 +135,15 @@ class DiscoveryManager:
def get_pending(self) -> List[DiscoveredSensor]: def get_pending(self) -> List[DiscoveredSensor]:
"""Get list of sensors with status 'pending'.""" """Get list of sensors with status 'pending'."""
rows = self.db.get_sensors(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]: def get_new_pending(self) -> List[DiscoveredSensor]:
"""Get list of pending sensors that haven't been reviewed yet.""" """Get list of pending sensors that haven't been reviewed yet."""

View File

@@ -131,7 +131,7 @@ class Sensorpajen:
# Initialize discovery manager # Initialize discovery manager
self.logger.info("Initializing discovery manager...") self.logger.info("Initializing discovery manager...")
self.discovery_manager = DiscoveryManager() self.discovery_manager = DiscoveryManager(sensor_config=self.sensor_config)
# Initialize MQTT publisher # Initialize MQTT publisher
self.logger.info("Initializing MQTT publisher...") self.logger.info("Initializing MQTT publisher...")

View File

@@ -196,6 +196,19 @@ class SensorReader:
# Create measurement for known sensor # Create measurement for known sensor
sensor_name = self.sensor_config.get_name(mac_with_colons) 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( measurement = Measurement(
temperature=temperature, temperature=temperature,
humidity=humidity, humidity=humidity,
@@ -254,9 +267,8 @@ class SensorReader:
) )
if is_new: if is_new:
logger.info(f"New sensor discovered: {mac} ({device_name})") # Notification is handled by DiscoveryManager
sensor = self.discovery_manager.sensors[mac] pass
self.discovery_manager.send_ntfy_notification(sensor)
def _parse_atc_data(self, data_str: str) -> Optional[tuple]: def _parse_atc_data(self, data_str: str) -> Optional[tuple]:
""" """

View File

@@ -1,10 +1,10 @@
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, TabbedContent, TabPane, DataTable from textual.widgets import Header, Footer, TabbedContent, TabPane, DataTable, Static, Button
from textual.containers import Container from textual.containers import Container, Horizontal
from textual import on from textual import on
from ..discovery_manager import DiscoveryManager from ..discovery_manager import DiscoveryManager
from ..config import SensorConfig from ..config import SensorConfig, save_env_var
from .modals import InputModal from .modals import InputModal
class SensorpajenApp(App): class SensorpajenApp(App):
@@ -38,6 +38,31 @@ class SensorpajenApp(App):
#modal-buttons Button { #modal-buttons Button {
margin: 0 1; 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 = [ BINDINGS = [
@@ -53,8 +78,9 @@ class SensorpajenApp(App):
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.discovery_manager = DiscoveryManager()
self.sensor_config = SensorConfig() 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: def compose(self) -> ComposeResult:
"""Create child widgets for the app.""" """Create child widgets for the app."""
@@ -66,8 +92,46 @@ class SensorpajenApp(App):
yield DataTable(id="configured-table", cursor_type="row") yield DataTable(id="configured-table", cursor_type="row")
with TabPane("Ignored", id="ignored"): with TabPane("Ignored", id="ignored"):
yield DataTable(id="ignored-table", cursor_type="row") 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() 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: def on_mount(self) -> None:
"""Handle app mount event.""" """Handle app mount event."""
self.refresh_data() self.refresh_data()
@@ -91,10 +155,13 @@ class SensorpajenApp(App):
name = await self.push_screen(InputModal("Enter sensor name", initial_value=default_name)) name = await self.push_screen(InputModal("Enter sensor name", initial_value=default_name))
if name: if name:
self.sensor_config.add_sensor(mac, name) try:
self.discovery_manager.approve(mac) self.sensor_config.add_sensor(mac, name)
self.notify(f"Approved {mac} as {name}") self.discovery_manager.approve(mac)
self.refresh_data() 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: async def action_ignore(self) -> None:
"""Ignore the selected discovered sensor.""" """Ignore the selected discovered sensor."""
@@ -110,28 +177,55 @@ class SensorpajenApp(App):
reason = await self.push_screen(InputModal("Enter ignore reason (optional)")) reason = await self.push_screen(InputModal("Enter ignore reason (optional)"))
if reason is not None: # Allow empty string but not None (Cancel) if reason is not None: # Allow empty string but not None (Cancel)
self.discovery_manager.ignore(mac, reason if reason else None) try:
self.notify(f"Ignored {mac}") self.discovery_manager.ignore(mac, reason if reason else None)
self.refresh_data() 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: async def action_edit(self) -> None:
"""Edit the selected configured sensor.""" """Edit the selected item (sensor or setting)."""
if self.query_one(TabbedContent).active != "configured": active_tab = self.query_one(TabbedContent).active
return
table = self.query_one("#configured-table", DataTable) if active_tab == "configured":
if table.cursor_row is None: table = self.query_one("#configured-table", DataTable)
return if table.cursor_row is None:
return
row = table.get_row_at(table.cursor_row) row = table.get_row_at(table.cursor_row)
mac = row[0] mac = row[0]
current_name = row[1] current_name = row[1]
name = await self.push_screen(InputModal("Edit sensor name", initial_value=current_name)) name = await self.push_screen(InputModal("Edit sensor name", initial_value=current_name))
if name: if name:
self.sensor_config.add_sensor(mac, name) try:
self.notify(f"Updated {mac} to {name}") self.sensor_config.add_sensor(mac, name)
self.refresh_data() 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: def action_remove(self) -> None:
"""Remove the selected configured sensor.""" """Remove the selected configured sensor."""
@@ -145,9 +239,16 @@ class SensorpajenApp(App):
row = table.get_row_at(table.cursor_row) row = table.get_row_at(table.cursor_row)
mac = row[0] mac = row[0]
self.sensor_config.remove_sensor(mac) try:
self.notify(f"Removed {mac}") self.sensor_config.remove_sensor(mac)
self.refresh_data()
# 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: def action_unignore(self) -> None:
"""Unignore the selected sensor.""" """Unignore the selected sensor."""
@@ -170,6 +271,8 @@ class SensorpajenApp(App):
self._update_discovery_table() self._update_discovery_table()
self._update_configured_table() self._update_configured_table()
self._update_ignored_table() self._update_ignored_table()
self._update_settings_table()
self._update_dashboard()
def _update_discovery_table(self) -> None: def _update_discovery_table(self) -> None:
table = self.query_one("#discovery-table", DataTable) table = self.query_one("#discovery-table", DataTable)
@@ -191,10 +294,35 @@ class SensorpajenApp(App):
def _update_configured_table(self) -> None: def _update_configured_table(self) -> None:
table = self.query_one("#configured-table", DataTable) table = self.query_one("#configured-table", DataTable)
table.clear(columns=True) 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(): 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: def _update_ignored_table(self) -> None:
table = self.query_one("#ignored-table", DataTable) table = self.query_one("#ignored-table", DataTable)
@@ -210,6 +338,71 @@ class SensorpajenApp(App):
s.ignore_reason or "" 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(): def main():
app = SensorpajenApp() app = SensorpajenApp()
app.run() app.run()