3 Commits

Author SHA1 Message Date
iv3jdv 5f41744d93 Add HTTPS warnings for Push notifications 2026-04-23 14:46:58 +02:00
iv3jdv bb697750b7 Fix MQTT, memory persitence e UI 2026-04-22 22:03:10 +02:00
iv3jdv df8ac4ab31 Fix MQTT, memory persitence e UI 2026-04-22 22:02:25 +02:00
5 changed files with 151 additions and 95 deletions
+4
View File
@@ -38,6 +38,8 @@ The ecosystem consists of three main parts:
* Run: `python3 app.py` * Run: `python3 app.py`
#### 🔑 Generating VAPID Keys (Push Notifications) #### 🔑 Generating VAPID Keys (Push Notifications)
> ⚠️ **IMPORTANT:** Web Push Notifications strictly require the dashboard to be accessed via a secure **HTTPS** connection (or localhost). Modern browsers will block push features over standard HTTP.
To enable Web Push Notifications, you must generate a unique VAPID key pair for your server. To enable Web Push Notifications, you must generate a unique VAPID key pair for your server.
1. Go to a free online generator like [vapidkeys.com](https://vapidkeys.com/). 1. Go to a free online generator like [vapidkeys.com](https://vapidkeys.com/).
2. Generate the keys. 2. Generate the keys.
@@ -100,6 +102,8 @@ L'ecosistema si compone di tre parti principali:
* Avvia: `python3 app.py` * Avvia: `python3 app.py`
#### 🔑 Generare le chiavi VAPID (Notifiche Push) #### 🔑 Generare le chiavi VAPID (Notifiche Push)
> ⚠️ **IMPORTANTE:** Le Notifiche Push Web richiedono tassativamente che la dashboard sia accessibile tramite una connessione sicura **HTTPS** (o localhost). I browser mobili e desktop bloccano le notifiche su connessioni HTTP non protette.
Per abilitare le Notifiche Push, devi generare una coppia di chiavi VAPID univoca per il tuo server. Per abilitare le Notifiche Push, devi generare una coppia di chiavi VAPID univoca per il tuo server.
1. Vai su un generatore online gratuito come [vapidkeys.com](https://vapidkeys.com/). 1. Vai su un generatore online gratuito come [vapidkeys.com](https://vapidkeys.com/).
2. Genera la coppia di chiavi. 2. Genera la coppia di chiavi.
+96 -73
View File
@@ -15,7 +15,7 @@ import logging
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
# ========================================== # ==========================================
# 0. CONFIGURAZIONE LOGGING & HARDWARE # 0. LOGGING & HARDWARE CONFIGURATION
# ========================================== # ==========================================
logging.basicConfig( logging.basicConfig(
handlers=[ handlers=[
@@ -33,48 +33,48 @@ try:
GPIO_AVAILABLE = True GPIO_AVAILABLE = True
except ImportError: except ImportError:
GPIO_AVAILABLE = False GPIO_AVAILABLE = False
logger.warning("Libreria RPi.GPIO non trovata. Reset hardware disabilitato.") logger.warning("RPi.GPIO library not found. Hardware reset disabled.")
# ========================================== # ==========================================
# 1. CARICAMENTO CONFIGURAZIONE UNIFICATA # 1. UNIFIED CONFIGURATION LOADING
# ========================================== # ==========================================
CONFIG_PATH = Path("/opt/node_config.json") CONFIG_PATH = Path("/opt/node_config.json")
def load_config(): def load_config():
try: try:
if not CONFIG_PATH.exists(): if not CONFIG_PATH.exists():
logger.error(f"ERRORE: File {CONFIG_PATH} non trovato!") logger.error(f"ERROR: File {CONFIG_PATH} not found!")
sys.exit(1) sys.exit(1)
with open(CONFIG_PATH, 'r') as f: with open(CONFIG_PATH, 'r') as f:
return json.load(f) return json.load(f)
except Exception as e: except Exception as e:
logger.error(f"ERRORE CRITICO JSON: {e}") logger.error(f"CRITICAL JSON ERROR: {e}")
sys.exit(1) sys.exit(1)
cfg = load_config() cfg = load_config()
# Identificativi e Topic # Identifiers and Topics
CLIENT_ID = cfg.get('client_id', 'iv3jdv').lower() CLIENT_ID = cfg.get('client_id', 'iv3jdv').lower()
BASE_TOPIC = cfg.get('mqtt', {}).get('base_topic', f"servizi/{CLIENT_ID}") BASE_TOPIC = cfg.get('mqtt', {}).get('base_topic', f"servizi/{CLIENT_ID}")
TOPIC_CMD = f"{BASE_TOPIC}/cmnd" TOPIC_CMD = f"{BASE_TOPIC}/cmnd"
TOPIC_STAT = f"{BASE_TOPIC}/stat" TOPIC_STAT = f"{BASE_TOPIC}/stat"
# Variabili di Stato Globali # Global Status Variables
boot_recovered = False boot_recovered = False
current_status = "ONLINE - Pronto" current_status = "ONLINE"
auto_healing_counter = {} auto_healing_counter = {}
# ========================================== # ==========================================
# 2. FUNZIONE NOTIFICA TELEGRAM # 2. TELEGRAM NOTIFICATION FUNCTION
# ========================================== # ==========================================
def send_telegram_message(message): def send_telegram_message(message):
t_cfg = cfg.get('telegram', {}) t_cfg = cfg.get('telegram', {})
if not t_cfg.get('enabled', False): return if not t_cfg.get('enabled', False): return
ora_attuale = int(time.strftime("%H")) current_hour = int(time.strftime("%H"))
if ora_attuale >= 23 or ora_attuale < 7: if current_hour >= 23 or current_hour < 7:
logger.info(f"🌙 Notte fonda ({ora_attuale}:00): Notifica Telegram evitata.") logger.info(f"🌙 Late night ({current_hour}:00): Telegram notification skipped.")
return return
token = t_cfg.get('token') token = t_cfg.get('token')
@@ -87,25 +87,35 @@ def send_telegram_message(message):
payload = {"chat_id": chat_id, "text": f"[{CLIENT_ID.upper()}]\n{clean_msg}"} payload = {"chat_id": chat_id, "text": f"[{CLIENT_ID.upper()}]\n{clean_msg}"}
requests.post(url, json=payload, timeout=10) requests.post(url, json=payload, timeout=10)
except Exception as e: except Exception as e:
logger.error(f"Errore invio Telegram: {e}") logger.error(f"Telegram sending error: {e}")
# ========================================== # ==========================================
# 3. LOGICA CAMBIO PROFILO MULTIPLO # 3. MULTIPLE PROFILE SWITCH LOGIC
# ========================================== # ==========================================
def get_actual_config_from_disk(): def get_actual_config_from_disk():
return "ONLINE - Da memoria" try:
path = "/opt/last_profile.txt"
if os.path.exists(path):
with open(path, "r") as f:
label = f.read().strip()
if label:
return f"ONLINE - {label}"
except Exception as e:
logger.error(f"Errore lettura memoria profilo: {e}")
return "ONLINE" # Default se il file non esiste o è vuoto
def switch_config(config_type): def switch_config(config_type):
profile = cfg.get('profiles', {}).get(config_type) profile = cfg.get('profiles', {}).get(config_type)
if not profile: 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', []) services = profile.get('services', [])
if not services: if not services:
return f"ERRORE: Nessun servizio configurato per {config_type}" return f"ERROR: No services configured for {config_type}"
try: try:
for s in services: for s in services:
@@ -113,28 +123,32 @@ def switch_config(config_type):
for s in services: for s in services:
if not os.path.exists(s['source']): 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']) shutil.copy(s['source'], s['target'])
for s in services: for s in services:
subprocess.run(["sudo", "systemctl", "start", s['name']], check=False) subprocess.run(["sudo", "systemctl", "start", s['name']], check=False)
send_telegram_message(f"✅ Switch multiplo completato: {label}") # Save the current profile to disk to remember it on reboot
with open("/opt/last_profile.txt", "w") as f:
f.write(label)
send_telegram_message(f"✅ Multiple switch completed: {label}")
return f"ONLINE - {label}" return f"ONLINE - {label}"
except Exception as e: except Exception as e:
return f"ERRORE: {str(e)}" return f"ERROR: {str(e)}"
def force_online_if_needed(client): def force_online_if_needed(client):
global boot_recovered, current_status global boot_recovered, current_status
if not boot_recovered: if not boot_recovered:
logger.info("⚠️ Recupero memoria saltato. Imposto stato da disco...") logger.info("⚠️ Memory recovery skipped. Setting status from disk...")
current_status = get_actual_config_from_disk() current_status = get_actual_config_from_disk()
client.publish(TOPIC_STAT, current_status, retain=True) client.publish(TOPIC_STAT, current_status, retain=True)
boot_recovered = True boot_recovered = True
# ========================================== # ==========================================
# 4. TELEMETRIA E AUTO-HEALING # 4. TELEMETRY AND AUTO-HEALING
# ========================================== # ==========================================
def get_cpu_temperature(): def get_cpu_temperature():
temp = 0.0 temp = 0.0
@@ -157,8 +171,8 @@ def get_system_status():
"processes": {}, "processes": {},
"timestamp": time.strftime("%H:%M:%S"), "timestamp": time.strftime("%H:%M:%S"),
"profiles": { "profiles": {
"A": cfg.get('profiles', {}).get('A', {}).get('label', 'PROFILO A'), "A": cfg.get('profiles', {}).get('A', {}).get('label', 'PROFILE A'),
"B": cfg.get('profiles', {}).get('B', {}).get('label', 'PROFILO B') "B": cfg.get('profiles', {}).get('B', {}).get('label', 'PROFILE B')
} }
} }
proc_path = Path(cfg['paths'].get('process_list', '')) proc_path = Path(cfg['paths'].get('process_list', ''))
@@ -169,7 +183,7 @@ def get_system_status():
for name in target_processes: for name in target_processes:
name = name.strip().lower() name = name.strip().lower()
if name: status["processes"][name] = "online" if name in running_names else "offline" if name: status["processes"][name] = "online" if name in running_names else "offline"
except Exception as e: logger.error(f"Errore controllo processi: {e}") except Exception as e: logger.error(f"Process check error: {e}")
return status return status
def check_auto_healing(client, status): def check_auto_healing(client, status):
@@ -179,33 +193,33 @@ def check_auto_healing(client, status):
attempts = auto_healing_counter.get(proc_name, 0) attempts = auto_healing_counter.get(proc_name, 0)
if attempts < 3: if attempts < 3:
auto_healing_counter[proc_name] = attempts + 1 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) client.publish(f"devices/{CLIENT_ID}/logs", msg)
send_telegram_message(msg) send_telegram_message(msg)
# --- INIZIO MODIFICA: RESET HARDWARE SPECIFICO PER MMDVMHOST --- # --- START MODIFICATION: SPECIFIC HARDWARE RESET FOR MMDVMHOST ---
if proc_name.lower() == "mmdvmhost" and GPIO_AVAILABLE: if proc_name.lower() == "mmdvmhost" and GPIO_AVAILABLE:
logger.info("Esecuzione RESET HAT automatico pre-riavvio MMDVMHost...") logger.info("Executing automatic HAT RESET before restarting MMDVMHost...")
try: try:
RESET_PIN = 21 # Assicurati che il PIN sia quello corretto per i tuoi nodi RESET_PIN = 21 # Ensure the PIN is correct for your nodes
GPIO.setwarnings(False) GPIO.setwarnings(False)
GPIO.setmode(GPIO.BCM) GPIO.setmode(GPIO.BCM)
GPIO.setup(RESET_PIN, GPIO.OUT) GPIO.setup(RESET_PIN, GPIO.OUT)
# Impulso LOW per resettare # LOW pulse to reset
GPIO.output(RESET_PIN, GPIO.LOW) GPIO.output(RESET_PIN, GPIO.LOW)
time.sleep(0.5) time.sleep(0.5)
GPIO.output(RESET_PIN, GPIO.HIGH) GPIO.output(RESET_PIN, GPIO.HIGH)
GPIO.cleanup(RESET_PIN) GPIO.cleanup(RESET_PIN)
# Diamo tempo al microcontrollore di riavviarsi # Give the microcontroller time to restart
time.sleep(1.5) time.sleep(1.5)
client.publish(f"devices/{CLIENT_ID}/logs", "🔌 Impulso GPIO (Reset MMDVM) inviato!") client.publish(f"devices/{CLIENT_ID}/logs", "🔌 GPIO Pulse (MMDVM Reset) sent!")
except Exception as e: except Exception as e:
logger.error(f"Errore GPIO in auto-healing: {e}") logger.error(f"GPIO error in auto-healing: {e}")
# --- FINE MODIFICA --- # --- END MODIFICATION ---
subprocess.run(["sudo", "systemctl", "restart", proc_name]) subprocess.run(["sudo", "systemctl", "restart", proc_name])
elif attempts == 3: elif attempts == 3:
msg = f"🚨 CRITICO: {proc_name} fallito!" msg = f"🚨 CRITICAL: {proc_name} failed!"
client.publish(f"devices/{CLIENT_ID}/logs", msg) client.publish(f"devices/{CLIENT_ID}/logs", msg)
send_telegram_message(msg) send_telegram_message(msg)
auto_healing_counter[proc_name] = 4 auto_healing_counter[proc_name] = 4
@@ -221,9 +235,9 @@ def publish_all(client):
if file_list_path.exists(): if file_list_path.exists():
try: try:
files = file_list_path.read_text(encoding="utf-8").splitlines() files = file_list_path.read_text(encoding="utf-8").splitlines()
nomi_estrattti = [Path(f.strip()).stem for f in files if f.strip()] extracted_names = [Path(f.strip()).stem for f in files if f.strip()]
status["config_files"] = nomi_estrattti status["config_files"] = extracted_names
status["files"] = nomi_estrattti status["files"] = extracted_names
except: pass except: pass
client.publish(f"devices/{CLIENT_ID}/services", json.dumps(status), qos=1) client.publish(f"devices/{CLIENT_ID}/services", json.dumps(status), qos=1)
@@ -246,7 +260,7 @@ def publish_all_ini_files(client):
with open(file_list_path, 'r') as f: with open(file_list_path, 'r') as f:
files_to_parse = [line.strip() for line in f if line.strip()] files_to_parse = [line.strip() for line in f if line.strip()]
except Exception as e: except Exception as e:
logger.error(f"Errore lettura {file_list_path}: {e}") logger.error(f"Error reading {file_list_path}: {e}")
return return
for file_path in files_to_parse: for file_path in files_to_parse:
@@ -275,7 +289,7 @@ def publish_all_ini_files(client):
topic = f"data/{CLIENT_ID}/{base_name}/{section}" topic = f"data/{CLIENT_ID}/{base_name}/{section}"
client.publish(topic, json.dumps(payload), retain=True) client.publish(topic, json.dumps(payload), retain=True)
except Exception as e: except Exception as e:
logger.error(f"Errore parsing INI per {file_path}: {e}") logger.error(f"Error parsing INI for {file_path}: {e}")
def write_config_from_json(slug, json_payload): def write_config_from_json(slug, json_payload):
file_list_path = Path(cfg['paths'].get('file_list', '')) file_list_path = Path(cfg['paths'].get('file_list', ''))
@@ -285,21 +299,21 @@ def write_config_from_json(slug, json_payload):
for f in files: for f in files:
p = Path(f.strip()) p = Path(f.strip())
if p.stem.lower() == slug.lower(): if p.stem.lower() == slug.lower():
nuovi_dati = json.loads(json_payload) new_data = json.loads(json_payload)
shutil.copy(p, str(p) + ".bak") 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}") 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.")
logger.info(f"Configurazione {slug} aggiornata con successo.") logger.info(f"Configuration {slug} updated successfully.")
break break
except Exception as e: logger.error(f"Errore scrittura config: {e}") except Exception as e: logger.error(f"Config writing error: {e}")
# ========================================== # ==========================================
# 5. CALLBACK MQTT # 5. MQTT CALLBACKS
# ========================================== # ==========================================
def on_connect(client, userdata, flags, reason_code, properties=None): def on_connect(client, userdata, flags, reason_code, properties=None):
if reason_code == 0: if reason_code == 0:
logger.info(f"✅ Connesso al broker MQTT: {CLIENT_ID.upper()}") logger.info(f"✅ Connected to MQTT broker: {CLIENT_ID.upper()}")
client.subscribe([(TOPIC_CMD, 0), (TOPIC_STAT, 0)]) client.subscribe([(TOPIC_CMD, 0), (TOPIC_STAT, 0)])
client.subscribe([ client.subscribe([
("devices/control/request", 0), ("devices/control/request", 0),
@@ -310,12 +324,11 @@ def on_connect(client, userdata, flags, reason_code, properties=None):
publish_all(client) publish_all(client)
publish_all_ini_files(client) publish_all_ini_files(client)
else: else:
logger.error(f"Errore connessione MQTT. Codice: {reason_code}") logger.error(f"MQTT connection error. Code: {reason_code}")
def on_disconnect(client, userdata, disconnect_flags, reason_code, properties=None): def on_disconnect(client, userdata, disconnect_flags, reason_code, properties=None):
logger.warning(f"⚠️ Disconnessione dal broker MQTT! Codice: {reason_code}") logger.warning(f"⚠️ Disconnected from MQTT broker! Code: {reason_code}")
logger.error("Forzo il riavvio del processo per ripristinare la rete in modo pulito...") logger.info("Waiting for network return. Paho-MQTT will attempt automatic reconnection...")
os._exit(1) # Uccide lo script immediatamente (Systemd lo farà risorgere)
def on_message(client, userdata, msg): def on_message(client, userdata, msg):
global boot_recovered, current_status, cfg global boot_recovered, current_status, cfg
@@ -323,7 +336,7 @@ def on_message(client, userdata, msg):
topic = msg.topic topic = msg.topic
if topic == TOPIC_STAT and not boot_recovered: 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 current_status = payload
boot_recovered = True boot_recovered = True
client.publish(TOPIC_STAT, current_status, retain=True) client.publish(TOPIC_STAT, current_status, retain=True)
@@ -336,8 +349,8 @@ def on_message(client, userdata, msg):
boot_recovered = True boot_recovered = True
publish_all(client) publish_all(client)
elif cmd == "REBOOT": 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)
logger.info("Comando REBOOT ricevuto. Riavvio sistema...") logger.info("REBOOT command received. Rebooting system...")
time.sleep(1) time.sleep(1)
subprocess.run(["sudo", "reboot"], check=True) subprocess.run(["sudo", "reboot"], check=True)
elif cmd == 'RESET_HAT': elif cmd == 'RESET_HAT':
@@ -351,28 +364,31 @@ def on_message(client, userdata, msg):
time.sleep(0.5) time.sleep(0.5)
GPIO.output(RESET_PIN, GPIO.HIGH) GPIO.output(RESET_PIN, GPIO.HIGH)
GPIO.cleanup(RESET_PIN) GPIO.cleanup(RESET_PIN)
logger.info(f"Impulso di RESET inviato al GPIO {RESET_PIN}") logger.info(f"RESET pulse sent to GPIO {RESET_PIN}")
time.sleep(1.5) time.sleep(1.5)
logger.info("Riavvio di MMDVMHost in corso...") logger.info("Restarting MMDVMHost...")
subprocess.run(["sudo", "systemctl", "restart", "mmdvmhost"], check=False) subprocess.run(["sudo", "systemctl", "restart", "mmdvmhost"], check=False)
client.publish(f"fleet/{CLIENT_ID}/status", "HAT RESET + MMDVM RESTART OK") client.publish(f"fleet/{CLIENT_ID}/status", "HAT RESET + MMDVM RESTART OK")
client.publish(f"devices/{CLIENT_ID}/logs", "🔌 HAT Reset + MMDVMHost Restarted") client.publish(f"devices/{CLIENT_ID}/logs", "🔌 HAT Reset + MMDVMHost Restarted")
except Exception as e: except Exception as e:
logger.error(f"Errore durante il reset GPIO/MMDVMHost: {e}") logger.error(f"Error during GPIO/MMDVMHost reset: {e}")
client.publish(f"fleet/{CLIENT_ID}/status", f"ERRORE RESET: {e}") client.publish(f"fleet/{CLIENT_ID}/status", f"RESET ERROR: {e}")
elif cmd in ["TG:OFF", "TG:ON"]: elif cmd in ["TG:OFF", "TG:ON"]:
nuovo_stato = (cmd == "TG:ON") new_state = (cmd == "TG:ON")
cfg['telegram']['enabled'] = nuovo_stato cfg['telegram']['enabled'] = new_state
try: try:
with open(CONFIG_PATH, 'w') as f: json.dump(cfg, f, indent=4) 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'}") client.publish(f"devices/{CLIENT_ID}/logs", f"{'🔔' if new_state else '🔇'} Notifications {'ON' if new_state else 'OFF'}")
if nuovo_stato: send_telegram_message("Notifiche riattivate!") if new_state: send_telegram_message("Notifications reactivated!")
except Exception as e: logger.error(f"Errore salvataggio stato Telegram: {e}") except Exception as e: logger.error(f"Error saving Telegram status: {e}")
elif topic == "devices/control/request" and payload.lower() in ["status", "update"]: elif topic == "devices/control/request" and payload.lower() in ["status", "update"]:
logger.info("📥 Received global update command (REQ CONFIG)")
publish_all(client) publish_all(client)
publish_all_ini_files(client) publish_all_ini_files(client)
# Force the visual update of the card on the dashboard!
client.publish(TOPIC_STAT, current_status, retain=True)
elif topic == f"devices/{CLIENT_ID}/control": elif topic == f"devices/{CLIENT_ID}/control":
if ":" in payload: if ":" in payload:
@@ -381,11 +397,11 @@ def on_message(client, userdata, msg):
try: try:
subprocess.run(["sudo", "systemctl", action.lower(), service.lower()], check=True) subprocess.run(["sudo", "systemctl", action.lower(), service.lower()], check=True)
client.publish(f"devices/{CLIENT_ID}/logs", f"{action.upper()}: {service}") client.publish(f"devices/{CLIENT_ID}/logs", f"{action.upper()}: {service}")
logger.info(f"Comando servizio eseguito: {action.upper()} {service}") logger.info(f"Service command executed: {action.upper()} {service}")
publish_all(client) publish_all(client)
except Exception as e: except Exception as e:
client.publish(f"devices/{CLIENT_ID}/logs", f"❌ ERROR: {str(e)}") client.publish(f"devices/{CLIENT_ID}/logs", f"❌ ERROR: {str(e)}")
logger.error(f"Errore esecuzione comando servizio: {e}") logger.error(f"Error executing service command: {e}")
elif topic.startswith(f"devices/{CLIENT_ID}/config_set/"): elif topic.startswith(f"devices/{CLIENT_ID}/config_set/"):
slug = topic.split("/")[-1] slug = topic.split("/")[-1]
@@ -402,6 +418,8 @@ def auto_publish_task(client):
time.sleep(cfg['settings'].get('update_interval', 30)) time.sleep(cfg['settings'].get('update_interval', 30))
def start_service(): def start_service():
global current_status
current_status = get_actual_config_from_disk()
client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2, client_id=CLIENT_ID.upper()) client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2, client_id=CLIENT_ID.upper())
client.will_set(TOPIC_STAT, payload=f"OFFLINE - {CLIENT_ID.upper()}", qos=1, retain=False) client.will_set(TOPIC_STAT, payload=f"OFFLINE - {CLIENT_ID.upper()}", qos=1, retain=False)
client.username_pw_set(cfg['mqtt']['user'], cfg['mqtt']['password']) client.username_pw_set(cfg['mqtt']['user'], cfg['mqtt']['password'])
@@ -409,19 +427,24 @@ def start_service():
client.on_disconnect = on_disconnect client.on_disconnect = on_disconnect
client.on_message = on_message client.on_message = on_message
while True: # 1. Start the telemetry "engine" ONLY ONCE
try:
logger.info("Tentativo di connessione al broker MQTT...")
client.connect(cfg['mqtt']['broker'], cfg['mqtt']['port'], 60)
client.loop_start()
threading.Thread(target=auto_publish_task, args=(client,), daemon=True).start() threading.Thread(target=auto_publish_task, args=(client,), daemon=True).start()
# Mantiene il processo vivo while True:
try:
logger.info("Attempting connection to MQTT broker...")
client.connect(cfg['mqtt']['broker'], cfg['mqtt']['port'], 60)
# 2. Start network manager in background (handles reconnections automatically!)
client.loop_start()
# 3. Pause main thread indefinitely
while True: while True:
time.sleep(1) time.sleep(1)
except Exception as e: except Exception as e:
logger.error(f"Impossibile connettersi o connessione persa ({e}). Riprovo in 10 secondi...") # This triggers ONLY if the broker is down when the node boots
logger.error(f"Broker unreachable at boot ({e}). Retrying in 10 seconds...")
time.sleep(10) time.sleep(10)
if __name__ == "__main__": if __name__ == "__main__":
+8
View File
@@ -459,6 +459,14 @@ def update_nodes():
mqtt_backend.publish("devices/control/request", "update") mqtt_backend.publish("devices/control/request", "update")
return jsonify({"success": True}) return jsonify({"success": True})
# Mandiamo il comando "update" direttamente nel topic privato di ciascun nodo
for client in clients_list:
cid = client['id'].lower()
mqtt_backend.publish(f"devices/{cid}/control", "update", qos=1)
logger.info("📢 Inviato comando REQ CONFIG diretto a tutti i nodi della flotta.")
return jsonify({"success": True})
@app.route('/api/users', methods=['GET']) @app.route('/api/users', methods=['GET'])
def get_users(): def get_users():
if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403 if session.get('role') != 'admin': return jsonify({"error": "Unauthorized"}), 403
+8 -10
View File
@@ -4,14 +4,12 @@
This guide describes the steps to install the Central This guide describes the steps to install the Central
Dashboard and the Remote Agents on MMDVM nodes. Dashboard and the Remote Agents on MMDVM nodes.
------------------------------------------------------------ ------------------------------------------------------------
1. PRE-REQUISITES 1. PRE-REQUISITES
------------------------------------------------------------ ------------------------------------------------------------
Ensure Python 3 is installed on all systems. Ensure Python 3 is installed on all systems.
The necessary dependencies are listed in the The necessary dependencies are listed in the
'requirements.txt' file. 'requirements.txt' file.
Install dependencies: Install dependencies:
pip install -r requirements.txt pip install -r requirements.txt
@@ -19,7 +17,6 @@ Install dependencies:
2. SERVER SETUP (CENTRAL HUB) 2. SERVER SETUP (CENTRAL HUB)
------------------------------------------------------------ ------------------------------------------------------------
The server handles the web interface and user permissions. The server handles the web interface and user permissions.
Steps: Steps:
1. Configure 'config.json' using 'config.example.json' as a template. 1. Configure 'config.json' using 'config.example.json' as a template.
2. Enter MQTT credentials and VAPID keys. 2. Enter MQTT credentials and VAPID keys.
@@ -32,6 +29,10 @@ Steps:
------------------------------------------------------------ ------------------------------------------------------------
Required to enable browser and mobile notifications. Required to enable browser and mobile notifications.
⚠️ WARNING: Web Push Notifications strictly require the
dashboard to be accessed via a secure HTTPS connection
(or localhost). They will NOT work over standard HTTP.
1. Go to https://vapidkeys.com/ and generate the keys. 1. Go to https://vapidkeys.com/ and generate the keys.
2. Copy 'Public Key' and 'Private Key' into 'config.json'. 2. Copy 'Public Key' and 'Private Key' into 'config.json'.
3. Set 'vapid_claim_email' (e.g., "mailto:your@email.com"). 3. Set 'vapid_claim_email' (e.g., "mailto:your@email.com").
@@ -40,7 +41,6 @@ Required to enable browser and mobile notifications.
4. AGENT SETUP (REMOTE NODES) 4. AGENT SETUP (REMOTE NODES)
------------------------------------------------------------ ------------------------------------------------------------
To be installed on each Raspberry Pi / MMDVM Node. To be installed on each Raspberry Pi / MMDVM Node.
1. Copy 'system_monitor.py' and 'node_config.json' to 1. Copy 'system_monitor.py' and 'node_config.json' to
'/opt/node_agent/'. '/opt/node_agent/'.
2. Edit 'node_config.json' with a unique 'client_id'. 2. Edit 'node_config.json' with a unique 'client_id'.
@@ -50,7 +50,6 @@ To be installed on each Raspberry Pi / MMDVM Node.
5. RUNNING AS A SERVICE (SYSTEMD) 5. RUNNING AS A SERVICE (SYSTEMD)
------------------------------------------------------------ ------------------------------------------------------------
For auto-start and process monitoring. For auto-start and process monitoring.
Configuration: Configuration:
1. Copy .service files to '/etc/systemd/system/': 1. Copy .service files to '/etc/systemd/system/':
- Server: sudo cp fleet-console.service /etc/systemd/system/ - Server: sudo cp fleet-console.service /etc/systemd/system/
@@ -69,14 +68,12 @@ Configuration:
Questa guida descrive i passaggi per installare la Dashboard Questa guida descrive i passaggi per installare la Dashboard
Centrale e gli Agenti Remoti sui nodi MMDVM. Centrale e gli Agenti Remoti sui nodi MMDVM.
------------------------------------------------------------ ------------------------------------------------------------
1. REQUISITI PRELIMINARI 1. REQUISITI PRELIMINARI
------------------------------------------------------------ ------------------------------------------------------------
Assicurarsi di avere Python 3 installato su tutti i sistemi. Assicurarsi di avere Python 3 installato su tutti i sistemi.
Le dipendenze necessarie sono elencate nel file Le dipendenze necessarie sono elencate nel file
'requirements.txt'. 'requirements.txt'.
Installazione dipendenze: Installazione dipendenze:
pip install -r requirements.txt pip install -r requirements.txt
@@ -84,7 +81,6 @@ Installazione dipendenze:
2. SETUP DEL SERVER (HUB CENTRALE) 2. SETUP DEL SERVER (HUB CENTRALE)
------------------------------------------------------------ ------------------------------------------------------------
Il server gestisce l'interfaccia web e i permessi. Il server gestisce l'interfaccia web e i permessi.
Passaggi: Passaggi:
1. Configura 'config.json' partendo da 'config.example.json'. 1. Configura 'config.json' partendo da 'config.example.json'.
2. Inserisci le credenziali MQTT e le chiavi VAPID. 2. Inserisci le credenziali MQTT e le chiavi VAPID.
@@ -97,6 +93,10 @@ Passaggi:
------------------------------------------------------------ ------------------------------------------------------------
Necessarie per abilitare le notifiche su browser e mobile. Necessarie per abilitare le notifiche su browser e mobile.
⚠️ ATTENZIONE: Le notifiche push richiedono tassativamente
che la dashboard sia accessibile tramite una connessione
sicura HTTPS. I browser bloccano la funzione su HTTP normale.
1. Vai su https://vapidkeys.com/ e genera le chiavi. 1. Vai su https://vapidkeys.com/ e genera le chiavi.
2. Copia 'Public Key' e 'Private Key' nel 'config.json'. 2. Copia 'Public Key' e 'Private Key' nel 'config.json'.
3. Imposta 'vapid_claim_email' (es. "mailto:tua@email.com"). 3. Imposta 'vapid_claim_email' (es. "mailto:tua@email.com").
@@ -105,7 +105,6 @@ Necessarie per abilitare le notifiche su browser e mobile.
4. SETUP DELL'AGENTE (NODI REMOTI) 4. SETUP DELL'AGENTE (NODI REMOTI)
------------------------------------------------------------ ------------------------------------------------------------
Da installare su ogni Raspberry Pi / Nodo MMDVM. Da installare su ogni Raspberry Pi / Nodo MMDVM.
1. Copia 'system_monitor.py' e 'node_config.json' in 1. Copia 'system_monitor.py' e 'node_config.json' in
'/opt/node_agent/'. '/opt/node_agent/'.
2. Modifica 'node_config.json' con il 'client_id' univoco. 2. Modifica 'node_config.json' con il 'client_id' univoco.
@@ -115,7 +114,6 @@ Da installare su ogni Raspberry Pi / Nodo MMDVM.
5. ESECUZIONE COME SERVIZIO (SYSTEMD) 5. ESECUZIONE COME SERVIZIO (SYSTEMD)
------------------------------------------------------------ ------------------------------------------------------------
Per l'avvio automatico e il monitoraggio del processo. Per l'avvio automatico e il monitoraggio del processo.
Configurazione: Configurazione:
1. Copia i file .service in '/etc/systemd/system/': 1. Copia i file .service in '/etc/systemd/system/':
- Server: sudo cp fleet-console.service /etc/systemd/system/ - Server: sudo cp fleet-console.service /etc/systemd/system/
+37 -14
View File
@@ -78,13 +78,23 @@
th { padding: 12px 15px; font-weight: 600; text-transform: uppercase; font-size: 0.75rem; color: var(--text-muted); } th { padding: 12px 15px; font-weight: 600; text-transform: uppercase; font-size: 0.75rem; color: var(--text-muted); }
td { padding: 10px 15px; border-bottom: 1px solid var(--border-color); color: var(--text-main); } td { padding: 10px 15px; border-bottom: 1px solid var(--border-color); color: var(--text-main); }
/* Animazioni Flat per i transiti */ /* Animazioni Uniformate per i transiti */
@keyframes pulse-border {
0% { border-color: var(--border-color); }
50% { border-color: var(--pulse-color, var(--primary)); }
100% { border-color: var(--border-color); }
}
.tx-active-unified {
animation: pulse-border 1.5s infinite !important;
color: var(--text-main) !important;
border-left: 4px solid var(--pulse-color, var(--primary)) !important;
background: #010409 !important;
}
/* La vecchia classe blink la teniamo solo per gli allarmi rossi dei demoni KO */
@keyframes flat-blink { 0% { border-color: var(--border-color); } 50% { border-color: var(--danger); } 100% { border-color: var(--border-color); } } @keyframes flat-blink { 0% { border-color: var(--border-color); } 50% { border-color: var(--danger); } 100% { border-color: var(--border-color); } }
.blink { animation: flat-blink 1.5s infinite; color: var(--danger) !important; } .blink { animation: flat-blink 1.5s infinite; color: var(--danger) !important; }
@keyframes flat-tx { 0% { border-left-color: var(--border-color); background: #010409; } 50% { border-left-color: var(--primary); background: rgba(47, 129, 247, 0.1); } 100% { border-left-color: var(--border-color); background: #010409; } }
.tx-active { animation: flat-tx 1.5s infinite !important; color: var(--text-main) !important; border-color: var(--border-color) !important; }
/* Finestre Modali (Popup) */ /* Finestre Modali (Popup) */
.modal-overlay { display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(1, 4, 9, 0.85); z-index:1000; align-items:center; justify-content:center; } .modal-overlay { display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(1, 4, 9, 0.85); z-index:1000; align-items:center; justify-content:center; }
.modal-content { background:var(--card-bg); border:1px solid var(--border-color); padding:25px; border-radius:6px; max-height: 90vh; overflow-y: auto; box-shadow: 0 10px 30px rgba(0,0,0,0.8); } .modal-content { background:var(--card-bg); border:1px solid var(--border-color); padding:25px; border-radius:6px; max-height: 90vh; overflow-y: auto; box-shadow: 0 10px 30px rgba(0,0,0,0.8); }
@@ -658,18 +668,31 @@
if (altDiv) { if (altDiv) {
altDiv.style.display = "block"; altDiv.innerText = telemetryObj.alt; altDiv.style.display = "block"; altDiv.innerText = telemetryObj.alt;
let altText = telemetryObj.alt.toUpperCase(); let altText = telemetryObj.alt.toUpperCase();
if (altText.includes("NXDN")) activeModeColor = "#10b981"; else if (altText.includes("YSF")) activeModeColor = "#8b5cf6"; else if (altText.includes("D-STAR")) activeModeColor = "#06b6d4"; else if (altText.includes("P25")) activeModeColor = "#f59e0b";
// Assegna il colore in base al modo
if (altText.includes("NXDN")) activeModeColor = "#10b981";
else if (altText.includes("YSF")) activeModeColor = "#8b5cf6";
else if (altText.includes("D-STAR")) activeModeColor = "#06b6d4";
else if (altText.includes("P25")) activeModeColor = "#f59e0b";
isTx = altText.includes("🟢") || altText.includes("🟣") || altText.includes("🔵") || altText.includes("🟠"); isTx = altText.includes("🟢") || altText.includes("🟣") || altText.includes("🔵") || altText.includes("🟠");
altDiv.style.setProperty('color', activeModeColor, 'important');
altDiv.style.setProperty('border-left', `4px solid ${activeModeColor}`, 'important'); // Passiamo il colore al CSS per l'animazione
if (isTx) { altDiv.classList.add('blink'); } else { altDiv.classList.remove('blink'); } altDiv.style.setProperty('--pulse-color', activeModeColor);
if (isTx) {
altDiv.classList.add('tx-active-unified');
} else {
altDiv.classList.remove('tx-active-unified');
altDiv.style.setProperty('color', 'var(--text-muted)', 'important');
altDiv.style.setProperty('border-left', `4px solid var(--border-color)`, 'important');
}
} }
} else { } else {
if (altDiv) altDiv.style.display = "none"; if (altDiv) altDiv.style.display = "none";
if (tsContainer) tsContainer.style.display = "flex"; if (tsContainer) tsContainer.style.display = "flex";
let netObj = data.networks && data.networks[c.id.toLowerCase()] ? data.networks[c.id.toLowerCase()] : {ts1: "", ts2: ""}; let netObj = data.networks && data.networks[c.id.toLowerCase()] ? data.networks[c.id.toLowerCase()] : {ts1: "", ts2: ""};
activeModeColor = "var(--primary)"; activeModeColor = "var(--primary)"; // Default Blu per il DMR
if (ts1Div && ts2Div) { if (ts1Div && ts2Div) {
[ts1Div, ts2Div].forEach((div, idx) => { [ts1Div, ts2Div].forEach((div, idx) => {
@@ -679,15 +702,15 @@
const fullLabel = netName ? `${baseLabel} [${netName}]` : baseLabel; const fullLabel = netName ? `${baseLabel} [${netName}]` : baseLabel;
div.innerText = `${fullLabel}: ${val}`; div.innerText = `${fullLabel}: ${val}`;
div.style.setProperty('--pulse-color', activeModeColor);
if (val.includes("🎙️")) { if (val.includes("🎙️")) {
isTx = true; isTx = true;
div.classList.add('tx-active'); div.classList.add('tx-active-unified');
} else { } else {
div.classList.remove('tx-active'); div.classList.remove('tx-active-unified');
div.style.setProperty('color', 'var(--text-main)', 'important'); div.style.setProperty('color', 'var(--text-muted)', 'important');
div.style.setProperty('border-left-color', 'var(--primary)', 'important'); div.style.setProperty('border-left', '4px solid var(--border-color)', 'important');
div.style.setProperty('background', 'rgba(59, 130, 246, 0.1)', 'important');
} }
}); });
} }