diff --git a/agent/system_monitor.py b/agent/system_monitor.py index 0f31a50..40a4629 100644 --- a/agent/system_monitor.py +++ b/agent/system_monitor.py @@ -12,47 +12,55 @@ import requests from pathlib import Path import configparser +try: + import RPi.GPIO as GPIO + # This variable must be GLOBAL, so defined at the top! + GPIO_AVAILABLE = True +except ImportError: + GPIO_AVAILABLE = False + print("Warning: RPi.GPIO library not found. Hardware reset disabled.") + # ========================================== -# 0. CARICAMENTO CONFIGURAZIONE UNIFICATA +# 0. UNIFIED CONFIGURATION LOADING # ========================================== CONFIG_PATH = Path("/opt/node_config.json") def load_config(): try: if not CONFIG_PATH.exists(): - print(f"❌ ERRORE: File {CONFIG_PATH} non trovato!") + print(f"❌ ERROR: File {CONFIG_PATH} not found!") sys.exit(1) with open(CONFIG_PATH, 'r') as f: return json.load(f) except Exception as e: - print(f"❌ ERRORE CRITICO JSON: {e}") + print(f"❌ CRITICAL JSON ERROR: {e}") sys.exit(1) -# Carichiamo l'unica configurazione necessaria +# Load the single necessary configuration cfg = load_config() -# Identificativi e Topic +# Identifiers and Topics CLIENT_ID = cfg.get('client_id', 'iv3jdv').lower() BASE_TOPIC = cfg.get('mqtt', {}).get('base_topic', f"servizi/{CLIENT_ID}") TOPIC_CMD = f"{BASE_TOPIC}/cmnd" TOPIC_STAT = f"{BASE_TOPIC}/stat" -# Variabili di Stato Globali +# Global State Variables boot_recovered = False -current_status = "ONLINE - Pronto" +current_status = "ONLINE - Ready" auto_healing_counter = {} # ========================================== -# 1. FUNZIONE NOTIFICA TELEGRAM +# 1. TELEGRAM NOTIFICATION FUNCTION # ========================================== def send_telegram_message(message): t_cfg = cfg.get('telegram', {}) if not t_cfg.get('enabled', False): return - ora_attuale = int(time.strftime("%H")) - if ora_attuale >= 23 or ora_attuale < 7: - print(f"🌙 Notte fonda ({ora_attuale}:00): Notifica evitata.") + current_hour = int(time.strftime("%H")) + if current_hour >= 23 or current_hour < 7: + print(f"🌙 Late night ({current_hour}:00): Notification skipped.") return token = t_cfg.get('token') @@ -65,58 +73,58 @@ def send_telegram_message(message): payload = {"chat_id": chat_id, "text": f"[{CLIENT_ID.upper()}]\n{clean_msg}"} requests.post(url, json=payload, timeout=10) except Exception as e: - print(f"⚠️ Errore invio Telegram: {e}") + print(f"⚠️ Telegram send error: {e}") # ========================================== -# 2. LOGICA CAMBIO PROFILO MULTIPLO +# 2. MULTIPLE PROFILE SWITCH LOGIC # ========================================== def get_actual_config_from_disk(): - return "ONLINE - Da memoria" + return "ONLINE - From memory" def switch_config(config_type): profile = cfg.get('profiles', {}).get(config_type) if not profile: - return f"ERRORE: Profilo {config_type} non trovato in JSON" + return f"ERROR: Profile {config_type} not found in JSON" - label = profile.get('label', f"Profilo {config_type}") + label = profile.get('label', f"Profile {config_type}") services = profile.get('services', []) if not services: - return f"ERRORE: Nessun servizio configurato per {config_type}" + return f"ERROR: No services configured for {config_type}" try: - # 1. STOP: Ferma prima tutti i demoni coinvolti per liberare i file + # 1. STOP: Stop all involved daemons first to release files for s in services: subprocess.run(["sudo", "systemctl", "stop", s['name']], check=False) - # 2. COPIA: Verifica e copia tutti i file di configurazione + # 2. COPY: Verify and copy all configuration files for s in services: if not os.path.exists(s['source']): - return f"ERRORE: Manca il file sorgente {s['source']}" + return f"ERROR: Missing source file {s['source']}" shutil.copy(s['source'], s['target']) - # 3. START: Fa ripartire tutti i demoni con i nuovi file + # 3. START: Restart all daemons with the new files for s in services: subprocess.run(["sudo", "systemctl", "start", s['name']], check=False) - send_telegram_message(f"✅ Switch multiplo completato: {label}") + send_telegram_message(f"✅ Multiple switch completed: {label}") return f"ONLINE - {label}" except Exception as e: - return f"ERRORE: {str(e)}" + return f"ERROR: {str(e)}" def force_online_if_needed(client): global boot_recovered, current_status if not boot_recovered: - print("⚠️ Recupero memoria saltato. Imposto stato da disco...") + print("⚠️ Memory recovery skipped. Setting status from disk...") current_status = get_actual_config_from_disk() client.publish(TOPIC_STAT, current_status, retain=True) boot_recovered = True # ========================================== -# 3. TELEMETRIA E AUTO-HEALING +# 3. TELEMETRY AND AUTO-HEALING # ========================================== def get_cpu_temperature(): temp = 0.0 @@ -139,8 +147,8 @@ def get_system_status(): "processes": {}, "timestamp": time.strftime("%H:%M:%S"), "profiles": { - "A": cfg.get('profiles', {}).get('A', {}).get('label', 'PROFILO A'), - "B": cfg.get('profiles', {}).get('B', {}).get('label', 'PROFILO B') + "A": cfg.get('profiles', {}).get('A', {}).get('label', 'PROFILE A'), + "B": cfg.get('profiles', {}).get('B', {}).get('label', 'PROFILE B') } } proc_path = Path(cfg['paths'].get('process_list', '')) @@ -151,7 +159,7 @@ def get_system_status(): for name in target_processes: name = name.strip().lower() if name: status["processes"][name] = "online" if name in running_names else "offline" - except Exception as e: print(f"Errore processi: {e}") + except Exception as e: print(f"Process error: {e}") return status def check_auto_healing(client, status): @@ -161,12 +169,12 @@ def check_auto_healing(client, status): attempts = auto_healing_counter.get(proc_name, 0) if attempts < 3: auto_healing_counter[proc_name] = attempts + 1 - msg = f"🛠 Auto-healing: {proc_name} offline. Riavvio {attempts+1}/3..." + msg = f"🛠 Auto-healing: {proc_name} offline. Restarting {attempts+1}/3..." client.publish(f"devices/{CLIENT_ID}/logs", msg) send_telegram_message(msg) subprocess.run(["sudo", "systemctl", "restart", proc_name]) elif attempts == 3: - msg = f"🚨 CRITICO: {proc_name} fallito!" + msg = f"🚨 CRITICAL: {proc_name} failed!" client.publish(f"devices/{CLIENT_ID}/logs", msg) send_telegram_message(msg) auto_healing_counter[proc_name] = 4 @@ -176,7 +184,7 @@ def check_auto_healing(client, status): def publish_all(client): status = get_system_status() - # Lettura della lista file per il menu della Dashboard + # Read file list for Dashboard menu file_list_path = Path(cfg['paths'].get('file_list', '')) status["config_files"] = [] status["files"] = [] @@ -184,9 +192,9 @@ def publish_all(client): if file_list_path.exists(): try: files = file_list_path.read_text(encoding="utf-8").splitlines() - nomi_estrattti = [Path(f.strip()).stem for f in files if f.strip()] - status["config_files"] = nomi_estrattti - status["files"] = nomi_estrattti + extracted_names = [Path(f.strip()).stem for f in files if f.strip()] + status["config_files"] = extracted_names + status["files"] = extracted_names except: pass client.publish(f"devices/{CLIENT_ID}/services", json.dumps(status), qos=1) @@ -209,7 +217,7 @@ def publish_all_ini_files(client): with open(file_list_path, 'r') as f: files_to_parse = [line.strip() for line in f if line.strip()] except Exception as e: - print(f"Errore lettura {file_list_path}: {e}") + print(f"Error reading {file_list_path}: {e}") return for file_path in files_to_parse: @@ -218,38 +226,38 @@ def publish_all_ini_files(client): try: base_name = os.path.splitext(os.path.basename(file_path))[0] - # --- INIZIO PARSER MANUALE (Anti-Chiavi Doppie) --- + # --- START MANUAL PARSER (Anti-Duplicate Keys) --- ini_data = {} current_section = None with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: for line in f: line = line.strip() - # Salta righe vuote o commenti + # Skip empty lines or comments if not line or line.startswith(('#', ';')): continue - # Riconosce le sezioni [Nome Sezione] + # Recognize sections [Section Name] if line.startswith('[') and line.endswith(']'): current_section = line[1:-1].strip() ini_data[current_section] = {} - # Riconosce le chiavi e i valori + # Recognize keys and values elif '=' in line and current_section is not None: k, v = line.split('=', 1) k, v = k.strip(), v.strip() - # LA MAGIA: Se la chiave esiste già, la unisce con una virgola! + # THE MAGIC: If the key already exists, merge it with a comma! if k in ini_data[current_section]: ini_data[current_section][k] = str(ini_data[current_section][k]) + "," + v else: ini_data[current_section][k] = v - # Pubblicazione sul broker MQTT + # Publish on MQTT broker for section, payload in ini_data.items(): topic = f"data/{CLIENT_ID}/{base_name}/{section}" client.publish(topic, json.dumps(payload), retain=True) except Exception as e: - print(f"Errore parsing INI per {file_path}: {e}") + print(f"INI parsing error for {file_path}: {e}") def write_config_from_json(slug, json_payload): file_list_path = Path(cfg['paths'].get('file_list', '')) @@ -259,20 +267,20 @@ def write_config_from_json(slug, json_payload): for f in files: p = Path(f.strip()) if p.stem.lower() == slug.lower(): - nuovi_dati = json.loads(json_payload) + new_data = json.loads(json_payload) shutil.copy(p, str(p) + ".bak") - with open(p, 'w', encoding="utf-8") as file: file.write(nuovi_dati.get("raw_text", "")) + with open(p, 'w', encoding="utf-8") as file: file.write(new_data.get("raw_text", "")) os.system(f"sudo systemctl restart {slug}") - send_telegram_message(f"📝 Config {slug.upper()} aggiornata via Web.") + send_telegram_message(f"📝 Config {slug.upper()} updated via Web.") break - except Exception as e: print(f"Errore scrittura config: {e}") + except Exception as e: print(f"Config write error: {e}") # ========================================== -# 4. CALLBACK MQTT +# 4. MQTT CALLBACKS # ========================================== def on_connect(client, userdata, flags, rc, properties=None): if rc == 0: - print(f"✅ Connesso: {CLIENT_ID.upper()}") + print(f"✅ Connected: {CLIENT_ID.upper()}") client.subscribe([(TOPIC_CMD, 0), (TOPIC_STAT, 0)]) client.subscribe([ ("devices/control/request", 0), @@ -281,7 +289,7 @@ def on_connect(client, userdata, flags, rc, properties=None): ]) threading.Timer(5.0, force_online_if_needed, [client]).start() publish_all(client) - publish_all_ini_files(client) # Pubblica gli INI appena si connette + publish_all_ini_files(client) # Publish INIs as soon as connected def on_message(client, userdata, msg): global boot_recovered, current_status, cfg @@ -289,7 +297,7 @@ def on_message(client, userdata, msg): topic = msg.topic if topic == TOPIC_STAT and not boot_recovered: - if not any(x in payload.upper() for x in ["OFFLINE", "ERRORE", "RIAVVIO"]): + if not any(x in payload.upper() for x in ["OFFLINE", "ERROR", "REBOOT"]): current_status = payload boot_recovered = True client.publish(TOPIC_STAT, current_status, retain=True) @@ -302,16 +310,50 @@ def on_message(client, userdata, msg): boot_recovered = True publish_all(client) elif cmd == "REBOOT": - client.publish(TOPIC_STAT, f"OFFLINE - Riavvio {CLIENT_ID.upper()}...", retain=False) + client.publish(TOPIC_STAT, f"OFFLINE - Rebooting {CLIENT_ID.upper()}...", retain=False) time.sleep(1) subprocess.run(["sudo", "reboot"], check=True) + elif cmd == 'RESET_HAT': + # Correct GPIO pin for MMDVM board hardware reset + RESET_PIN = 21 + + if GPIO_AVAILABLE: + try: + GPIO.setwarnings(False) + GPIO.setmode(GPIO.BCM) + GPIO.setup(RESET_PIN, GPIO.OUT) + + # 1. Send reset pulse (LOW for 0.5 seconds) + GPIO.output(RESET_PIN, GPIO.LOW) + time.sleep(0.5) + GPIO.output(RESET_PIN, GPIO.HIGH) + + # Release GPIO resources + GPIO.cleanup(RESET_PIN) + print(f"[{CLIENT_ID}] RESET pulse sent to GPIO {RESET_PIN}") + + # 2. Wait 1.5 seconds to let the microcontroller firmware reboot + time.sleep(1.5) + + # 3. Restart MMDVMHost service to realign serial communication + print(f"[{CLIENT_ID}] Restarting MMDVMHost...") + subprocess.run(["sudo", "systemctl", "restart", "mmdvmhost"], check=False) + + # 4. Send confirmations to dashboard + client.publish(f"fleet/{CLIENT_ID}/status", "HAT RESET + MMDVM RESTART OK") + client.publish(f"devices/{CLIENT_ID}/logs", "🔌 HAT Reset + MMDVMHost Restarted") + + except Exception as e: + print(f"Error during GPIO/MMDVMHost reset: {e}") + client.publish(f"fleet/{CLIENT_ID}/status", f"RESET ERROR: {e}") + elif cmd in ["TG:OFF", "TG:ON"]: - nuovo_stato = (cmd == "TG:ON") - cfg['telegram']['enabled'] = nuovo_stato + new_state = (cmd == "TG:ON") + cfg['telegram']['enabled'] = new_state try: with open(CONFIG_PATH, 'w') as f: json.dump(cfg, f, indent=4) - client.publish(f"devices/{CLIENT_ID}/logs", f"{'🔔' if nuovo_stato else '🔇'} Notifiche {'ON' if nuovo_stato else 'OFF'}") - if nuovo_stato: send_telegram_message("Notifiche riattivate!") + client.publish(f"devices/{CLIENT_ID}/logs", f"{'🔔' if new_state else '🔇'} Notifications {'ON' if new_state else 'OFF'}") + if new_state: send_telegram_message("Notifications enabled!") except: pass elif topic == "devices/control/request" and payload.lower() in ["status", "update"]: @@ -338,7 +380,7 @@ def on_message(client, userdata, msg): def auto_publish_task(client): while True: status = publish_all(client) - publish_all_ini_files(client) # <--- ECCO IL LOOP CORRETTO! + publish_all_ini_files(client) # <--- HERE IS THE CORRECT LOOP! check_auto_healing(client, status) time.sleep(cfg['settings'].get('update_interval', 30)) diff --git a/templates/index.html b/templates/index.html index bee96a0..061f695 100644 --- a/templates/index.html +++ b/templates/index.html @@ -28,7 +28,7 @@ * { box-sizing: border-box; } body { font-family: 'Inter', sans-serif; background: var(--bg-gradient); background-attachment: fixed; margin: 0; padding: 0; color: var(--text-main); transition: color 0.3s; min-height: 100vh; } - /* Top Bar Fluttuante */ + /* Floating Top Bar */ #top-bar-container { position: sticky; top: 15px; z-index: 100; padding: 0 20px; display: flex; justify-content: center; } #top-bar { background: var(--topbar-bg); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); padding: 12px 30px; display: flex; justify-content: space-between; align-items: center; border-radius: 50px; box-shadow: var(--glass-shadow); border: 1px solid var(--border-color); width: 100%; max-width: 1400px; } @@ -71,7 +71,7 @@ .table-container { background: var(--card-bg); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border-radius: 20px; border: 1px solid var(--border-color); overflow: hidden; max-height: 400px; overflow-y: auto; box-shadow: var(--glass-shadow); margin: 0 20px 40px 20px; } table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.9rem; } - /* Stile intestazione tabella (vetro smerigliato) */ + /* Table Header Frosted Glass */ thead { background: rgba(255, 255, 255, 0.9); position: sticky; top: 0; z-index: 1; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05); } body.dark-mode thead { background: rgba(15, 23, 42, 0.95); box-shadow: 0 4px 6px -1px rgba(0,0,0,0.3); } th { padding: 15px; font-weight: 700; text-transform: uppercase; font-size: 0.75rem; letter-spacing: 0.5px; color: var(--text-muted); } @@ -80,7 +80,7 @@ @keyframes pulse-glow { 0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); } 70% { box-shadow: 0 0 0 10px rgba(239, 68, 68, 0); } 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); } } .blink { animation: pulse-glow 1.5s infinite; color: var(--danger) !important; font-weight: 800 !important; background: rgba(239, 68, 68, 0.1) !important; border-left-color: var(--danger) !important; } - /* Modals (Vetro scuro) */ + /* Modals (Dark Glass) */ .modal-overlay { display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); backdrop-filter:blur(5px); -webkit-backdrop-filter:blur(5px); z-index:1000; align-items:center; justify-content:center; } .modal-content { background:var(--card-bg); backdrop-filter:blur(20px); border:1px solid var(--border-color); padding:30px; border-radius:24px; box-shadow:0 25px 50px -12px rgba(0,0,0,0.5); max-height: 90vh; overflow-y: auto; } @@ -90,7 +90,7 @@ input, select { background: rgba(128,128,128,0.1); border: 1px solid var(--border-color); color: var(--text-main); padding: 10px 15px; border-radius: 12px; font-family: inherit; outline: none; transition: 0.2s; } input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); } input:disabled { opacity: 0.6; cursor: not-allowed; } - /* Fix per il menu a tendina (selezioni) in Dark Mode */ + /* Dropdown Fix in Dark Mode */ option { background: #ffffff; color: #1e293b; } body.dark-mode option { background: #0f172a; color: #f8fafc; } @@ -151,7 +151,7 @@