Switching editors
This commit is contained in:
@@ -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
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__)
|
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
|
||||||
|
# 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
|
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():
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
# 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))
|
sensor = self._row_to_sensor(self.db.get_sensor(mac))
|
||||||
self.send_ntfy_notification(sensor)
|
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."""
|
||||||
|
|||||||
@@ -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...")
|
||||||
|
|||||||
@@ -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]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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:
|
||||||
|
try:
|
||||||
self.sensor_config.add_sensor(mac, name)
|
self.sensor_config.add_sensor(mac, name)
|
||||||
self.discovery_manager.approve(mac)
|
self.discovery_manager.approve(mac)
|
||||||
self.notify(f"Approved {mac} as {name}")
|
self.notify(f"Approved {mac} as {name}")
|
||||||
self.refresh_data()
|
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,15 +177,18 @@ 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)
|
||||||
|
try:
|
||||||
self.discovery_manager.ignore(mac, reason if reason else None)
|
self.discovery_manager.ignore(mac, reason if reason else None)
|
||||||
self.notify(f"Ignored {mac}")
|
self.notify(f"Ignored {mac}")
|
||||||
self.refresh_data()
|
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
|
|
||||||
|
|
||||||
|
if active_tab == "configured":
|
||||||
table = self.query_one("#configured-table", DataTable)
|
table = self.query_one("#configured-table", DataTable)
|
||||||
if table.cursor_row is None:
|
if table.cursor_row is None:
|
||||||
return
|
return
|
||||||
@@ -129,9 +199,33 @@ class SensorpajenApp(App):
|
|||||||
|
|
||||||
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:
|
||||||
|
try:
|
||||||
self.sensor_config.add_sensor(mac, name)
|
self.sensor_config.add_sensor(mac, name)
|
||||||
self.notify(f"Updated {mac} to {name}")
|
self.notify(f"Updated {mac} to {name}")
|
||||||
self.refresh_data()
|
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]
|
||||||
|
|
||||||
|
try:
|
||||||
self.sensor_config.remove_sensor(mac)
|
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.notify(f"Removed {mac}")
|
||||||
self.refresh_data()
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user