Switching editors
This commit is contained in:
@@ -26,7 +26,7 @@ classifiers = [
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"pybluez2",
|
||||
"pybluez",
|
||||
"bluepy>=1.3.0",
|
||||
"paho-mqtt>=1.6.0",
|
||||
"textual>=0.40.0",
|
||||
|
||||
198
scripts/dev-remote.sh
Executable file
198
scripts/dev-remote.sh
Executable 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
|
||||
@@ -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)
|
||||
# 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():
|
||||
|
||||
@@ -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
|
||||
|
||||
# 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."""
|
||||
|
||||
@@ -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...")
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
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,15 +177,18 @@ 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)
|
||||
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
|
||||
"""Edit the selected item (sensor or setting)."""
|
||||
active_tab = self.query_one(TabbedContent).active
|
||||
|
||||
if active_tab == "configured":
|
||||
table = self.query_one("#configured-table", DataTable)
|
||||
if table.cursor_row is None:
|
||||
return
|
||||
@@ -129,9 +199,33 @@ class SensorpajenApp(App):
|
||||
|
||||
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]
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user