963 lines
38 KiB
Python
963 lines
38 KiB
Python
# system_utils.py
|
|
import tkinter as tk
|
|
from tkinter import messagebox, simpledialog, filedialog, ttk
|
|
import datetime
|
|
import webbrowser
|
|
import subprocess
|
|
import csv
|
|
import threading
|
|
import time
|
|
import psutil
|
|
import os
|
|
import platform
|
|
import uuid
|
|
import pygame
|
|
import json
|
|
import random
|
|
|
|
# Importaciones directas de módulos (Acceso con el prefijo del módulo)
|
|
import config
|
|
import monitor_manager
|
|
|
|
# Inicializar pygame mixer
|
|
try:
|
|
pygame.mixer.init()
|
|
except Exception as e:
|
|
print(f"ADVERTENCIA: No se pudo iniciar pygame.mixer: {e}")
|
|
|
|
# --- Constante para la comunicación de progreso ---
|
|
PROGRESS_FILE = os.path.join(config.BASE_DIR, "task_progress.txt")
|
|
|
|
# ===============================================
|
|
# Log y Soporte
|
|
# ===============================================
|
|
def log_event(message):
|
|
"""Escribe un mensaje en el log del sistema."""
|
|
if config.system_log and config.system_log.winfo_exists():
|
|
timestamp = datetime.datetime.now().strftime("[%H:%M:%S] ")
|
|
config.system_log.config(state=tk.NORMAL)
|
|
config.system_log.insert(tk.END, timestamp + message + "\n")
|
|
config.system_log.see(tk.END)
|
|
config.system_log.config(state=tk.DISABLED)
|
|
|
|
def bytes_a_human_readable(n):
|
|
"""Convierte bytes a KB, MB, GB, etc."""
|
|
symbols = ('K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y')
|
|
prefix = {}
|
|
for i, s in enumerate(symbols):
|
|
prefix[s] = 1 << (i + 1) * 10
|
|
|
|
for s in reversed(symbols):
|
|
if n >= prefix[s]:
|
|
value = float(n) / prefix[s]
|
|
return f'{value:.2f} {s}iB'
|
|
return f'{n} B'
|
|
|
|
# ===============================================
|
|
# Lógica de Persistencia de Alarmas
|
|
# ===============================================
|
|
|
|
def guardar_alarmas():
|
|
"""Guarda la lista de alarmas en un archivo JSON."""
|
|
|
|
# 1. Preparar datos para serializar: Convertir objetos datetime a strings ISO
|
|
data_to_save = {}
|
|
|
|
# Encontramos el último ID usado
|
|
last_id = 0
|
|
|
|
for uid, data in config.alarmas_programadas.items():
|
|
data_to_save[uid] = {
|
|
'time_str': data['time'].isoformat(), # Convertir datetime a string
|
|
'active': data['active'],
|
|
'message': data['message'],
|
|
'sound_file': data['sound_file']
|
|
}
|
|
last_id = max(last_id, uid)
|
|
|
|
final_data = {
|
|
"last_id": last_id,
|
|
"alarms": data_to_save
|
|
}
|
|
|
|
try:
|
|
os.makedirs(os.path.dirname(config.ALARM_SAVE_FILE), exist_ok=True)
|
|
with open(config.ALARM_SAVE_FILE, 'w', encoding='utf-8') as f:
|
|
json.dump(final_data, f, indent=4)
|
|
log_event("Datos de alarma guardados con éxito.")
|
|
except Exception as e:
|
|
log_event(f"ERROR: No se pudieron guardar las alarmas: {e}")
|
|
|
|
def cargar_alarmas(treeview_alarmas=None, root=None):
|
|
"""Carga las alarmas desde el archivo JSON y las añade al modelo y al Treeview."""
|
|
|
|
if not os.path.exists(config.ALARM_SAVE_FILE):
|
|
return
|
|
|
|
try:
|
|
with open(config.ALARM_SAVE_FILE, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
|
|
config.alarma_counter = data.get("last_id", 0)
|
|
alarm_data = data.get("alarms", {})
|
|
|
|
for uid_str, data in alarm_data.items():
|
|
uid = int(uid_str)
|
|
|
|
# 1. Convertir string ISO de vuelta a objeto datetime
|
|
target_time = datetime.datetime.fromisoformat(data['time_str'])
|
|
|
|
# 2. Re-agregar al modelo
|
|
config.alarmas_programadas[uid] = {
|
|
'time': target_time,
|
|
'active': data['active'],
|
|
'message': data['message'],
|
|
'sound_file': data['sound_file']
|
|
}
|
|
|
|
# 3. Si se proporciona Treeview, actualizar la interfaz
|
|
if treeview_alarmas:
|
|
status = "ACTIVA" if data['active'] else "INACTIVA"
|
|
treeview_alarmas.insert('', tk.END, values=(
|
|
uid,
|
|
target_time.strftime('%H:%M'),
|
|
data['message'],
|
|
status,
|
|
target_time.strftime('%Y-%m-%d')
|
|
), tags=('active',) if data['active'] else ('inactive',))
|
|
|
|
# Si la alarma está activa, aseguramos que el hilo de verificación inicie
|
|
if data['active'] and root and not hasattr(agregar_alarma, 'hilo_activo'):
|
|
threading.Thread(target=lambda: verificar_alarma(root, treeview_alarmas), daemon=True).start()
|
|
setattr(agregar_alarma, 'hilo_activo', True)
|
|
|
|
log_event(f"Se cargaron {len(alarm_data)} alarmas.")
|
|
|
|
except Exception as e:
|
|
log_event(f"ERROR: Fallo al cargar el archivo de alarmas: {e}")
|
|
config.alarmas_programadas = {} # Limpiar modelo en caso de fallo
|
|
|
|
# ===============================================
|
|
# Funcionalidades de Alarma (Modificadas para Pygame)
|
|
# ===============================================
|
|
|
|
def verificar_alarma(root, treeview_alarmas):
|
|
"""
|
|
Función del hilo secundario que verifica la hora actual contra la hora objetivo.
|
|
"""
|
|
while config.monitor_running:
|
|
|
|
alarmas_a_chequear = list(config.alarmas_programadas.items())
|
|
now = datetime.datetime.now()
|
|
|
|
for alarma_id, data in alarmas_a_chequear:
|
|
if data['active']:
|
|
target = data['time']
|
|
|
|
# Comparamos hora y minuto
|
|
if now.hour == target.hour and now.minute == target.minute and now.second < 2:
|
|
# Alarma alcanzada!
|
|
root.after(0, lambda: notificar_alarma_alcanzada(alarma_id, data, treeview_alarmas))
|
|
|
|
# Desactivamos la alarma en el modelo para que no se repita
|
|
data['active'] = False
|
|
guardar_alarmas() # Guardar después de desactivar
|
|
|
|
time.sleep(1) # Chequeamos cada segundo
|
|
|
|
def notificar_alarma_alcanzada(alarma_id, data, treeview_alarmas):
|
|
"""Muestra la notificación visual, reproduce el sonido, actualiza el Treeview y registra el evento."""
|
|
|
|
# Detenemos música antes de empezar a sonar la alarma
|
|
detener_mp3()
|
|
|
|
# 1. Reproducir Sonido (Usando pygame)
|
|
sound_file = data['sound_file'] # Usamos el archivo almacenado en el modelo
|
|
|
|
if sound_file and os.path.exists(sound_file):
|
|
try:
|
|
if pygame.mixer.music.get_busy():
|
|
pygame.mixer.music.stop()
|
|
|
|
pygame.mixer.music.load(sound_file)
|
|
pygame.mixer.music.set_volume(config.alarma_volumen)
|
|
pygame.mixer.music.play(-1) # Reproducir en bucle (-1)
|
|
config.alarma_sonando = True
|
|
log_event(f"Sonido de alarma '{os.path.basename(sound_file)}' iniciado.")
|
|
except Exception as e:
|
|
log_event(f"ERROR al reproducir sonido con pygame: {e}")
|
|
else:
|
|
log_event("ADVERTENCIA: Archivo de sonido no encontrado o no válido.")
|
|
|
|
# 2. Notificación visual
|
|
msg = f"¡Alarma programada alcanzada!\nTarea: {data['message']}"
|
|
log_event(f"ALARMA ACTIVADA: '{data['message']}' a las {data['time'].strftime('%H:%M:%S')}")
|
|
messagebox.showinfo("ALARMA", msg)
|
|
|
|
# 3. Actualizar el Treeview para mostrar 'INACTIVA'
|
|
for item in treeview_alarmas.get_children():
|
|
values = treeview_alarmas.item(item, 'values')
|
|
if values and values[0] == str(alarma_id):
|
|
treeview_alarmas.item(item, values=(alarma_id, values[1], values[2], "INACTIVA", values[4]), tags=('inactive',))
|
|
break
|
|
|
|
guardar_alarmas() # Guardar después de actualizar la interfaz
|
|
|
|
def detener_sonido_alarma():
|
|
"""Detiene la reproducción de sonido de la alarma usando pygame."""
|
|
if config.alarma_sonando and pygame.mixer.music.get_busy():
|
|
pygame.mixer.music.stop()
|
|
config.alarma_sonando = False
|
|
log_event("Reproducción de alarma detenida.")
|
|
elif config.alarma_sonando:
|
|
config.alarma_sonando = False
|
|
log_event("Estado de alarma limpiado. No estaba sonando.")
|
|
|
|
def ajustar_volumen_alarma(nuevo_volumen_str):
|
|
"""Ajusta el volumen de la alarma (rango 0.0 a 1.0) desde el slider (0-100)."""
|
|
try:
|
|
vol_int = int(float(nuevo_volumen_str))
|
|
vol = vol_int / 100.0 # Convertir de 0-100 a 0.0-1.0
|
|
|
|
# 1. Actualizar la variable de configuración para futuras alarmas/música
|
|
config.alarma_volumen = vol
|
|
|
|
# 2. Si hay algo sonando, ajustar inmediatamente el volumen
|
|
if pygame.mixer.music.get_busy():
|
|
pygame.mixer.music.set_volume(vol)
|
|
|
|
log_event(f"Volumen de reproducción ajustado a {vol:.2f}.")
|
|
except Exception as e:
|
|
log_event(f"Error al ajustar volumen: {e}")
|
|
|
|
|
|
def agregar_alarma(root, hora_str, minuto_str, tarea, treeview_alarmas, window_to_close=None, sound_file=None):
|
|
"""
|
|
Añade una nueva alarma a la lista (usando ID autonumérico) y actualiza el Treeview.
|
|
Acepta el argumento 'sound_file' para la ruta del sonido seleccionado.
|
|
"""
|
|
|
|
try:
|
|
hora = int(hora_str)
|
|
minuto = int(minuto_str)
|
|
tarea = tarea.strip()
|
|
|
|
if not tarea:
|
|
tarea = "Alarma sin descripción"
|
|
|
|
# Cálculo de la hora objetivo
|
|
now = datetime.datetime.now()
|
|
target_time = datetime.datetime(now.year, now.month, now.day, hora, minuto, 0)
|
|
|
|
if target_time < now:
|
|
target_time += datetime.timedelta(days=1)
|
|
|
|
# 1. Generar ID autonumérico y único
|
|
config.alarma_counter += 1
|
|
alarma_id = config.alarma_counter
|
|
|
|
# 2. Añadir al modelo
|
|
config.alarmas_programadas[alarma_id] = {
|
|
'time': target_time,
|
|
'active': True,
|
|
'message': tarea,
|
|
'sound_file': sound_file if sound_file else config.ALERTA_SOUND_FILE # Usar el seleccionado
|
|
}
|
|
|
|
# 3. Añadir al Treeview
|
|
treeview_alarmas.insert('', tk.END, values=(
|
|
alarma_id,
|
|
target_time.strftime('%H:%M'),
|
|
tarea,
|
|
"ACTIVA",
|
|
target_time.strftime('%Y-%m-%d')
|
|
), tags=('active',))
|
|
|
|
# Configuramos los tags visuales
|
|
treeview_alarmas.tag_configure('active', background='#FFCCCC') # Fondo rojo claro si está activa
|
|
treeview_alarmas.tag_configure('inactive', background='#DDDDDD')
|
|
|
|
log_event(f"Nueva alarma ({alarma_id}) programada: {tarea}")
|
|
|
|
# Guardar inmediatamente después de agregar
|
|
guardar_alarmas()
|
|
|
|
# 4. Iniciar el hilo de verificación si no está activo
|
|
if not hasattr(agregar_alarma, 'hilo_activo'):
|
|
threading.Thread(target=lambda: verificar_alarma(root, treeview_alarmas), daemon=True).start()
|
|
setattr(agregar_alarma, 'hilo_activo', True)
|
|
|
|
# 5. Cerrar la ventana flotante si existe
|
|
if window_to_close:
|
|
window_to_close.destroy()
|
|
|
|
|
|
except ValueError:
|
|
messagebox.showerror("Error de entrada", "Asegúrate de seleccionar una hora y minuto válidos (00-23, 00-59).")
|
|
|
|
def toggle_alarma(treeview_alarmas):
|
|
"""Activa o desactiva la alarma seleccionada."""
|
|
selected_item = treeview_alarmas.focus()
|
|
if not selected_item:
|
|
messagebox.showwarning("Advertencia", "Selecciona una alarma de la lista.")
|
|
return
|
|
|
|
alarma_id = int(treeview_alarmas.item(selected_item, 'values')[0])
|
|
data = config.alarmas_programadas.get(alarma_id)
|
|
|
|
if data:
|
|
data['active'] = not data['active']
|
|
new_status = "ACTIVA" if data['active'] else "INACTIVA"
|
|
|
|
# Actualizar Treeview y tags visuales
|
|
treeview_alarmas.item(selected_item, values=(alarma_id, data['time'].strftime('%H:%M'), data['message'], new_status, data['time'].strftime('%Y-%m-%d')), tags=('active',) if data['active'] else ('inactive',))
|
|
|
|
log_event(f"Alarma {alarma_id} toggled a {new_status}.")
|
|
guardar_alarmas() # Guardar después del toggle
|
|
|
|
def eliminar_alarma(treeview_alarmas):
|
|
"""Elimina la alarma seleccionada del modelo y del Treeview."""
|
|
selected_item = treeview_alarmas.focus()
|
|
if not selected_item:
|
|
messagebox.showwarning("Advertencia", "Selecciona una alarma para eliminar.")
|
|
return
|
|
|
|
alarma_id = int(treeview_alarmas.item(selected_item, 'values')[0])
|
|
|
|
if messagebox.askyesno("Confirmar Eliminación", f"¿Estás seguro de que deseas eliminar la alarma con ID {alarma_id}?"):
|
|
|
|
# 1. Eliminar del modelo
|
|
if alarma_id in config.alarmas_programadas:
|
|
del config.alarmas_programadas[alarma_id]
|
|
|
|
# 2. Eliminar del Treeview
|
|
treeview_alarmas.delete(selected_item)
|
|
log_event(f"Alarma {alarma_id} eliminada.")
|
|
guardar_alarmas() # Guardar después de la eliminación
|
|
|
|
def modificar_alarma_existente(root, alarma_id, hora_str, minuto_str, tarea, treeview_alarmas, window_to_close, sound_file):
|
|
"""
|
|
Sobreescribe los datos de una alarma existente en el modelo y actualiza el Treeview.
|
|
"""
|
|
|
|
try:
|
|
data = config.alarmas_programadas.get(alarma_id)
|
|
if not data:
|
|
messagebox.showerror("Error", "Alarma no encontrada en el sistema.")
|
|
return
|
|
|
|
hora = int(hora_str)
|
|
minuto = int(minuto_str)
|
|
tarea = tarea.strip() or "Alarma sin descripción"
|
|
|
|
# 1. Recalcular la hora objetivo
|
|
now = datetime.datetime.now()
|
|
target_time = datetime.datetime(now.year, now.month, now.day, hora, minuto, 0)
|
|
if target_time < now:
|
|
target_time += datetime.timedelta(days=1)
|
|
|
|
# 2. Actualizar el modelo (manteniendo el estado 'active' actual)
|
|
data['time'] = target_time
|
|
data['message'] = tarea
|
|
data['sound_file'] = sound_file
|
|
|
|
# 3. Actualizar el Treeview
|
|
new_status = "ACTIVA" if data['active'] else "INACTIVA"
|
|
|
|
# Encontramos el item en el Treeview para actualizarlo
|
|
for item in treeview_alarmas.get_children():
|
|
if treeview_alarmas.item(item, 'values')[0] == str(alarma_id):
|
|
treeview_alarmas.item(item, values=(
|
|
alarma_id,
|
|
target_time.strftime('%H:%M'),
|
|
tarea,
|
|
new_status,
|
|
target_time.strftime('%Y-%m-%d')
|
|
), tags=('active',) if data['active'] else ('inactive',))
|
|
break
|
|
|
|
log_event(f"Alarma {alarma_id} modificada y re-programada para {target_time.strftime('%H:%M')}.")
|
|
guardar_alarmas()
|
|
window_to_close.destroy()
|
|
|
|
except ValueError:
|
|
messagebox.showerror("Error de entrada", "Asegúrate de seleccionar una hora y minuto válidos.")
|
|
except Exception as e:
|
|
log_event(f"ERROR al modificar alarma: {e}")
|
|
messagebox.showerror("Error", f"Error al modificar la alarma: {e}")
|
|
|
|
|
|
def seleccionar_archivo_alarma(root, label_archivo):
|
|
"""Abre un diálogo para seleccionar un archivo de sonido de la carpeta alarmas."""
|
|
|
|
try:
|
|
# Asegurar que la carpeta exista antes de usarla como directorio inicial
|
|
os.makedirs(config.ALARM_FOLDER, exist_ok=True)
|
|
except Exception as e:
|
|
log_event(f"ERROR: No se pudo crear la carpeta de alarmas: {e}")
|
|
messagebox.showerror("Error", f"No se pudo crear el directorio de alarmas: {e}")
|
|
|
|
try:
|
|
archivo_seleccionado = filedialog.askopenfilename(
|
|
initialdir=config.ALARM_FOLDER,
|
|
title="Seleccionar Sonido de Alarma",
|
|
filetypes=(("Archivos de Audio", "*.wav *.mp3"), ("Todos los archivos", "*.*")),
|
|
parent=root
|
|
)
|
|
|
|
if archivo_seleccionado:
|
|
# 1. Almacenar la ruta completa en la variable de configuración global
|
|
config.ALERTA_SOUND_FILE = archivo_seleccionado
|
|
|
|
# 2. Actualizar el Label en la UI para mostrar el nombre del archivo
|
|
label_archivo.config(text=os.path.basename(archivo_seleccionado))
|
|
|
|
log_event(f"Sonido seleccionado: {os.path.basename(archivo_seleccionado)}")
|
|
return archivo_seleccionado
|
|
else:
|
|
log_event("Selección de sonido cancelada.")
|
|
return config.ALERTA_SOUND_FILE
|
|
|
|
except Exception as e:
|
|
log_event(f"ERROR al iniciar el diálogo de selección de archivo: {e}")
|
|
messagebox.showerror("Error", "No se pudo iniciar el diálogo de selección de archivo.")
|
|
return config.ALERTA_SOUND_FILE
|
|
|
|
|
|
# ===============================================
|
|
# Funcionalidades de Reproducción de Música (NUEVAS)
|
|
# ===============================================
|
|
|
|
def seleccionar_mp3(root, label_archivo):
|
|
"""Abre un diálogo para seleccionar un archivo MP3 o WAV."""
|
|
|
|
try:
|
|
# Directorio inicial
|
|
initial_dir = os.path.join(config.BASE_DIR, "data")
|
|
os.makedirs(initial_dir, exist_ok=True)
|
|
|
|
archivo_seleccionado = filedialog.askopenfilename(
|
|
initialdir=initial_dir,
|
|
title="Seleccionar Archivo de Música",
|
|
filetypes=(("Archivos de Audio", "*.mp3 *.wav"), ("Todos los archivos", "*.*")),
|
|
parent=root
|
|
)
|
|
|
|
if archivo_seleccionado:
|
|
config.current_music_file = archivo_seleccionado
|
|
label_archivo.config(text=os.path.basename(archivo_seleccionado))
|
|
log_event(f"Música seleccionada: {os.path.basename(archivo_seleccionado)}")
|
|
return archivo_seleccionado
|
|
else:
|
|
log_event("Selección de música cancelada.")
|
|
return None
|
|
|
|
except Exception as e:
|
|
log_event(f"ERROR al iniciar el diálogo de selección de música: {e}")
|
|
messagebox.showerror("Error", "No se pudo iniciar el diálogo de selección de archivo.")
|
|
return None
|
|
|
|
def reproducir_mp3(root):
|
|
"""Carga y reproduce el archivo seleccionado, asegurando que no haya conflictos con la alarma."""
|
|
|
|
if not config.current_music_file or not os.path.exists(config.current_music_file):
|
|
messagebox.showwarning("Advertencia", "Por favor, selecciona un archivo de música primero.")
|
|
return
|
|
|
|
# Detener cualquier reproducción previa (alarma o música)
|
|
detener_sonido_alarma() # Detiene la alarma si está sonando
|
|
detener_mp3() # Detiene la música si está sonando
|
|
|
|
try:
|
|
pygame.mixer.music.load(config.current_music_file)
|
|
# Reutilizamos la variable de volumen global, ya que Pygame Mixer tiene un solo canal de control de volumen
|
|
pygame.mixer.music.set_volume(config.alarma_volumen)
|
|
pygame.mixer.music.play(-1) # Reproducir en bucle
|
|
config.music_sonando = True
|
|
log_event(f"Reproducción de música iniciada: {os.path.basename(config.current_music_file)}")
|
|
except pygame.error as e:
|
|
log_event(f"ERROR de Pygame al reproducir música: {e}")
|
|
messagebox.showerror("Error de Reproducción", f"No se pudo reproducir el archivo. Asegúrate de que sea compatible con Pygame. ({e})")
|
|
except Exception as e:
|
|
log_event(f"ERROR inesperado al reproducir música: {e}")
|
|
|
|
def detener_mp3():
|
|
"""Detiene la reproducción de música si está activa."""
|
|
if hasattr(config, 'music_sonando') and config.music_sonando and pygame.mixer.music.get_busy():
|
|
pygame.mixer.music.stop()
|
|
config.music_sonando = False
|
|
log_event("Reproducción de música detenida.")
|
|
elif hasattr(config, 'music_sonando') and config.music_sonando:
|
|
config.music_sonando = False
|
|
log_event("Estado de música limpiado. No estaba sonando.")
|
|
|
|
def ajustar_volumen_mp3(nuevo_volumen_str):
|
|
"""Ajusta el volumen de Pygame (rango 0.0 a 1.0) desde el slider (0-100)."""
|
|
# Reutiliza la misma lógica que ajustar_volumen_alarma, ya que Pygame Mixer solo tiene un volumen maestro.
|
|
ajustar_volumen_alarma(nuevo_volumen_str)
|
|
|
|
|
|
# ===============================================
|
|
# Funcionalidades Externas (Existentes)
|
|
# ===============================================
|
|
def lanzar_url(url):
|
|
"""Abre una URL en el navegador y registra el evento."""
|
|
try:
|
|
webbrowser.open_new_tab(url)
|
|
log_event(f"Lanzando URL: {url}")
|
|
except Exception as e:
|
|
log_event(f"Error al intentar abrir la URL {url}: {e}")
|
|
|
|
def ejecutar_script_en_hilo(status_label, root):
|
|
"""Ejecuta un script de Bash en un hilo separado con ProgressBar."""
|
|
|
|
def run_script():
|
|
if not root.winfo_exists(): return
|
|
|
|
# 1. PREPARACIÓN E INICIO DE PROGRESSBAR
|
|
if os.path.exists(config.PROGRESS_FILE): os.remove(config.PROGRESS_FILE)
|
|
if config.progress_bar:
|
|
root.after(0, config.progress_bar.config, {"value": 0, "maximum": 100})
|
|
root.after(0, config.progress_bar.start, 20)
|
|
|
|
status_label.after(0, status_label.config, {"text": "Ejecutando Backup...", "bg": "orange"})
|
|
log_event("Iniciando copia de seguridad (backup)...")
|
|
|
|
try:
|
|
command = ["bash", config.SCRIPT_PATH, config.BASE_DIR]
|
|
result = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
|
|
if not root.winfo_exists(): return # Doble chequeo después de subprocess
|
|
|
|
# 2. FINALIZACIÓN Y LIMPIEZA
|
|
if config.progress_bar:
|
|
root.after(0, config.progress_bar.stop)
|
|
root.after(0, config.progress_bar.config, {"value": 100 if result.returncode == 0 else 0})
|
|
|
|
if result.returncode == 0:
|
|
if root.winfo_exists(): root.after(0, status_label.config, {"text": "Backup: OK", "bg": "green"})
|
|
log_event("Copia de seguridad finalizada con éxito.")
|
|
else:
|
|
if root.winfo_exists(): root.after(0, status_label.config, {"text": f"Backup: ERROR ({result.returncode})", "bg": "red"})
|
|
log_event(f"ERROR en la copia de seguridad. Código: {result.returncode}. Verifique la terminal.")
|
|
|
|
except Exception as e:
|
|
if config.progress_bar: root.after(0, config.progress_bar.stop)
|
|
if root.winfo_exists(): root.after(0, status_label.config, {"text": f"Error desconocido: {str(e)}", "bg": "red"})
|
|
log_event(f"Error desconocido en backup: {str(e)}")
|
|
|
|
finally:
|
|
if os.path.exists(config.PROGRESS_FILE): os.remove(config.PROGRESS_FILE)
|
|
|
|
|
|
# 3. INICIO DEL HILO DE TAREA Y EL HILO MONITOR
|
|
threading.Thread(target=run_script, daemon=True).start()
|
|
threading.Thread(target=lambda: read_progress_pipe(root), daemon=True).start()
|
|
|
|
|
|
def read_progress_pipe(root):
|
|
"""Hilo que monitorea un archivo temporal para actualizar la ProgressBar."""
|
|
if not config.progress_bar: return
|
|
|
|
while config.monitor_running:
|
|
if not root.winfo_exists(): break
|
|
|
|
try:
|
|
if os.path.exists(config.PROGRESS_FILE):
|
|
with open(config.PROGRESS_FILE, 'r') as f:
|
|
content = f.read().strip()
|
|
|
|
if content.isdigit():
|
|
value = int(content)
|
|
root.after(0, config.progress_bar.config, {"value": value})
|
|
root.after(0, config.progress_bar.update)
|
|
elif content.startswith("STATUS:"):
|
|
log_event(content[7:])
|
|
|
|
except Exception as e:
|
|
pass
|
|
|
|
time.sleep(0.1)
|
|
|
|
|
|
def update_time(status_bar, root):
|
|
"""Función que actualiza la hora y el día de la semana en un label (Hilo)."""
|
|
while config.monitor_running:
|
|
now = datetime.datetime.now()
|
|
day_of_week = now.strftime("%A")
|
|
time_str = now.strftime("%H:%M:%S")
|
|
date_str = now.strftime("%Y-%m-%d")
|
|
label_text = f"{day_of_week}, {date_str} - {time_str}"
|
|
|
|
if root.winfo_exists():
|
|
root.after(1000, lambda: status_bar.winfo_exists() and status_bar.config(text=label_text))
|
|
else:
|
|
break
|
|
|
|
time.sleep(1)
|
|
|
|
def manejar_registro_csv(status_label):
|
|
"""Inicia o detiene la escritura de datos de recursos a un archivo CSV."""
|
|
|
|
if config.registro_csv_activo:
|
|
config.registro_csv_activo = False
|
|
status_label.config(text="Registro: Detenido", bg="gray")
|
|
log_event("Registro de historial de recursos detenido.")
|
|
else:
|
|
config.registro_csv_activo = True
|
|
status_label.config(text="Registro: ACTIVO", bg="gold")
|
|
log_event(f"Registro de historial de recursos iniciado en: {config.archivo_registro_csv}")
|
|
|
|
try:
|
|
with open(config.archivo_registro_csv, mode='a', newline='') as file:
|
|
writer = csv.writer(file)
|
|
if file.tell() == 0:
|
|
writer.writerow(['Timestamp', 'CPU_Total (%)', 'RAM_Total (%)', 'Net_Sent (KB/s)', 'Net_Recv (KB/s)'])
|
|
except Exception as e:
|
|
log_event(f"ERROR: No se pudo iniciar el registro CSV: {e}")
|
|
config.registro_csv_activo = False
|
|
status_label.config(text="Registro: ERROR", bg="red")
|
|
|
|
return config.registro_csv_activo
|
|
|
|
# ===============================================
|
|
# Funciones del Editor de Texto (Existentes)
|
|
# ===============================================
|
|
|
|
def nuevo_archivo():
|
|
"""Limpia el contenido actual del editor de texto."""
|
|
if not config.editor_texto:
|
|
log_event("ERROR: Editor de texto no inicializado.")
|
|
return
|
|
|
|
config.editor_texto.delete("1.0", tk.END)
|
|
log_event("Editor de texto limpiado. Nuevo archivo iniciado.")
|
|
|
|
def abrir_carpeta_especifica(ruta, nombre):
|
|
"""Abre una carpeta específica del proyecto en el explorador de archivos."""
|
|
|
|
try:
|
|
os.makedirs(ruta, exist_ok=True)
|
|
# Usamos subprocess.Popen para ser compatible con más sistemas
|
|
if platform.system() == "Windows":
|
|
subprocess.Popen(['explorer', ruta])
|
|
elif platform.system() == "Darwin": # macOS
|
|
subprocess.Popen(['open', ruta])
|
|
else: # Asume Linux/Unix (usa xdg-open)
|
|
subprocess.Popen(['xdg-open', ruta])
|
|
|
|
log_event(f"Carpeta '{nombre}' abierta: {ruta}")
|
|
except Exception as e:
|
|
log_event(f"ERROR al abrir la carpeta '{nombre}': {e}")
|
|
messagebox.showerror("Error", f"No se pudo abrir el directorio {nombre}: {e}")
|
|
|
|
def abrir_carpeta_notas():
|
|
"""Función wrapper para abrir la carpeta de notas."""
|
|
abrir_carpeta_especifica(config.NOTES_FOLDER, "Notas")
|
|
|
|
|
|
def abrir_archivo(root):
|
|
"""Muestra un diálogo para seleccionar y abrir un archivo .txt de la carpeta de notas."""
|
|
if not config.editor_texto:
|
|
log_event("ERROR: Editor de texto no inicializado.")
|
|
return
|
|
|
|
# Usar la nueva carpeta de notas
|
|
ruta_notas = config.NOTES_FOLDER
|
|
|
|
# Asegurar que la carpeta exista antes de abrir el diálogo
|
|
os.makedirs(ruta_notas, exist_ok=True)
|
|
|
|
archivo_seleccionado = filedialog.askopenfilename(
|
|
initialdir=ruta_notas,
|
|
title="Abrir Archivo de Notas",
|
|
filetypes=(("Archivos de texto", "*.txt"), ("Todos los archivos", "*.*")),
|
|
parent=root
|
|
)
|
|
|
|
if not archivo_seleccionado:
|
|
log_event("Apertura de archivo de notas cancelada.")
|
|
return
|
|
|
|
try:
|
|
with open(archivo_seleccionado, 'r', encoding='utf-8') as f:
|
|
contenido = f.read()
|
|
|
|
config.editor_texto.delete("1.0", tk.END)
|
|
config.editor_texto.insert("1.0", contenido)
|
|
|
|
nombre = os.path.basename(archivo_seleccionado)
|
|
log_event(f"Archivo de notas cargado con éxito: {nombre}")
|
|
messagebox.showinfo("Abierto", f"Archivo '{nombre}' cargado con éxito.")
|
|
|
|
except Exception as e:
|
|
log_event(f"ERROR al abrir o leer el archivo de notas: {e}")
|
|
messagebox.showerror("Error de Lectura", f"No se pudo leer el archivo seleccionado: {e}")
|
|
|
|
|
|
def guardar_texto(root):
|
|
"""
|
|
Pide al usuario un nombre de archivo y guarda el contenido del editor
|
|
en la carpeta Proyecto/data/notas/ como un archivo .txt.
|
|
"""
|
|
if not config.editor_texto:
|
|
log_event("ERROR: Editor de texto no inicializado.")
|
|
return
|
|
|
|
try:
|
|
nombre_archivo = simpledialog.askstring(
|
|
"Guardar Archivo",
|
|
"Introduce el nombre del archivo (ej: notas)",
|
|
parent=root
|
|
)
|
|
except Exception as e:
|
|
log_event(f"ERROR al iniciar diálogo de guardado: {e}")
|
|
return
|
|
|
|
if not nombre_archivo:
|
|
log_event("Guardado cancelado por el usuario.")
|
|
return
|
|
|
|
ruta_notas = config.NOTES_FOLDER # Usar la nueva carpeta de notas
|
|
|
|
if not nombre_archivo.lower().endswith('.txt'):
|
|
nombre_archivo += '.txt'
|
|
|
|
ruta_completa = os.path.join(ruta_notas, nombre_archivo)
|
|
|
|
contenido = config.editor_texto.get("1.0", tk.END)
|
|
|
|
try:
|
|
os.makedirs(ruta_notas, exist_ok=True)
|
|
|
|
with open(ruta_completa, 'w', encoding='utf-8') as f:
|
|
f.write(contenido)
|
|
|
|
log_event(f"Archivo de notas guardado con éxito: {ruta_completa}")
|
|
messagebox.showinfo("Guardado", f"Archivo guardado como:\n{nombre_archivo}")
|
|
|
|
except PermissionError:
|
|
error_msg = f"ERROR: Permiso denegado al escribir en {ruta_completa}."
|
|
log_event(error_msg)
|
|
messagebox.showerror("Error de Permiso", error_msg)
|
|
except Exception as e:
|
|
error_msg = f"ERROR al guardar {nombre_archivo}: {e}"
|
|
log_event(error_msg)
|
|
messagebox.showerror("Error de Escritura", error_msg)
|
|
|
|
# ===============================================
|
|
# Funciones de Web Scraping Adicionales
|
|
# ===============================================
|
|
|
|
def detener_scraping():
|
|
"""Detiene la ejecución del hilo de scraping si está activo."""
|
|
if config.scraping_running:
|
|
config.scraping_running = False
|
|
if config.scraping_progress_bar:
|
|
config.scraping_progress_bar.stop()
|
|
log_event("🛑 Proceso de Web Scrapear solicitado para detenerse.")
|
|
|
|
# Opcional: Escribir un mensaje de detención en el área de salida
|
|
if config.scraping_output_text:
|
|
config.scraping_output_text.insert(tk.END, "\n--- PROCESO CANCELADO POR EL USUARIO ---\n")
|
|
|
|
else:
|
|
log_event("El proceso de Web Scrapear no está actualmente activo.")
|
|
|
|
def guardar_scraping(contenido, root):
|
|
"""
|
|
Guarda el contenido del ScrolledText de scraping en un archivo TXT
|
|
en la carpeta Proyecto/data/scraping/.
|
|
"""
|
|
if not contenido or contenido.strip() == "Resultado de la Extracción:":
|
|
messagebox.showwarning("Advertencia", "El área de resultados está vacía.")
|
|
return
|
|
|
|
# Pedir un nombre de archivo
|
|
try:
|
|
nombre_base = datetime.datetime.now().strftime("scraping_%Y%m%d_%H%M%S")
|
|
nombre_archivo = simpledialog.askstring(
|
|
"Guardar Resultado",
|
|
f"Introduce el nombre del archivo (predeterminado: {nombre_base})",
|
|
initialvalue=nombre_base,
|
|
parent=root
|
|
)
|
|
except Exception as e:
|
|
log_event(f"ERROR al iniciar diálogo de guardado de scraping: {e}")
|
|
return
|
|
|
|
if not nombre_archivo:
|
|
log_event("Guardado de scraping cancelado por el usuario.")
|
|
return
|
|
|
|
# Rutas
|
|
ruta_scrapping = config.SCRAPING_FOLDER
|
|
|
|
if not nombre_archivo.lower().endswith('.txt'):
|
|
nombre_archivo += '.txt'
|
|
|
|
ruta_completa = os.path.join(ruta_scrapping, nombre_archivo)
|
|
|
|
try:
|
|
# Asegurar que la carpeta exista
|
|
os.makedirs(ruta_scrapping, exist_ok=True)
|
|
|
|
with open(ruta_completa, 'w', encoding='utf-8') as f:
|
|
f.write(contenido)
|
|
|
|
log_event(f"Resultado de scraping guardado con éxito: {ruta_completa}")
|
|
messagebox.showinfo("Guardado", f"Resultado de scraping guardado como:\n{nombre_archivo}")
|
|
|
|
except Exception as e:
|
|
error_msg = f"ERROR al guardar el resultado de scraping: {e}"
|
|
log_event(error_msg)
|
|
messagebox.showerror("Error de Escritura", error_msg)
|
|
|
|
def abrir_archivo_scraping_config(root):
|
|
"""
|
|
Abre un diálogo para seleccionar un archivo JSON de configuración de scraping
|
|
y lo carga en config.scraping_config_data.
|
|
"""
|
|
try:
|
|
# 1. Asegurar que la carpeta exista
|
|
os.makedirs(config.SCRAPING_CONFIG_FOLDER, exist_ok=True)
|
|
|
|
# 2. Abrir diálogo de selección
|
|
archivo_seleccionado = filedialog.askopenfilename(
|
|
initialdir=config.SCRAPING_CONFIG_FOLDER,
|
|
title="Cargar Configuración de Scraping (JSON)",
|
|
filetypes=(("Archivos JSON", "*.json"), ("Todos los archivos", "*.*")),
|
|
parent=root
|
|
)
|
|
|
|
if not archivo_seleccionado:
|
|
log_event("Carga de configuración de scraping cancelada.")
|
|
return
|
|
|
|
# 3. Cargar el JSON
|
|
with open(archivo_seleccionado, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
|
|
# 4. Validar estructura mínima (opcional, pero buena práctica)
|
|
required_keys = ['type', 'selector']
|
|
if not all(key in data for key in required_keys):
|
|
messagebox.showerror("Error de Configuración", f"El archivo JSON debe contener al menos las claves: {', '.join(required_keys)}.")
|
|
log_event("ERROR: Configuración JSON incompleta.")
|
|
return
|
|
|
|
# 5. Guardar en la configuración global
|
|
config.scraping_config_data = data
|
|
|
|
# 6. Actualizar campos de la UI
|
|
file_name = os.path.basename(archivo_seleccionado)
|
|
|
|
# Actualizar URL de la interfaz si el JSON tiene 'url'
|
|
if 'url' in data and config.scraping_url_input:
|
|
config.scraping_url_input.set(data['url'])
|
|
|
|
# Actualizar Label del archivo cargado
|
|
if config.scraping_config_file_label:
|
|
config.scraping_config_file_label.config(text=f"Config: [{file_name}]")
|
|
|
|
log_event(f"Configuración de scraping '{file_name}' cargada con éxito.")
|
|
messagebox.showinfo("Éxito", f"Configuración de '{file_name}' cargada. Presiona 'Iniciar Scrapear'.")
|
|
|
|
except FileNotFoundError:
|
|
log_event("ERROR: Archivo de configuración no encontrado.")
|
|
messagebox.showerror("Error", "Archivo no encontrado.")
|
|
except json.JSONDecodeError:
|
|
log_event("ERROR: El archivo no es un JSON válido.")
|
|
messagebox.showerror("Error", "El archivo cargado no es un formato JSON válido.")
|
|
except Exception as e:
|
|
log_event(f"ERROR al cargar la configuración de scraping: {e}")
|
|
messagebox.showerror("Error", f"Fallo al cargar la configuración: {e}")
|
|
|
|
# ===============================================
|
|
# Funcionalidad de Juegos (NUEVO)
|
|
# ===============================================
|
|
|
|
def simular_juego_camellos(root):
|
|
"""
|
|
Simula una carrera de camellos con actualizaciones Thread-Safe,
|
|
mostrando la simulación en una nueva ventana Toplevel.
|
|
"""
|
|
|
|
# 1. Chequeo de juego activo
|
|
if hasattr(config, 'juego_window') and config.juego_window and config.juego_window.winfo_exists():
|
|
messagebox.showwarning("Advertencia", "Ya hay un juego activo. Ciérralo para iniciar uno nuevo.")
|
|
return
|
|
|
|
# 2. Inicializar la ventana de juego
|
|
juego_window = tk.Toplevel(root)
|
|
juego_window.title("Carrera de Camellos 🐫 (Thread-Safe)")
|
|
juego_window.geometry("550x300")
|
|
juego_window.resizable(False, False)
|
|
config.juego_window = juego_window
|
|
config.juego_running = True
|
|
|
|
# Variables de estado del juego
|
|
posiciones = {'Camello A': 0, 'Camello B': 0, 'Camello C': 0}
|
|
meta = 35
|
|
|
|
# 3. Widgets de la UI
|
|
tk.Label(juego_window, text="¡Iniciando Carrera!", font=('Helvetica', 14, 'bold')).pack(pady=10)
|
|
|
|
track_frame = tk.Frame(juego_window)
|
|
track_frame.pack(padx=20, pady=10, fill='x')
|
|
|
|
# Labels para la pista
|
|
labels = {}
|
|
for i, camello in enumerate(posiciones.keys()):
|
|
tk.Label(track_frame, text=f"{camello}:", font=('Helvetica', 10, 'bold')).grid(row=i, column=0, sticky='w', padx=5, pady=5)
|
|
# Usamos un font monoespaciado para que la barra se vea bien
|
|
labels[camello] = tk.Label(track_frame, text="🐫", anchor='w', width=45, font=('Courier', 10), bg='lightgray', relief='sunken')
|
|
labels[camello].grid(row=i, column=1, sticky='ew', padx=5, pady=5)
|
|
|
|
resultado_label = tk.Label(juego_window, text="En curso...", font=('Helvetica', 12))
|
|
resultado_label.pack(pady=10)
|
|
|
|
def cerrar_juego():
|
|
"""Función que maneja el cierre de la ventana del juego."""
|
|
config.juego_running = False
|
|
juego_window.destroy()
|
|
log_event("Juego de camellos cerrado.")
|
|
|
|
juego_window.protocol("WM_DELETE_WINDOW", cerrar_juego)
|
|
|
|
def avanzar_carrera():
|
|
"""Lógica de avance de la carrera (simulación)."""
|
|
if not config.juego_running or not juego_window.winfo_exists():
|
|
return
|
|
|
|
ganador = None
|
|
|
|
for camello in posiciones.keys():
|
|
if posiciones[camello] < meta:
|
|
# Avance aleatorio (1-3 pasos)
|
|
avance = random.randint(1, 3)
|
|
posiciones[camello] += avance
|
|
|
|
# Actualizar la representación visual (Thread-Safe)
|
|
bar = " " * min(posiciones[camello], meta)
|
|
|
|
# Para la visualización, el camello siempre está al final de la barra
|
|
labels[camello].config(text=bar + "🐫" + " " * (meta - posiciones[camello]) + " | META")
|
|
|
|
if posiciones[camello] >= meta:
|
|
ganador = camello
|
|
break
|
|
|
|
if ganador:
|
|
resultado_label.config(text=f"¡{ganador} ha ganado la carrera!", fg='green')
|
|
config.juego_running = False
|
|
log_event(f"Carrera de camellos finalizada. Ganador: {ganador}")
|
|
|
|
# Botón para cerrar
|
|
ttk.Button(juego_window, text="Cerrar Juego", command=cerrar_juego).pack(pady=10)
|
|
|
|
elif config.juego_running:
|
|
# Re-programar el siguiente paso (Thread-Safe)
|
|
juego_window.after(300, avanzar_carrera)
|
|
|
|
log_event("Simulación de Carrera de Camellos iniciada.")
|
|
juego_window.after(100, avanzar_carrera) # Iniciar la simulación |