mirror of
https://github.com/cheveguerra/TotalConnect.git
synced 2026-08-19 00:46:37 +00:00
2562 lines
109 KiB
Python
Executable File
2562 lines
109 KiB
Python
Executable File
# -*- coding: utf-8 -*-
|
|
"""
|
|
================================================================================
|
|
INSTRUCCIONES DE COMPILACIÓN Y USO (PORTABILIDAD Y ARQUITECTURA)
|
|
================================================================================
|
|
|
|
Este script puede ser empaquetado en un único archivo ejecutable binario (.exe en Windows
|
|
o binario nativo en Linux) que se puede ejecutar en cualquier máquina de su misma
|
|
arquitectura sin necesidad de instalar Python o dependencias adicionales.
|
|
|
|
--------------------------------------------------------------------------------
|
|
1. REQUISITOS PREVIOS (Instalación de Python y PyInstaller)
|
|
--------------------------------------------------------------------------------
|
|
Si la máquina de desarrollo NO tiene instalado Python o PyInstaller:
|
|
|
|
A) Instalar Python:
|
|
- Windows: Descargue el instalador desde https://www.python.org/
|
|
Asegúrese de marcar la casilla "Add Python.exe to PATH" durante la instalación.
|
|
- Linux (Debian/Ubuntu): Viene preinstalado. Si no, instálelo usando:
|
|
sudo apt update && sudo apt install python3 python3-pip python3-venv
|
|
|
|
B) Instalar Dependencias del Script:
|
|
Abra la terminal (cmd/PowerShell en Windows, o bash en Linux) y ejecute:
|
|
pip install total-connect-client pyinstaller tzdata
|
|
|
|
--------------------------------------------------------------------------------
|
|
2. COMANDOS DE COMPILACIÓN
|
|
--------------------------------------------------------------------------------
|
|
Ejecute el comando correspondiente según el sistema operativo en la carpeta del script:
|
|
|
|
A) En Windows:
|
|
- Para generar un ejecutable estándar con ventana negra de consola:
|
|
python -m PyInstaller --onefile --name totalconnect index.py
|
|
|
|
- Para generar un ejecutable silencioso en segundo plano (sin ventana negra):
|
|
python -m PyInstaller --onefile --noconsole --name totalconnect index.py
|
|
|
|
B) En Linux:
|
|
- Para generar el ejecutable binario nativo de Linux:
|
|
python3 -m PyInstaller --onefile --name totalconnect index.py
|
|
|
|
--------------------------------------------------------------------------------
|
|
3. INSTRUCCIONES DE USO DEL EJECUTABLE
|
|
--------------------------------------------------------------------------------
|
|
Una vez compilado, busque el ejecutable generado dentro de la carpeta "dist/".
|
|
|
|
- Mueva o copie el ejecutable a la carpeta que desee.
|
|
- Copie el archivo 'credentials.json' a esa misma carpeta (al lado del ejecutable).
|
|
- Estatus del Servidor Web (Inicia Servidor Web en http://localhost:PORT/):
|
|
- Windows: Doble clic en 'totalconnect.exe' (se iniciará como servidor y abrirá el navegador de inmediato).
|
|
- Linux: ./totalconnect
|
|
- El arranque es INSTANTÁNEO. Si no hay reporte previo ('status.html'), genera un placeholder limpio indicando que debe hacer clic en "Actualizar" para su primera carga.
|
|
- Parámetros CLI de Navegador: Puede usar '--no-browser' o '-nb' para evitar que se abra la ventana del navegador.
|
|
- Ejecución Única (On-Demand / Guarda HTML y sale):
|
|
- Ejecutar agregando el argumento '--once' o '-o' (ej: totalconnect.exe --once o ./totalconnect -o)
|
|
- Sirve para programar el script mediante el Programador de Tareas en Windows o el demonio Cron en Linux.
|
|
|
|
--------------------------------------------------------------------------------
|
|
4. FORMATOS SOPORTADOS PARA 'credentials.json'
|
|
--------------------------------------------------------------------------------
|
|
El archivo 'credentials.json' debe colocarse junto al ejecutable y soporta dos formatos:
|
|
|
|
Formato A (Formato por objeto con puerto, exclusiones y navegador - RECOMENDADO):
|
|
{
|
|
"port": 8080,
|
|
"exclude": ["1071", "1171", "1215"], // Lista de sucursales a ignorar (ID, número o nombre)
|
|
"open_browser": true, // true para abrir navegador al iniciar (defecto en GUI)
|
|
"accounts": [
|
|
{
|
|
"label": "2020",
|
|
"username": "TuUsuario2020",
|
|
"password": "TuPassword2020"
|
|
},
|
|
{
|
|
"label": "2019",
|
|
"username": "TuUsuario2019",
|
|
"password": "TuPassword2019"
|
|
}
|
|
]
|
|
}
|
|
|
|
Formato B (Formato original de lista - Puerto por defecto 8080):
|
|
[
|
|
{
|
|
"label": "2020",
|
|
"username": "TuUsuario2020",
|
|
"password": "TuPassword2020"
|
|
},
|
|
{
|
|
"label": "2019",
|
|
"username": "TuUsuario2019",
|
|
"password": "TuPassword2019"
|
|
}
|
|
]
|
|
|
|
--------------------------------------------------------------------------------
|
|
5. MOTOR DE POLLING HÍBRIDO Y AUDITORÍA EN VIVO (Mantenimiento Inteligente)
|
|
--------------------------------------------------------------------------------
|
|
Este script implementa un motor de polling de alto rendimiento optimizado contra rate-limits:
|
|
|
|
A) Motor Dinámico Híbrido (Lote 27 + 7 por ciclo de 30s):
|
|
- Cada 30 segundos, consulta a 27 sucursales del turno ordinario (ordenadas de forma
|
|
persistente por antigüedad de verificación) + hasta 7 de alta frecuencia (paneles
|
|
desarmados, en alarma o con fallas físicas).
|
|
- Realiza un Poll Lite SOAP (GetLiveEvents) rápido si la antigüedad de datos es menor
|
|
a 10 minutos. Si detecta actividad física, programa un Poll Full REST (fullStatus)
|
|
para el siguiente ciclo de 30s.
|
|
- Realiza un Poll Full REST de mantenimiento automático si la antigüedad supera los
|
|
10 minutos, garantizando coherencia de datos con Honeywell.
|
|
- Limitador integrado de velocidad de máximo 35 Polls Full por ciclo para prevenir
|
|
bloqueos de cuenta en arranques en frío.
|
|
|
|
B) Visor de Logs y Autogestión de Almacenamiento:
|
|
- Se incluye un botón interactivo "Ver Log" en la cabecera del dashboard para abrir
|
|
una terminal oscura en el navegador con las últimas 150 líneas del log de actividad.
|
|
- Rotación automática de 'activity.log' en caliente al alcanzar los 5 MB de tamaño,
|
|
preservando un respaldo anterior ('activity.log.1').
|
|
- En producción, es recomendable iniciar el daemon silenciosamente en Linux:
|
|
nohup ./venv/bin/python3 index.py > /dev/null 2> server.log &
|
|
Esto descarta la consola redundante (que ya va a activity.log) y mantiene
|
|
'server.log' ocupado únicamente con tracebacks de error reales del sistema.
|
|
|
|
C) Reinicio de Fábrica (Hard Reset) por Clic Largo (3 segundos):
|
|
- Al mantener presionado el botón "Actualizar" por 3 segundos completos, se ejecuta
|
|
un Hard Reset (limpieza de cachés de disco, purga de memoria de sesiones de clientes
|
|
y reinicio de escaneo de cero).
|
|
- Ergonomía del botón:
|
|
* Clic Rápido (<300ms): Ejecuta un refresco clásico en caliente de la UI.
|
|
* Clic Medio / Cancelado (300ms a 3000ms): Cancela de forma silenciosa ("aquí no pasó nada").
|
|
* Retraso Visual de 1s: El botón no se pinta en rojo ni cambia su texto hasta cruzar
|
|
el primer segundo (1000ms) de presión continua, evitando destellos molestos en
|
|
clics habituales.
|
|
================================================================================
|
|
"""
|
|
import os
|
|
import sys
|
|
import json
|
|
import time
|
|
import datetime
|
|
import threading
|
|
import http.server
|
|
import socketserver
|
|
import webbrowser
|
|
import platform
|
|
import logging
|
|
from socketserver import ThreadingMixIn
|
|
from zoneinfo import ZoneInfo
|
|
import requests
|
|
import xml.etree.ElementTree as ET
|
|
from total_connect_client.client import TotalConnectClient
|
|
from total_connect_client.const import ArmingState, make_http_endpoint
|
|
|
|
# Silenciar advertencias internas irrelevantes de la librería TotalConnect Client
|
|
logging.getLogger("total_connect_client").setLevel(logging.ERROR)
|
|
|
|
# Configurar codificación UTF-8 para la consola (evita UnicodeEncodeError con emojis en Windows)
|
|
if sys.stdout is not None:
|
|
try:
|
|
sys.stdout.reconfigure(encoding='utf-8')
|
|
except Exception:
|
|
pass
|
|
if sys.stderr is not None:
|
|
try:
|
|
sys.stderr.reconfigure(encoding='utf-8')
|
|
except Exception:
|
|
pass
|
|
|
|
# Directorio del script (soporta empaquetado con PyInstaller)
|
|
if getattr(sys, 'frozen', False):
|
|
BASE_DIR = os.path.dirname(sys.executable)
|
|
else:
|
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
CREDENTIALS_FILE = os.path.join(BASE_DIR, 'credentials.json')
|
|
HTML_OUTPUT_FILE = os.path.join(BASE_DIR, 'status.html')
|
|
CACHE_FILE = os.path.join(BASE_DIR, 'locations_cache.json')
|
|
LAST_STATE_FILE = os.path.join(BASE_DIR, 'last_state.json')
|
|
LAST_EVENT_IDS_FILE = os.path.join(BASE_DIR, 'last_event_ids.json')
|
|
LOG_FILE = os.path.join(BASE_DIR, 'activity.log')
|
|
APP_VERSION = "06.07.26"
|
|
polling_trigger_event = threading.Event()
|
|
soap_disabled_accounts = set()
|
|
soap_session_ids = {} # dict: username -> GUID SessionId para SOAP GetLiveEvents
|
|
|
|
def load_last_event_ids():
|
|
"""Carga los últimos IDs de eventos SOAP guardados en disco."""
|
|
if os.path.exists(LAST_EVENT_IDS_FILE):
|
|
try:
|
|
with open(LAST_EVENT_IDS_FILE, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
if isinstance(data, dict):
|
|
return data
|
|
except Exception as e:
|
|
log_msg(f"⚠️ No se pudieron cargar los IDs de eventos: {e}")
|
|
return {}
|
|
|
|
def save_last_event_ids(event_ids):
|
|
"""Guarda los últimos IDs de eventos SOAP en disco."""
|
|
try:
|
|
with open(LAST_EVENT_IDS_FILE, 'w', encoding='utf-8') as f:
|
|
json.dump(event_ids, f, ensure_ascii=False, indent=2)
|
|
except Exception as e:
|
|
log_msg(f"⚠️ No se pudieron guardar los IDs de eventos: {e}")
|
|
|
|
def load_polling_state():
|
|
"""Carga el estado del polling (activo/pausado) exclusivamente desde credentials.json."""
|
|
if os.path.exists(CREDENTIALS_FILE):
|
|
try:
|
|
with open(CREDENTIALS_FILE, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
if isinstance(data, dict):
|
|
return data.get("polling_enabled", True)
|
|
except Exception:
|
|
pass
|
|
return True
|
|
|
|
def save_polling_state(enabled):
|
|
"""Guarda el estado del polling (activo/pausado) exclusivamente en credentials.json."""
|
|
target_files = [CREDENTIALS_FILE]
|
|
if platform.system() == "Windows":
|
|
target_files += [
|
|
r"Z:\data2\Software\Bot-WhatsApp\TotalConnect\credentials.json",
|
|
r"E:\Software\TotalConnect\credentials.json",
|
|
r"E:\Software\TotalConnect\dist\credentials.json"
|
|
]
|
|
|
|
for target_path in set(target_files):
|
|
if os.path.exists(target_path):
|
|
try:
|
|
with open(target_path, 'r', encoding='utf-8') as f:
|
|
creds_data = json.load(f)
|
|
if isinstance(creds_data, dict):
|
|
creds_data["polling_enabled"] = enabled
|
|
with open(target_path, 'w', encoding='utf-8') as f:
|
|
json.dump(creds_data, f, ensure_ascii=False, indent=2)
|
|
log_msg(f"💾 Estado de polling ({enabled}) guardado en: {target_path}")
|
|
except Exception as e:
|
|
log_msg(f"⚠️ No se pudo actualizar polling_enabled en {target_path}: {e}")
|
|
|
|
polling_enabled = load_polling_state()
|
|
|
|
def log_msg(msg):
|
|
"""Escribe un mensaje de log con timestamp tanto en la consola (con flush) como en activity.log."""
|
|
try:
|
|
now_str = datetime.datetime.now(ZoneInfo("America/Mexico_City")).strftime('%Y-%m-%d %I:%M:%S %p')
|
|
formatted = f"[{now_str}] {msg}"
|
|
except Exception:
|
|
formatted = f"[LOG] {msg}"
|
|
print(formatted, flush=True)
|
|
|
|
# Rotar activity.log si supera los 5 MB
|
|
try:
|
|
if os.path.exists(LOG_FILE) and os.path.getsize(LOG_FILE) > 5 * 1024 * 1024:
|
|
backup_file = LOG_FILE + ".1"
|
|
if os.path.exists(backup_file):
|
|
os.remove(backup_file)
|
|
os.rename(LOG_FILE, backup_file)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
with open(LOG_FILE, 'a', encoding='utf-8') as f:
|
|
f.write(formatted + '\n')
|
|
except Exception:
|
|
pass
|
|
|
|
update_lock = threading.Lock()
|
|
current_loading_status = ""
|
|
polling_lock = threading.Lock()
|
|
last_event_ids = load_last_event_ids()
|
|
consecutive_errors = {}
|
|
last_poll_timestamp = "Nunca"
|
|
client_pool = {}
|
|
client_pool_lock = threading.Lock()
|
|
|
|
|
|
|
|
README_CONTENT = """# TotalConnect 2.0 - Panel de Monitoreo de Alarmería
|
|
|
|
Este proyecto proporciona un panel web centralizado, responsivo y de alto rendimiento para el monitoreo consolidado de múltiples cuentas de alarmas de TotalConnect 2.0 (Resideo/Honeywell). Está diseñado para ejecutarse nativamente tanto en Linux como en Windows y generar reportes visuales dinámicos.
|
|
|
|
---
|
|
|
|
## 🛠️ Requerimientos del Sistema y Servicios
|
|
|
|
### 1. Sistema Operativo y Servidor
|
|
* **Linux (Debian/Ubuntu/Proxmox VM o similar):** Probado y validado en Debian/Ubuntu con Python 3.11.
|
|
* **Windows (7/10/11 o Windows Server):** Ejecutable mediante el binario autónomo `totalconnect.exe` (compilado con PyInstaller).
|
|
* **Acceso de Red:** El host debe tener salida a Internet (para conectarse al API de Total Connect) y ser accesible en la red local (para visualizar el panel en el puerto configurado).
|
|
|
|
### 2. Servicios de Sistema Recomendados
|
|
* **En Linux:** Servicio Systemd (`totalconnect.service`) configurado para mantener el servidor web encendido de forma persistente y asegurar su auto-recuperación.
|
|
* **En Windows:** Ejecutar el binario en segundo plano o utilizar el Programador de Tareas para actualizaciones silenciosas.
|
|
|
|
---
|
|
|
|
## 📦 Dependencias de Python
|
|
|
|
Si ejecutas el script directamente desde el código fuente (`index.py`), se requiere:
|
|
1. **`total-connect-client`:** Librería principal para interactuar con la API REST y OAuth2 de Total Connect.
|
|
2. **`tzdata`:** Requerido en Windows para soportar zonas horarias locales (`America/Mexico_City`).
|
|
|
|
*Nota: Si utilizas el ejecutable compilado `totalconnect.exe`, estas dependencias ya vienen empaquetadas en su interior.*
|
|
|
|
---
|
|
|
|
## 📂 Estructura de Archivos del Proyecto
|
|
|
|
* **`totalconnect.exe` / `totalconnect`:** El binario ejecutable autónomo.
|
|
* **`index.py`:** El script principal de código fuente en Python.
|
|
* **`credentials.json`:** Archivo confidencial que contiene las credenciales y el puerto HTTP.
|
|
* **`locations_cache.json`:** Historial temporal auto-generado de nombres de sucursales para soporte sin conexión.
|
|
* **`status.html`:** Reporte final auto-generado.
|
|
* **`activity.log`:** Archivo de logs de la aplicación.
|
|
* **`activity.log.1`:** Respaldo anterior del log (se rota automáticamente al superar los 5 MB).
|
|
* **`server.log`:** Registro de errores del sistema del comando de arranque en segundo plano.
|
|
|
|
---
|
|
|
|
## 🚀 Modos de Ejecución y Guía rápida
|
|
|
|
### Modo 1: Servidor Web Continuo (Por Defecto)
|
|
Arranca el Servidor HTTP integrado. Lee el puerto del archivo `credentials.json` (por defecto `8080`).
|
|
* **En Windows:** Haz doble clic en `totalconnect.exe` o corre en consola: `totalconnect.exe`
|
|
* **En Linux:** Ejecuta `./totalconnect`
|
|
* **Acceso:** Abre tu navegador en `http://localhost:PORT/` o `http://IP_DEL_SERVIDOR:PORT/`
|
|
|
|
### Modo 2: Actualización Única (On-Demand / Tareas Programadas)
|
|
Consulta los estados una sola vez, genera `status.html` y finaliza. Ideal para automatizar con **Cron (Linux)** o **Programador de Tareas (Windows)**.
|
|
* **Comando:** Agregar el argumento `--once` o `-o` (ej: `totalconnect.exe --once` o `./totalconnect -o`).
|
|
|
|
---
|
|
|
|
## 🔑 Formato de Configuración (`credentials.json`)
|
|
|
|
El archivo `credentials.json` debe colocarse junto al ejecutable o script y soporta dos formatos:
|
|
|
|
### Formato A (Formato por objeto con puerto, exclusiones y navegador - RECOMENDADO):
|
|
```json
|
|
{{
|
|
"port": 8080,
|
|
"exclude": ["1071", "1171", "1215"], // Lista de sucursales a ignorar (ID, número o nombre)
|
|
"open_browser": true, // true para abrir navegador al iniciar (defecto en GUI)
|
|
"accounts": [
|
|
{{
|
|
"label": "2020",
|
|
"username": "TuUsuario2020",
|
|
"password": "TuPassword2020"
|
|
}},
|
|
{{
|
|
"label": "2019",
|
|
"username": "TuUsuario2019",
|
|
"password": "TuPassword2019"
|
|
}}
|
|
]
|
|
}}
|
|
```
|
|
|
|
### Formato B (Formato original de lista - Puerto por defecto 8080):
|
|
```json
|
|
[
|
|
{{
|
|
"label": "2020",
|
|
"username": "TuUsuario2020",
|
|
"password": "TuPassword2020"
|
|
}},
|
|
{{
|
|
"label": "2019",
|
|
"username": "TuUsuario2019",
|
|
"password": "TuPassword2019"
|
|
}}
|
|
]
|
|
```
|
|
|
|
---
|
|
|
|
## 🔒 Robustez y Resiliencia Implementada
|
|
|
|
* **Motor Híbrido Dinámico (27 + 7):** En cada ciclo de 30s se procesan 27 sucursales del turno ordinario (ordenadas secuencialmente por antigüedad) + hasta 7 de alta frecuencia (desarmadas/alarmadas/fallos), previniendo rate-limits.
|
|
* **Decisión Inteligente (SOAP vs REST):** Realiza un Poll Lite SOAP (GetLiveEvents) rápido si los datos de la sucursal tienen menos de 10 minutos. Si detecta actividad física, programa un Poll Full REST (fullStatus) para la siguiente vuelta.
|
|
* **Tope de Consultas REST:** Limitador integrado de máximo 35 consultas REST por ciclo para un arranque en frío seguro sin bloqueos de cuenta.
|
|
* **Visor de Logs Interactiva (150 líneas):** Botón "Ver Log" en el dashboard para auditar el servidor en vivo directamente desde el navegador.
|
|
* **Autogestión de Espacio:** Rotación automática de logs a los 5 MB de tamaño.
|
|
* **Arranque Silencioso en Producción (VM):**
|
|
`nohup ./venv/bin/python3 index.py > /dev/null 2> server.log &`
|
|
Descarta prints de consola duplicados y deja `server.log` activo únicamente para atrapar excepciones de crash.
|
|
* **Reinicio de Fábrica (Hard Reset) por Clic Largo (3s):**
|
|
Al mantener presionado el botón "Actualizar" por 3 segundos, se purga la memoria y base de datos física para resolver inconsistencias. Cuenta con retraso de 1 segundo en el cambio de color para evitar destellos y cancelación silenciosa ergonómica si te arrepientes antes de completarse los 3 segundos.
|
|
"""
|
|
|
|
def auto_generate_docs():
|
|
"""Genera automáticamente el archivo README.md y credentials.json si no existen."""
|
|
# Generar README.md si no existe
|
|
readme_path = os.path.join(BASE_DIR, 'README.md')
|
|
if not os.path.exists(readme_path):
|
|
try:
|
|
with open(readme_path, 'w', encoding='utf-8') as f:
|
|
f.write(README_CONTENT)
|
|
print("📝 Se ha creado el archivo de documentación README.md de forma automática.")
|
|
except Exception as e:
|
|
print(f"⚠️ No se pudo auto-generar README.md: {e}")
|
|
|
|
# Generar plantilla de credentials.json si no existe
|
|
if not os.path.exists(CREDENTIALS_FILE):
|
|
dummy_credentials = {
|
|
"port": 8080,
|
|
"accounts": [
|
|
{
|
|
"label": "CUENTA_EJEMPLO",
|
|
"username": "TuUsuarioTotalConnect",
|
|
"password": "TuPasswordTotalConnect"
|
|
}
|
|
]
|
|
}
|
|
try:
|
|
with open(CREDENTIALS_FILE, 'w', encoding='utf-8') as f:
|
|
json.dump(dummy_credentials, f, ensure_ascii=False, indent=2)
|
|
print(f"🔑 Se ha creado una plantilla de credenciales en: {CREDENTIALS_FILE}")
|
|
print(" Por favor edita este archivo con tus credenciales reales de TotalConnect.")
|
|
except Exception as e:
|
|
print(f"⚠️ No se pudo crear la plantilla de credenciales: {e}")
|
|
|
|
def parse_arming_state(state):
|
|
"""Mapea los estados de ArmingState de total-connect-client a etiquetas legibles."""
|
|
if isinstance(state, str) and state == "REVOKED":
|
|
return {
|
|
"label": "Token Revocado / Error",
|
|
"class": "state-revoked",
|
|
"badge": "Desconectado"
|
|
}
|
|
|
|
if isinstance(state, int):
|
|
try:
|
|
state = ArmingState(state)
|
|
except Exception:
|
|
state = ArmingState.UNKNOWN
|
|
|
|
if hasattr(state, 'is_disarmed') and state.is_disarmed():
|
|
if state == ArmingState.DISARMED_ZONE_FAULTED:
|
|
return {
|
|
"label": "Desarmado (Zona Abierta / Fallo)",
|
|
"class": "state-fault",
|
|
"badge": "Revisar"
|
|
}
|
|
return {
|
|
"label": "Desarmado",
|
|
"class": "state-disarmed",
|
|
"badge": "Listo"
|
|
}
|
|
elif hasattr(state, 'is_armed_away') and state.is_armed_away():
|
|
return {
|
|
"label": "Armado Away",
|
|
"class": "state-armed-away",
|
|
"badge": "Armado (Away)"
|
|
}
|
|
elif hasattr(state, 'is_armed_home') and (state.is_armed_home() or state.is_armed_night() or state.is_armed_custom_bypass()):
|
|
return {
|
|
"label": "Armado Stay",
|
|
"class": "state-armed-stay",
|
|
"badge": "Armado (Stay)"
|
|
}
|
|
elif hasattr(state, 'is_triggered') and state.is_triggered():
|
|
return {
|
|
"label": "¡ALARMA ACTIVA!",
|
|
"class": "state-alarm",
|
|
"badge": "¡ALERTA!"
|
|
}
|
|
elif hasattr(state, 'is_pending') and state.is_pending():
|
|
return {
|
|
"label": "Procesando Cambio...",
|
|
"class": "state-pending",
|
|
"badge": "Pendiente"
|
|
}
|
|
else:
|
|
state_name = getattr(state, 'name', str(state))
|
|
return {
|
|
"label": f"Desconocido ({state_name})",
|
|
"class": "state-unknown",
|
|
"badge": "Desconocido"
|
|
}
|
|
|
|
def format_trigger_time(raw_time_str):
|
|
"""Formatea la hora de disparo de alarma. Si contiene 'Z', convierte de UTC a CDMX. Si no, formatea la hora local existente."""
|
|
if not raw_time_str:
|
|
return ""
|
|
try:
|
|
s = str(raw_time_str).strip()
|
|
if 'T' in s:
|
|
if s.endswith('Z'):
|
|
clean_str = s.rstrip('Z')
|
|
dt = datetime.datetime.fromisoformat(clean_str).replace(tzinfo=datetime.timezone.utc)
|
|
dt_cdmx = dt.astimezone(ZoneInfo("America/Mexico_City"))
|
|
return f" @ {dt_cdmx.strftime('%I:%M:%S %p')}"
|
|
else:
|
|
dt = datetime.datetime.fromisoformat(s)
|
|
return f" @ {dt.strftime('%I:%M:%S %p')}"
|
|
return f" @ {s}"
|
|
except Exception:
|
|
try:
|
|
return f" @ {str(raw_time_str).split('T')[1].rstrip('Z')}"
|
|
except Exception:
|
|
return f" @ {raw_time_str}"
|
|
|
|
|
|
def get_account_badge_style(label):
|
|
"""Devuelve un estilo distintivo para el badge de cada cuenta (solo el cuadrito)."""
|
|
clean = str(label).strip().lower()
|
|
|
|
# Paleta específica para cuentas conocidas
|
|
if "2020" in clean:
|
|
# Azul Real / Índigo Elegante
|
|
return 'style="background: #eff6ff; color: #1d4ed8; border: 1px solid #bfdbfe;"'
|
|
elif "2019" in clean:
|
|
# Morado / Violeta Elegante
|
|
return 'style="background: #faf5ff; color: #7e22ce; border: 1px solid #e9d5ff;"'
|
|
else:
|
|
# Paleta dinámica para cualquier otra cuenta (hash de color)
|
|
PALETTES = [
|
|
'style="background: #f0fdf4; color: #15803d; border: 1px solid #bbf7d0;"', # Verde
|
|
'style="background: #fff7ed; color: #c2410c; border: 1px solid #fed7aa;"', # Naranja
|
|
'style="background: #ecfeff; color: #0e7490; border: 1px solid #a5f3fc;"', # Cían
|
|
'style="background: #fdf2f8; color: #be185d; border: 1px solid #fbcfe8;"', # Rosa
|
|
]
|
|
idx = sum(ord(c) for c in clean) % len(PALETTES)
|
|
return PALETTES[idx]
|
|
|
|
|
|
def _get_location_sort_priority(loc_item):
|
|
"""Calcula la prioridad de ordenamiento de una sucursal según su estado de armado."""
|
|
state = loc_item['status']
|
|
if isinstance(state, str) and state == "REVOKED":
|
|
return 3
|
|
if isinstance(state, int):
|
|
try:
|
|
state = ArmingState(state)
|
|
except Exception:
|
|
state = ArmingState.UNKNOWN
|
|
if hasattr(state, 'is_triggered') and state.is_triggered():
|
|
return 0
|
|
if hasattr(state, 'is_disarmed') and state.is_disarmed():
|
|
return 1
|
|
return 2
|
|
|
|
|
|
def generate_html_report(data):
|
|
"""Genera un archivo HTML premium, limpio, claro y compacto con el estatus consolidado."""
|
|
now = datetime.datetime.now(ZoneInfo("America/Mexico_City"))
|
|
dias = ["lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"]
|
|
meses = ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"]
|
|
|
|
timestamp = f"{dias[now.weekday()]}, {now.day} de {meses[now.month - 1]} de {now.year} - {now.strftime('%I:%M:%S %p')}"
|
|
|
|
# Generar checkboxes dinámicos para las cuentas de credentials.json
|
|
checkboxes_html = ""
|
|
accounts_cfg, _, _, _ = load_credentials_config()
|
|
if accounts_cfg:
|
|
for acc in accounts_cfg:
|
|
label = acc.get("label", "")
|
|
checkboxes_html += f"""
|
|
<label style="display: flex; align-items: center; gap: 4px; font-size: 0.72rem; font-weight: 600; color: #334155; cursor: pointer; user-select: none;">
|
|
<input type="checkbox" name="account-select" value="{label}" checked style="cursor: pointer; accent-color: #2563eb;">
|
|
{label}
|
|
</label>"""
|
|
|
|
# Consolidar todas las sucursales en una sola lista plana
|
|
all_locations = []
|
|
for account in data:
|
|
for loc in account['locations']:
|
|
all_locations.append({
|
|
"account_label": account['label'],
|
|
"id": loc['id'],
|
|
"name": loc['name'],
|
|
"status": loc['status'],
|
|
"triggered_partitions": loc.get('triggered_partitions', []),
|
|
"active_zones": loc.get('active_zones', []),
|
|
"last_download_time": loc.get('last_download_time'),
|
|
"last_update_time": loc.get('last_update_time')
|
|
})
|
|
|
|
# Ordenar prioritariamente:
|
|
# 0: Alarmas activas (¡ALARMA ACTIVA!)
|
|
# 1: Desarmadas (sucursales abiertas)
|
|
# 2: Armadas (Stay, Away, etc.)
|
|
# 3: Revocadas/Desconectadas (con error de token / caché)
|
|
# Dentro de cada grupo se ordena de forma secuencial por número/nombre.
|
|
all_locations.sort(key=lambda x: (_get_location_sort_priority(x), x['name']))
|
|
|
|
cards_html = ""
|
|
if not all_locations:
|
|
cards_html = """
|
|
<div style="grid-column: 1 / -1; text-align: center; padding: 48px 16px; background: white; border-radius: 12px; border: 1px solid var(--border-color); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05); margin-top: 10px;">
|
|
<div style="font-size: 3rem; margin-bottom: 16px;">⏳</div>
|
|
<h3 style="font-size: 1.1rem; font-weight: 700; color: #1e293b; margin-bottom: 8px;">Sin datos de sucursales</h3>
|
|
<p style="font-size: 0.85rem; color: #64748b; max-width: 400px; margin: 0 auto 20px;">
|
|
Haz clic en el botón <strong>"Actualizar"</strong> arriba para conectar a los paneles de alarma por primera vez.
|
|
</p>
|
|
</div>
|
|
"""
|
|
else:
|
|
for loc in all_locations:
|
|
state = parse_arming_state(loc['status'])
|
|
|
|
# Generar detalles adicionales si la sucursal está alarmada o tiene zonas abiertas/fallos
|
|
details_html = ""
|
|
|
|
if loc["triggered_partitions"]:
|
|
parts_str = ", ".join(loc["triggered_partitions"])
|
|
details_html += f"""
|
|
<div class="card-alert-banner">
|
|
🚨 Alarma en: <strong>{parts_str}</strong>
|
|
</div>"""
|
|
|
|
if loc["active_zones"]:
|
|
zones_list_html = ""
|
|
for z in loc["active_zones"]:
|
|
badge_cls = "zone-alarm" if z["status"] == "Alarma" else "zone-open"
|
|
|
|
# Formatear la hora de activación si existe y es alarma
|
|
time_str = ""
|
|
if z["status"] == "Alarma" and z.get("trigger_time"):
|
|
time_str = format_trigger_time(z["trigger_time"])
|
|
|
|
zones_list_html += f"""
|
|
<div class="zone-row">
|
|
<span class="zone-badge {badge_cls}">{z["status"]}</span>
|
|
<span class="zone-desc" title="{z["name"]} (Partición {z["partition"]})">
|
|
{z["name"]} (Z{z["id"]}){time_str}
|
|
</span>
|
|
</div>"""
|
|
|
|
details_html += f"""
|
|
<div class="card-details-box">
|
|
<div class="details-title">Zonas activas / fallos:</div>
|
|
<div class="zones-container">
|
|
{zones_list_html}
|
|
</div>
|
|
</div>"""
|
|
|
|
card_class = "branch-card"
|
|
if isinstance(loc['status'], str) and loc['status'] == "REVOKED":
|
|
card_class += " branch-card-disabled"
|
|
|
|
dl_time = loc.get('last_download_time') or 'Nunca'
|
|
up_time = loc.get('last_update_time') or 'Nunca'
|
|
footer_html = f"""
|
|
<div class="branch-footer-time" style="display: flex; justify-content: space-between; align-items: center; border-top: 1px solid #f1f5f9; padding-top: 8px; margin-top: 8px; font-size: 0.62rem; color: #94a3b8; font-weight: 500;">
|
|
<span title="Última consulta profunda a Honeywell">⬇️ Verif: {dl_time}</span>
|
|
<span title="Último evento reportado por SOAP">🔄 Evento: {up_time}</span>
|
|
</div>
|
|
"""
|
|
|
|
cards_html += f"""
|
|
<div class="{card_class}">
|
|
<div class="branch-header">
|
|
<span class="status-badge {state['class']}">
|
|
<span class="status-dot"></span>
|
|
{state['label']}
|
|
</span>
|
|
<span class="account-badge">{loc['account_label']}</span>
|
|
</div>
|
|
<div class="branch-name" title="{loc['name']}">🏢 {loc['name']}</div>
|
|
{details_html}
|
|
{footer_html}
|
|
</div>"""
|
|
|
|
html_content = f"""<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Estatus de Sucursales - TotalConnect</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {{
|
|
--bg-color: #f1f5f9;
|
|
--panel-bg: #ffffff;
|
|
--border-color: #e2e8f0;
|
|
--text-main: #0f172a;
|
|
--text-muted: #64748b;
|
|
}}
|
|
|
|
body {{
|
|
margin: 0;
|
|
padding: 0;
|
|
font-family: 'Outfit', sans-serif;
|
|
background: var(--bg-color);
|
|
color: var(--text-main);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: flex-start;
|
|
}}
|
|
|
|
.container {{
|
|
width: 95%;
|
|
max-width: 1400px;
|
|
margin: 30px auto;
|
|
background: var(--panel-bg);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 16px;
|
|
padding: 24px;
|
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
|
}}
|
|
|
|
header {{
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
border-bottom: 1px solid var(--border-color);
|
|
padding-bottom: 16px;
|
|
margin-bottom: 20px;
|
|
}}
|
|
|
|
h1 {{
|
|
margin: 0;
|
|
font-size: 1.8rem;
|
|
font-weight: 800;
|
|
color: var(--text-main);
|
|
letter-spacing: -0.5px;
|
|
}}
|
|
|
|
.header-subtitle {{
|
|
font-size: 0.85rem;
|
|
color: var(--text-muted);
|
|
margin-top: 2px;
|
|
}}
|
|
|
|
.timestamp {{
|
|
text-align: right;
|
|
}}
|
|
|
|
.timestamp-label {{
|
|
font-size: 0.7rem;
|
|
color: var(--text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
margin-bottom: 2px;
|
|
}}
|
|
|
|
.timestamp-value {{
|
|
font-size: 0.88rem;
|
|
font-weight: 600;
|
|
color: #2563eb;
|
|
}}
|
|
|
|
/* Grid Layout para Tarjetas */
|
|
.grid-container {{
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
|
|
gap: 12px;
|
|
margin-top: 10px;
|
|
}}
|
|
|
|
.branch-card {{
|
|
background: #ffffff;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 10px;
|
|
padding: 12px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
transition: all 0.15s ease-in-out;
|
|
}}
|
|
|
|
.branch-card:hover {{
|
|
border-color: #cbd5e1;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
|
transform: translateY(-1px);
|
|
}}
|
|
|
|
.branch-card-disabled {{
|
|
background: #e2e8f0 !important;
|
|
opacity: 0.65;
|
|
cursor: not-allowed;
|
|
}}
|
|
|
|
.branch-card-disabled:hover {{
|
|
border-color: #e2e8f0 !important;
|
|
box-shadow: none !important;
|
|
transform: none !important;
|
|
}}
|
|
|
|
.branch-header {{
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}}
|
|
|
|
.branch-name {{
|
|
font-size: 0.82rem;
|
|
font-weight: 600;
|
|
line-height: 1.3;
|
|
color: #1e293b;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}}
|
|
|
|
/* Alertas de Zonas/Particiones */
|
|
.card-alert-banner {{
|
|
background: #fef2f2;
|
|
color: #b91c1c;
|
|
border: 1px solid #fee2e2;
|
|
border-radius: 6px;
|
|
padding: 5px 8px;
|
|
font-size: 0.72rem;
|
|
font-weight: 600;
|
|
margin-top: 2px;
|
|
}}
|
|
|
|
.card-details-box {{
|
|
background: #f8fafc;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 6px;
|
|
padding: 8px;
|
|
margin-top: 2px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}}
|
|
|
|
.details-title {{
|
|
font-size: 0.65rem;
|
|
font-weight: 700;
|
|
color: #64748b;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.3px;
|
|
margin-bottom: 2px;
|
|
}}
|
|
|
|
.zones-container {{
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}}
|
|
|
|
.zone-row {{
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 6px;
|
|
font-size: 0.72rem;
|
|
}}
|
|
|
|
.zone-badge {{
|
|
padding: 1px 4px;
|
|
border-radius: 3px;
|
|
font-size: 0.6rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
}}
|
|
|
|
.zone-alarm {{
|
|
background: #f43f5e;
|
|
color: #ffffff;
|
|
}}
|
|
|
|
.zone-open {{
|
|
background: #f1f5f9;
|
|
color: #475569;
|
|
border: 1px solid #cbd5e1;
|
|
}}
|
|
|
|
.zone-desc {{
|
|
color: #334155;
|
|
font-weight: 500;
|
|
line-height: 1.3;
|
|
}}
|
|
|
|
/* Badges de Estado */
|
|
.status-badge {{
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 3px 8px;
|
|
border-radius: 6px;
|
|
font-weight: 600;
|
|
font-size: 0.72rem;
|
|
gap: 5px;
|
|
}}
|
|
|
|
.account-badge {{
|
|
background: #f1f5f9;
|
|
color: #475569;
|
|
border: 1px solid #e2e8f0;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
font-size: 0.68rem;
|
|
font-weight: 600;
|
|
}}
|
|
|
|
.status-dot {{
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
display: inline-block;
|
|
}}
|
|
|
|
/* Clases de estados claras */
|
|
.state-disarmed {{
|
|
background: #f0fdf4;
|
|
color: #15803d;
|
|
border: 1px solid #bbf7d0;
|
|
}}
|
|
.state-disarmed .status-dot {{
|
|
background-color: #16a34a;
|
|
}}
|
|
|
|
.state-fault {{
|
|
background: #fffbeb;
|
|
color: #b45309;
|
|
border: 1px solid #fde68a;
|
|
}}
|
|
.state-fault .status-dot {{
|
|
background-color: #d97706;
|
|
}}
|
|
|
|
.state-armed-away {{
|
|
background: #fef2f2;
|
|
color: #b91c1c;
|
|
border: 1px solid #fecaca;
|
|
}}
|
|
.state-armed-away .status-dot {{
|
|
background-color: #dc2626;
|
|
}}
|
|
|
|
.state-armed-stay {{
|
|
background: #fff7ed;
|
|
color: #c2410c;
|
|
border: 1px solid #ffedd5;
|
|
}}
|
|
.state-armed-stay .status-dot {{
|
|
background-color: #ea580c;
|
|
}}
|
|
|
|
.state-alarm {{
|
|
background: #fff1f2;
|
|
color: #be123c;
|
|
border: 1px solid #ffe4e6;
|
|
}}
|
|
.state-alarm .status-dot {{
|
|
background-color: #e11d48;
|
|
}}
|
|
|
|
.state-pending {{
|
|
background: #eff6ff;
|
|
color: #1d4ed8;
|
|
border: 1px solid #dbeafe;
|
|
}}
|
|
.state-pending .status-dot {{
|
|
background-color: #2563eb;
|
|
}}
|
|
|
|
.state-unknown {{
|
|
background: #f8fafc;
|
|
color: #64748b;
|
|
border: 1px solid #e2e8f0;
|
|
}}
|
|
.state-unknown .status-dot {{
|
|
background-color: #94a3b8;
|
|
}}
|
|
|
|
.state-revoked {{
|
|
background: #fffbeb;
|
|
color: #d97706;
|
|
border: 1px solid #fef3c7;
|
|
}}
|
|
.state-revoked .status-dot {{
|
|
background-color: #d97706;
|
|
}}
|
|
|
|
/* Botón de Actualización */
|
|
#refresh-btn {{
|
|
background: #2563eb;
|
|
color: white;
|
|
border: none;
|
|
padding: 8px 14px;
|
|
border-radius: 8px;
|
|
font-family: 'Outfit', sans-serif;
|
|
font-size: 0.82rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
transition: background-color 0.15s, border-color 0.15s, color 0.15s, transform 0.1s, box-shadow 0.15s;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
}}
|
|
|
|
#refresh-btn:hover {{
|
|
background: #1d4ed8;
|
|
transform: translateY(-0.5px);
|
|
}}
|
|
|
|
#refresh-btn:disabled {{
|
|
background: #93c5fd;
|
|
cursor: not-allowed;
|
|
transform: none;
|
|
}}
|
|
|
|
#refresh-btn.holding {{
|
|
background: #dc2626 !important;
|
|
color: #ffffff !important;
|
|
transform: scale(0.95);
|
|
box-shadow: 0 0 12px rgba(220, 38, 38, 0.4);
|
|
}}
|
|
|
|
/* Pantalla de Carga Overlay */
|
|
#loading-overlay {{
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
background: rgba(15, 23, 42, 0.7);
|
|
backdrop-filter: blur(4px);
|
|
z-index: 9999;
|
|
display: none;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
color: white;
|
|
font-family: 'Outfit', sans-serif;
|
|
}}
|
|
|
|
.spinner {{
|
|
width: 48px;
|
|
height: 48px;
|
|
border: 4px solid rgba(255, 255, 255, 0.3);
|
|
border-radius: 50%;
|
|
border-top-color: white;
|
|
animation: spin 1s ease-in-out infinite;
|
|
margin-bottom: 16px;
|
|
}}
|
|
|
|
@keyframes spin {{
|
|
to {{ transform: rotate(360deg); }}
|
|
}}
|
|
|
|
.loading-title {{
|
|
font-size: 1.25rem;
|
|
font-weight: 700;
|
|
margin-bottom: 8px;
|
|
}}
|
|
|
|
.loading-subtitle {{
|
|
font-size: 0.9rem;
|
|
color: #cbd5e1;
|
|
}}
|
|
|
|
footer {{
|
|
margin-top: 24px;
|
|
text-align: center;
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
border-top: 1px solid var(--border-color);
|
|
padding-top: 12px;
|
|
}}
|
|
|
|
/* Modal de Log */
|
|
.log-modal {{
|
|
display: none;
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(15, 23, 42, 0.5);
|
|
backdrop-filter: blur(4px);
|
|
z-index: 1050;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}}
|
|
|
|
.log-modal-content {{
|
|
background: #ffffff;
|
|
border-radius: 16px;
|
|
width: 90%;
|
|
max-width: 900px;
|
|
max-height: 85vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
|
border: 1px solid #e2e8f0;
|
|
overflow: hidden;
|
|
animation: modalFadeIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
|
}}
|
|
|
|
@keyframes modalFadeIn {{
|
|
from {{ opacity: 0; transform: scale(0.95); }}
|
|
to {{ opacity: 1; transform: scale(1); }}
|
|
}}
|
|
|
|
.log-modal-header {{
|
|
padding: 14px 20px;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
background: #f8fafc;
|
|
}}
|
|
|
|
.log-modal-header h3 {{
|
|
margin: 0;
|
|
font-size: 1.05rem;
|
|
font-weight: 800;
|
|
color: #0f172a;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}}
|
|
|
|
.log-modal-close {{
|
|
background: transparent;
|
|
border: none;
|
|
color: #64748b;
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
border-radius: 6px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: all 0.15s;
|
|
}}
|
|
|
|
.log-modal-close:hover {{
|
|
background: #e2e8f0;
|
|
color: #0f172a;
|
|
}}
|
|
|
|
.log-modal-body {{
|
|
padding: 16px 20px;
|
|
overflow-y: auto;
|
|
flex-grow: 1;
|
|
background: #0f172a;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}}
|
|
|
|
.log-terminal {{
|
|
font-family: 'Courier New', Courier, monospace;
|
|
font-size: 0.72rem;
|
|
color: #34d399;
|
|
background: transparent;
|
|
border: none;
|
|
resize: none;
|
|
width: 100%;
|
|
height: auto;
|
|
min-height: 400px;
|
|
line-height: 1.5;
|
|
outline: none;
|
|
overflow: visible;
|
|
white-space: pre-wrap;
|
|
margin: 0;
|
|
}}
|
|
|
|
.log-modal-footer {{
|
|
padding: 12px 20px;
|
|
border-top: 1px solid #e2e8f0;
|
|
background: #f8fafc;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 10px;
|
|
}}
|
|
|
|
.btn-log-action {{
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 8px 16px;
|
|
font-weight: 600;
|
|
font-size: 0.78rem;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}}
|
|
|
|
.btn-log-refresh {{
|
|
background: #3b82f6;
|
|
color: #ffffff;
|
|
border: none;
|
|
}}
|
|
|
|
.btn-log-refresh:hover {{
|
|
background: #2563eb;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<header>
|
|
<div>
|
|
<h1 title="v{APP_VERSION}" style="cursor: help;">TotalConnect 2.0</h1>
|
|
<div class="header-subtitle">Monitoreo centralizado de alarmas</div>
|
|
</div>
|
|
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
|
|
<div class="accounts-selector" style="display: flex; gap: 10px; align-items: center; background: #f8fafc; padding: 6px 12px; border-radius: 8px; border: 1px solid #e2e8f0;">
|
|
<span style="font-size: 0.7rem; font-weight: 700; color: #64748b; text-transform: uppercase; letter-spacing: 0.3px; margin-right: 4px;">Cuentas:</span>
|
|
{checkboxes_html}
|
|
</div>
|
|
<button id="toggle-polling-btn" onclick="togglePolling()" style="display: flex; align-items: center; gap: 6px; padding: 6px 12px; font-weight: 600; font-size: 0.75rem; border-radius: 8px; border: 1px solid #a7f3d0; background: #ecfdf5; color: #065f46; cursor: pointer; transition: all 0.2s;">
|
|
<span id="polling-dot" style="width: 8px; height: 8px; border-radius: 50%; background: #10b981; display: inline-block;"></span>
|
|
<span id="polling-text">Polling: Activo</span>
|
|
</button>
|
|
<button id="refresh-btn">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/></svg>
|
|
Actualizar
|
|
</button>
|
|
<button id="view-log-btn" onclick="openLogModal()" style="display: flex; align-items: center; gap: 6px; padding: 6px 12px; font-weight: 600; font-size: 0.75rem; border-radius: 8px; border: 1px solid #cbd5e1; background: #f8fafc; color: #475569; cursor: pointer; transition: all 0.2s;">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
|
|
Ver Log
|
|
</button>
|
|
<div class="timestamp">
|
|
<div class="timestamp-label">Última Actualización</div>
|
|
<div class="timestamp-value">{timestamp}</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="grid-container">
|
|
{cards_html}
|
|
</div>
|
|
|
|
<footer>
|
|
Generado automáticamente on-demand • TotalConnect REST Client
|
|
</footer>
|
|
</div>
|
|
|
|
<!-- Modal de Log de Actividad -->
|
|
<div id="log-modal" class="log-modal" onclick="if(event.target === this) closeLogModal()">
|
|
<div class="log-modal-content">
|
|
<div class="log-modal-header">
|
|
<h3>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
|
|
Log de Actividad del Servidor (Últimas 150 líneas)
|
|
</h3>
|
|
<button class="log-modal-close" onclick="closeLogModal()">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
|
</button>
|
|
</div>
|
|
<div class="log-modal-body">
|
|
<pre id="log-terminal-box" class="log-terminal">Cargando bitácora de actividad...</pre>
|
|
</div>
|
|
<div class="log-modal-footer">
|
|
<button class="btn-log-action btn-log-refresh" onclick="fetchLogContent()">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/></svg>
|
|
Refrescar
|
|
</button>
|
|
<button class="btn-log-action" style="background: #e2e8f0; color: #475569; border: 1px solid #cbd5e1;" onclick="closeLogModal()">
|
|
Cerrar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal de carga -->
|
|
<div id="loading-overlay">
|
|
<div class="spinner"></div>
|
|
<div class="loading-title">Actualizando Estatus</div>
|
|
<div class="loading-subtitle">Conectando a los paneles de alarma... Esto tomará aproximadamente 1-2 minutos.</div>
|
|
</div>
|
|
|
|
<script>
|
|
async function triggerRefresh() {{
|
|
const selected = Array.from(document.querySelectorAll('input[name="account-select"]:checked')).map(cb => cb.value);
|
|
if (selected.length === 0) {{
|
|
alert('Por favor selecciona al menos una cuenta para actualizar.');
|
|
return;
|
|
}}
|
|
|
|
const btn = document.getElementById('refresh-btn');
|
|
const overlay = document.getElementById('loading-overlay');
|
|
const subtitle = document.querySelector('.loading-subtitle');
|
|
|
|
btn.disabled = true;
|
|
btn.innerHTML = 'Actualizando...';
|
|
overlay.style.display = 'flex';
|
|
|
|
let url = '/refresh';
|
|
let statusUrl = '/current-loading';
|
|
if (window.location.protocol === 'file:') {{
|
|
if (window.location.pathname.indexOf(':') !== -1) {{
|
|
url = 'http://localhost:8080/refresh';
|
|
statusUrl = 'http://localhost:8080/current-loading';
|
|
}} else {{
|
|
url = 'http://192.168.100.50:8080/refresh';
|
|
statusUrl = 'http://192.168.100.50:8080/current-loading';
|
|
}}
|
|
}}
|
|
|
|
// Iniciar sondeo del estado de carga en la UI
|
|
const statusInterval = setInterval(async () => {{
|
|
try {{
|
|
const res = await fetch(statusUrl);
|
|
const data = await res.json();
|
|
if (data.status) {{
|
|
subtitle.innerHTML = data.status;
|
|
}}
|
|
}} catch (e) {{
|
|
// Ignorar fallas temporales
|
|
}}
|
|
}}, 1500);
|
|
|
|
try {{
|
|
const response = await fetch(url, {{
|
|
method: 'POST',
|
|
headers: {{ 'Content-Type': 'application/json' }},
|
|
body: JSON.stringify({{ accounts: selected }})
|
|
}});
|
|
const result = await response.json();
|
|
clearInterval(statusInterval);
|
|
if (result.status === 'success') {{
|
|
setTimeout(() => window.location.reload(), 1500);
|
|
}} else {{
|
|
alert('Error al actualizar: ' + (result.message || 'Error desconocido'));
|
|
overlay.style.display = 'none';
|
|
btn.disabled = false;
|
|
btn.innerHTML = 'Actualizar';
|
|
subtitle.innerHTML = 'Conectando a los paneles de alarma... Esto tomará aproximadamente 1-2 minutos.';
|
|
}}
|
|
}} catch (error) {{
|
|
clearInterval(statusInterval);
|
|
alert('No se pudo conectar con el servidor de actualización.');
|
|
overlay.style.display = 'none';
|
|
btn.disabled = false;
|
|
btn.innerHTML = 'Actualizar';
|
|
subtitle.innerHTML = 'Conectando a los paneles de alarma... Esto tomará aproximadamente 1-2 minutos.';
|
|
}}
|
|
}}
|
|
|
|
async function togglePolling() {{
|
|
let toggleUrl = '/toggle-polling';
|
|
if (window.location.protocol === 'file:') {{
|
|
if (window.location.pathname.indexOf(':') !== -1) {{
|
|
toggleUrl = 'http://localhost:8080/toggle-polling';
|
|
}} else {{
|
|
toggleUrl = 'http://192.168.100.50:8080/toggle-polling';
|
|
}}
|
|
}}
|
|
try {{
|
|
const res = await fetch(toggleUrl, {{ method: 'POST' }});
|
|
const data = await res.json();
|
|
if (data.status === 'success') {{
|
|
updatePollingUI(data.enabled);
|
|
}}
|
|
}} catch (e) {{
|
|
alert('No se pudo cambiar el estado del polling.');
|
|
}}
|
|
}}
|
|
|
|
function updatePollingUI(enabled) {{
|
|
const dot = document.getElementById('polling-dot');
|
|
const text = document.getElementById('polling-text');
|
|
const btn = document.getElementById('toggle-polling-btn');
|
|
if (!dot || !text || !btn) return;
|
|
if (enabled) {{
|
|
dot.style.background = '#10b981';
|
|
text.innerText = 'Polling: Activo';
|
|
btn.style.borderColor = '#a7f3d0';
|
|
btn.style.background = '#ecfdf5';
|
|
btn.style.color = '#065f46';
|
|
}} else {{
|
|
dot.style.background = '#ef4444';
|
|
text.innerText = 'Polling: Pausado';
|
|
btn.style.borderColor = '#fca5a5';
|
|
btn.style.background = '#fef2f2';
|
|
btn.style.color = '#991b1b';
|
|
}}
|
|
}}
|
|
|
|
let currentLastPoll = null;
|
|
|
|
// Consultar periódicamente el estado del polling en segundo plano
|
|
setInterval(async () => {{
|
|
try {{
|
|
let pollUrl = '/polling-status';
|
|
if (window.location.protocol === 'file:') {{
|
|
if (window.location.pathname.indexOf(':') !== -1) {{
|
|
pollUrl = 'http://localhost:8080/polling-status';
|
|
}} else {{
|
|
pollUrl = 'http://192.168.100.50:8080/polling-status';
|
|
}}
|
|
}}
|
|
const res = await fetch(pollUrl);
|
|
const data = await res.json();
|
|
if (data.enabled !== undefined) {{
|
|
updatePollingUI(data.enabled);
|
|
}}
|
|
// Si se completó un nuevo ciclo de actualización en segundo plano, recargar automáticamente las tarjetas
|
|
if (data.last_poll) {{
|
|
if (currentLastPoll !== null && currentLastPoll !== data.last_poll) {{
|
|
window.location.reload();
|
|
}}
|
|
currentLastPoll = data.last_poll;
|
|
}}
|
|
}} catch (e) {{}}
|
|
}}, 6000);
|
|
|
|
// Recargar automáticamente al recuperar el foco de la ventana tras inactividad
|
|
document.addEventListener('visibilitychange', () => {{
|
|
if (document.visibilityState === 'visible') {{
|
|
window.location.reload();
|
|
}}
|
|
}});
|
|
|
|
async function fetchLogContent() {{
|
|
const terminal = document.getElementById('log-terminal-box');
|
|
if (!terminal) return;
|
|
|
|
let logUrl = '/activity-log';
|
|
if (window.location.protocol === 'file:') {{
|
|
if (window.location.pathname.indexOf(':') !== -1) {{
|
|
logUrl = 'http://localhost:8080/activity-log';
|
|
}} else {{
|
|
logUrl = 'http://192.168.100.50:8080/activity-log';
|
|
}}
|
|
}}
|
|
|
|
try {{
|
|
terminal.innerText = 'Cargando bitácora de actividad...';
|
|
const res = await fetch(logUrl);
|
|
const text = await res.text();
|
|
terminal.innerText = text || 'No hay actividad registrada aún.';
|
|
const body = document.querySelector('.log-modal-body');
|
|
if (body) {{
|
|
body.scrollTop = body.scrollHeight;
|
|
}}
|
|
}} catch (e) {{
|
|
terminal.innerText = 'Error al conectar con el servidor para leer el log: ' + e.message;
|
|
}}
|
|
}}
|
|
|
|
function openLogModal() {{
|
|
const modal = document.getElementById('log-modal');
|
|
if (modal) {{
|
|
modal.style.display = 'flex';
|
|
fetchLogContent();
|
|
}}
|
|
}}
|
|
|
|
function closeLogModal() {{
|
|
const modal = document.getElementById('log-modal');
|
|
if (modal) {{
|
|
modal.style.display = 'none';
|
|
}}
|
|
}}
|
|
|
|
const refreshBtn = document.getElementById('refresh-btn');
|
|
let holdTimer = null;
|
|
let visualTimer = null;
|
|
let isHolding = false;
|
|
let holdStartTime = 0;
|
|
const HOLD_DURATION = 3000; // 3 segundos
|
|
|
|
function startHold(e) {{
|
|
if (refreshBtn.disabled) return;
|
|
isHolding = true;
|
|
holdStartTime = Date.now();
|
|
|
|
// Iniciar el temporizador visual para el cambio a rojo al cabo de 1 segundo (1000ms)
|
|
visualTimer = setTimeout(() => {{
|
|
if (isHolding) {{
|
|
refreshBtn.classList.add('holding');
|
|
refreshBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg> ¡Mantén para Limpiar!';
|
|
}}
|
|
}}, 1000);
|
|
|
|
holdTimer = setTimeout(async () => {{
|
|
isHolding = false;
|
|
clearHoldState();
|
|
|
|
if (confirm('⚠️ ¿Estás seguro de que deseas borrar toda la caché local del servidor (last_state, sucursales y SOAP) y forzar un escaneo limpio desde cero?')) {{
|
|
triggerResetCache();
|
|
}}
|
|
}}, HOLD_DURATION);
|
|
}}
|
|
|
|
function cancelHold(e) {{
|
|
if (!isHolding) return;
|
|
isHolding = false;
|
|
clearTimeout(holdTimer);
|
|
clearTimeout(visualTimer);
|
|
clearHoldState();
|
|
|
|
const pressDuration = Date.now() - holdStartTime;
|
|
// Clic corto ordinario: menos de 300ms actualiza normal.
|
|
// Presión de arrepentimiento: entre 300ms y 3000ms regresa a "aquí no pasó nada" sin hacer refresh.
|
|
if (pressDuration < 300) {{
|
|
triggerRefresh();
|
|
}}
|
|
}}
|
|
|
|
function clearHoldState() {{
|
|
refreshBtn.classList.remove('holding');
|
|
refreshBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/></svg> Actualizar';
|
|
}}
|
|
|
|
async function triggerResetCache() {{
|
|
const btn = document.getElementById('refresh-btn');
|
|
const overlay = document.getElementById('loading-overlay');
|
|
const title = document.querySelector('.loading-title');
|
|
const subtitle = document.querySelector('.loading-subtitle');
|
|
|
|
btn.disabled = true;
|
|
title.innerHTML = 'Limpiando Caché';
|
|
subtitle.innerHTML = 'Borrando base de datos temporal e iniciando escaneo limpio desde cero...';
|
|
overlay.style.display = 'flex';
|
|
|
|
let url = '/reset-cache';
|
|
if (window.location.protocol === 'file:') {{
|
|
if (window.location.pathname.indexOf(':') !== -1) {{
|
|
url = 'http://localhost:8080/reset-cache';
|
|
}} else {{
|
|
url = 'http://192.168.100.50:8080/reset-cache';
|
|
}}
|
|
}}
|
|
|
|
try {{
|
|
const response = await fetch(url, {{
|
|
method: 'POST',
|
|
headers: {{ 'Content-Type': 'application/json' }}
|
|
}});
|
|
const result = await response.json();
|
|
if (result.status === 'success') {{
|
|
setTimeout(() => window.location.reload(), 2000);
|
|
}} else {{
|
|
alert('Error al reiniciar: ' + (result.message || 'Error desconocido'));
|
|
overlay.style.display = 'none';
|
|
btn.disabled = false;
|
|
title.innerHTML = 'Actualizando Estatus';
|
|
subtitle.innerHTML = 'Conectando a los paneles de alarma... Esto tomará aproximadamente 1-2 minutos.';
|
|
}}
|
|
}} catch (error) {{
|
|
alert('No se pudo conectar con el servidor para reiniciar la caché.');
|
|
overlay.style.display = 'none';
|
|
btn.disabled = false;
|
|
title.innerHTML = 'Actualizando Estatus';
|
|
subtitle.innerHTML = 'Conectando a los paneles de alarma... Esto tomará aproximadamente 1-2 minutos.';
|
|
}}
|
|
}}
|
|
|
|
// Registrar listeners para mouse y pantallas táctiles
|
|
if (refreshBtn) {{
|
|
refreshBtn.addEventListener('mousedown', startHold);
|
|
refreshBtn.addEventListener('mouseup', cancelHold);
|
|
refreshBtn.addEventListener('mouseleave', cancelHold);
|
|
|
|
refreshBtn.addEventListener('touchstart', (e) => {{
|
|
e.preventDefault();
|
|
startHold(e);
|
|
}});
|
|
refreshBtn.addEventListener('touchend', (e) => {{
|
|
e.preventDefault();
|
|
cancelHold(e);
|
|
}});
|
|
}}
|
|
</script>
|
|
</body>
|
|
</html>"""
|
|
|
|
with open(HTML_OUTPUT_FILE, 'w', encoding='utf-8') as f:
|
|
f.write(html_content)
|
|
log_msg(f"🎉 Reporte HTML premium generado con éxito en: {HTML_OUTPUT_FILE}")
|
|
|
|
|
|
class DashboardHandler(http.server.SimpleHTTPRequestHandler):
|
|
def log_message(self, format, *args):
|
|
# Silenciar logs de peticiones de red ordinarias en consola/stderr
|
|
pass
|
|
|
|
def do_GET(self):
|
|
if self.path == '/':
|
|
self.send_response(200)
|
|
self.send_header('Content-type', 'text/html; charset=utf-8')
|
|
self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
|
|
self.send_header('Pragma', 'no-cache')
|
|
self.send_header('Expires', '0')
|
|
self.end_headers()
|
|
try:
|
|
with open(HTML_OUTPUT_FILE, 'r', encoding='utf-8') as f:
|
|
self.wfile.write(f.read().encode('utf-8'))
|
|
except Exception as e:
|
|
self.wfile.write(f"Error cargando el archivo: {e}".encode('utf-8'))
|
|
elif self.path == '/activity-log':
|
|
self.send_response(200)
|
|
self.send_header('Content-type', 'text/plain; charset=utf-8')
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
|
self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
|
|
self.end_headers()
|
|
try:
|
|
if os.path.exists(LOG_FILE):
|
|
with open(LOG_FILE, 'r', encoding='utf-8') as f:
|
|
lines = f.readlines()
|
|
# Devolver las últimas 150 líneas de actividad para la modal
|
|
last_lines = "".join(lines[-150:])
|
|
self.wfile.write(last_lines.encode('utf-8'))
|
|
else:
|
|
self.wfile.write("No hay actividad registrada aún.".encode('utf-8'))
|
|
except Exception as e:
|
|
self.wfile.write(f"Error al leer activity.log: {e}".encode('utf-8'))
|
|
elif self.path == '/current-loading':
|
|
self.send_response(200)
|
|
self.send_header('Content-type', 'application/json')
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
|
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
|
self.end_headers()
|
|
self.wfile.write(json.dumps({"status": current_loading_status}).encode('utf-8'))
|
|
elif self.path == '/polling-status':
|
|
self.send_response(200)
|
|
self.send_header('Content-type', 'application/json')
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
|
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
|
self.end_headers()
|
|
data = {
|
|
"enabled": polling_enabled,
|
|
"last_poll": last_poll_timestamp,
|
|
"loading_status": current_loading_status
|
|
}
|
|
self.wfile.write(json.dumps(data).encode('utf-8'))
|
|
else:
|
|
# Comportamiento predeterminado para otros archivos
|
|
super().do_GET()
|
|
|
|
def do_POST(self):
|
|
if self.path == '/refresh':
|
|
self.send_response(200)
|
|
self.send_header('Content-type', 'application/json')
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
|
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
|
self.end_headers()
|
|
|
|
# Leer el cuerpo del POST si existe
|
|
content_length = int(self.headers.get('Content-Length', 0))
|
|
selected_labels = None
|
|
if content_length > 0:
|
|
try:
|
|
post_data = self.rfile.read(content_length)
|
|
body_json = json.loads(post_data.decode('utf-8'))
|
|
selected_labels = body_json.get('accounts')
|
|
except Exception as e:
|
|
print(f"⚠️ Error al parsear cuerpo de solicitud POST: {e}")
|
|
|
|
# Intentar adquirir el lock en modo no-bloqueante
|
|
acquired = update_lock.acquire(blocking=False)
|
|
if not acquired:
|
|
self.wfile.write(json.dumps({
|
|
"status": "error",
|
|
"message": "Ya hay una actualización en curso en el servidor. Por favor espera a que termine la anterior."
|
|
}).encode('utf-8'))
|
|
return
|
|
|
|
try:
|
|
# Cargar configuración completa
|
|
accounts_cfg, _, exclude_cfg, _ = load_credentials_config()
|
|
|
|
# Filtrar si el usuario seleccionó específicas
|
|
if selected_labels is not None:
|
|
accounts_to_run = [acc for acc in accounts_cfg if acc.get('label') in selected_labels]
|
|
else:
|
|
accounts_to_run = accounts_cfg
|
|
|
|
success = run_update(accounts=accounts_to_run, exclude=exclude_cfg, full_refresh=True)
|
|
if success:
|
|
self.wfile.write(json.dumps({"status": "success"}).encode('utf-8'))
|
|
else:
|
|
self.wfile.write(json.dumps({"status": "error", "message": "No se pudieron recuperar datos de ninguna cuenta."}).encode('utf-8'))
|
|
except Exception as e:
|
|
self.wfile.write(json.dumps({"status": "error", "message": str(e)}).encode('utf-8'))
|
|
finally:
|
|
update_lock.release()
|
|
elif self.path == '/reset-cache':
|
|
self.send_response(200)
|
|
self.send_header('Content-type', 'application/json')
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
|
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
|
self.end_headers()
|
|
|
|
acquired = update_lock.acquire(blocking=False)
|
|
if not acquired:
|
|
self.wfile.write(json.dumps({
|
|
"status": "error",
|
|
"message": "Ya hay una actualización en curso en el servidor. Por favor espera."
|
|
}).encode('utf-8'))
|
|
return
|
|
|
|
try:
|
|
# 1. Limpiar sesiones en caliente
|
|
with client_pool_lock:
|
|
client_pool.clear()
|
|
consecutive_errors.clear()
|
|
|
|
# 2. Borrar archivos físicos
|
|
for fpath in [LAST_STATE_FILE, CACHE_FILE, LAST_EVENT_IDS_FILE]:
|
|
if os.path.exists(fpath):
|
|
try:
|
|
os.remove(fpath)
|
|
except Exception:
|
|
pass
|
|
|
|
log_msg("🗑️ Hard Reset: Se eliminó la caché del servidor a petición del usuario. Iniciando escaneo limpio.")
|
|
|
|
# 3. Disparar el poller inmediatamente
|
|
polling_trigger_event.set()
|
|
|
|
self.wfile.write(json.dumps({
|
|
"status": "success",
|
|
"message": "Limpieza de caché completada con éxito. Reiniciando de cero..."
|
|
}).encode('utf-8'))
|
|
except Exception as e:
|
|
self.wfile.write(json.dumps({
|
|
"status": "error",
|
|
"message": f"Error al purgar caché: {e}"
|
|
}).encode('utf-8'))
|
|
finally:
|
|
update_lock.release()
|
|
elif self.path == '/toggle-polling':
|
|
global polling_enabled
|
|
polling_enabled = not polling_enabled
|
|
save_polling_state(polling_enabled)
|
|
state_str = "activado" if polling_enabled else "pausado"
|
|
log_msg(f"🛑/🟢 Polling en segundo plano {state_str} por el usuario.")
|
|
if polling_enabled:
|
|
polling_trigger_event.set()
|
|
self.send_response(200)
|
|
self.send_header('Content-type', 'application/json')
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
|
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
|
self.end_headers()
|
|
self.wfile.write(json.dumps({"status": "success", "enabled": polling_enabled}).encode('utf-8'))
|
|
else:
|
|
self.send_response(404)
|
|
self.end_headers()
|
|
|
|
def do_OPTIONS(self):
|
|
self.send_response(200)
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
|
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
|
self.end_headers()
|
|
|
|
|
|
class ThreadingHTTPServer(ThreadingMixIn, http.server.HTTPServer):
|
|
pass
|
|
|
|
|
|
def load_credentials_config():
|
|
"""Carga las credenciales, el puerto, la lista de exclusión y la opción de navegador de credentials.json."""
|
|
is_windows = platform.system() == "Windows"
|
|
has_display = is_windows or "DISPLAY" in os.environ
|
|
default_open = True if has_display else False
|
|
|
|
if not os.path.exists(CREDENTIALS_FILE):
|
|
return None, 8080, [], default_open
|
|
try:
|
|
with open(CREDENTIALS_FILE, 'r', encoding='utf-8') as f:
|
|
config = json.load(f)
|
|
|
|
# Si es un objeto (nuevo formato recomendado)
|
|
if isinstance(config, dict):
|
|
port = config.get("port", 8080)
|
|
exclude = config.get("exclude", [])
|
|
open_browser = config.get("open_browser", default_open)
|
|
accounts = config.get("accounts", [])
|
|
return accounts, port, exclude, open_browser
|
|
# Si es una lista (formato heredado compatible)
|
|
elif isinstance(config, list):
|
|
return config, 8080, [], default_open
|
|
except Exception as e:
|
|
print(f"⚠️ Error al leer credenciales: {e}")
|
|
return None, 8080, [], default_open
|
|
return None, 8080, [], default_open
|
|
|
|
|
|
def background_poller_thread():
|
|
global polling_enabled, last_poll_timestamp, consecutive_errors
|
|
log_msg("🔄 Motor de polling en segundo plano iniciado (intervalo: 30s)...")
|
|
while True:
|
|
polling_trigger_event.wait(timeout=30)
|
|
polling_trigger_event.clear()
|
|
|
|
if not polling_enabled:
|
|
continue
|
|
|
|
acquired = update_lock.acquire(blocking=False)
|
|
if not acquired:
|
|
continue
|
|
|
|
try:
|
|
accounts_cfg, _, exclude_cfg, _ = load_credentials_config()
|
|
if not accounts_cfg:
|
|
continue
|
|
|
|
now = datetime.datetime.now(ZoneInfo("America/Mexico_City"))
|
|
last_poll_timestamp = now.strftime('%I:%M:%S %p')
|
|
|
|
# Comprobar si hay errores persistentes acumulados (Circuit Breaker)
|
|
has_persistent_errors = any(err_count >= 3 for err_count in consecutive_errors.values())
|
|
if has_persistent_errors:
|
|
log_msg("⚠️ Circuit Breaker: Polling pausado automáticamente por múltiples fallos de autenticación.")
|
|
polling_enabled = False
|
|
continue
|
|
|
|
log_msg(f"🔄 Polling automático iniciado ({last_poll_timestamp})...")
|
|
# Ejecutar actualización real de datos desde Honeywell
|
|
run_update(accounts=accounts_cfg, exclude=exclude_cfg)
|
|
|
|
except Exception as e:
|
|
log_msg(f"⚠️ Excepción en hilo de polling: {e}")
|
|
finally:
|
|
update_lock.release()
|
|
|
|
|
|
def run_server(port=8080, open_browser=True):
|
|
# Cargar y re-renderizar la plantilla HTML desde last_state.json al arrancar el servidor
|
|
if os.path.exists(LAST_STATE_FILE):
|
|
try:
|
|
with open(LAST_STATE_FILE, 'r', encoding='utf-8') as f:
|
|
last_state = json.load(f)
|
|
# Reconstruir objetos ArmingState
|
|
for acc_state in last_state:
|
|
for loc in acc_state.get('locations', []):
|
|
if isinstance(loc['status'], int):
|
|
try:
|
|
loc['status'] = ArmingState(loc['status'])
|
|
except Exception:
|
|
loc['status'] = ArmingState.UNKNOWN
|
|
generate_html_report(last_state)
|
|
except Exception as e:
|
|
print(f"⚠️ No se pudo regenerar el reporte desde last_state.json: {e}")
|
|
elif not os.path.exists(HTML_OUTPUT_FILE):
|
|
print("⚠️ No se encontró reporte previo. Generando primer reporte vacío...")
|
|
generate_html_report([])
|
|
|
|
# Arrancar hilo en segundo plano para el polling continuo de eventos
|
|
poller = threading.Thread(target=background_poller_thread, daemon=True)
|
|
poller.start()
|
|
|
|
socketserver.TCPServer.allow_reuse_address = True
|
|
with ThreadingHTTPServer(("", port), DashboardHandler) as httpd:
|
|
url = f"http://localhost:{port}/"
|
|
print(f"\n🌐 Servidor de monitoreo iniciado en:")
|
|
print(f" - Local: {url}")
|
|
print(f" - VM Red: http://192.168.100.50:{port}/")
|
|
print("Presiona Ctrl+C para detener el servidor.")
|
|
|
|
if open_browser:
|
|
print(f" 🚀 Abriendo navegador en {url}...")
|
|
try:
|
|
webbrowser.open(url)
|
|
except Exception as e:
|
|
print(f" ⚠️ No se pudo abrir el navegador automáticamente: {e}")
|
|
|
|
try:
|
|
httpd.serve_forever()
|
|
except KeyboardInterrupt:
|
|
print("\n🛑 Servidor detenido.")
|
|
|
|
|
|
def soap_get_events(session_id, last_event_id, location_id=0):
|
|
"""Consulta nuevos eventos SOAP desde last_event_id. Devuelve (lista_de_eventos, next_event_id)."""
|
|
url = "https://rs.alarmnet.com/TC21API/tc2.asmx"
|
|
headers = {
|
|
'Content-Type': 'text/xml; charset=utf-8',
|
|
'SOAPAction': 'https://services.alarmnet.com/TC2/GetLiveEvents'
|
|
}
|
|
|
|
event_count = 50 if last_event_id > 0 else 1
|
|
|
|
soap_body = f"""<?xml version="1.0" encoding="utf-8"?>
|
|
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
<soap:Body>
|
|
<GetLiveEvents xmlns="https://services.alarmnet.com/TC2/">
|
|
<SessionId>{session_id}</SessionId>
|
|
<LocationId>{location_id}</LocationId>
|
|
<LastEventIdReceived>{last_event_id}</LastEventIdReceived>
|
|
<MaxRecords>{event_count}</MaxRecords>
|
|
<DateFormat>1</DateFormat>
|
|
<TimeFormat>1</TimeFormat>
|
|
</GetLiveEvents>
|
|
</soap:Body>
|
|
</soap:Envelope>"""
|
|
|
|
events = []
|
|
max_event_id = last_event_id
|
|
|
|
try:
|
|
res = requests.post(url, data=soap_body.encode('utf-8'), headers=headers, timeout=12)
|
|
res.raise_for_status()
|
|
root = ET.fromstring(res.text)
|
|
|
|
# Encontrar nodo GetLiveEventsResult o GetSessionUserEventsResult ignorando namespaces
|
|
result_node = None
|
|
for elem in root.iter():
|
|
if elem.tag.endswith('GetLiveEventsResult') or elem.tag.endswith('GetSessionUserEventsResult'):
|
|
result_node = elem
|
|
break
|
|
|
|
if result_node is None:
|
|
result_node = root
|
|
|
|
# Extraer ResultCode
|
|
result_code_text = "0"
|
|
for child in result_node:
|
|
if child.tag.endswith('ResultCode'):
|
|
result_code_text = child.text
|
|
break
|
|
|
|
if result_code_text == '0':
|
|
# Buscar dinámicamente cualquier nodo que represente un evento por sus campos internos
|
|
for elem in result_node.iter():
|
|
event_id_val = None
|
|
loc_id_val = None
|
|
class_id_val = None
|
|
type_id_val = None
|
|
|
|
for child in elem:
|
|
tag_local = child.tag.split('}')[-1].lower() if '}' in child.tag else child.tag.lower()
|
|
if tag_local in ('eventid', 'eventrecordid'):
|
|
try:
|
|
event_id_val = int(child.text)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
elif tag_local in ('locationid', 'deviceid'):
|
|
try:
|
|
loc_id_val = int(child.text)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
elif tag_local in ('eventclassid', 'filterclass'):
|
|
try:
|
|
class_id_val = int(child.text)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
elif tag_local in ('eventtypeid', 'eventtype'):
|
|
try:
|
|
type_id_val = int(child.text)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
if event_id_val is not None:
|
|
events.append({
|
|
'event_id': event_id_val,
|
|
'location_id': loc_id_val if loc_id_val is not None else 0,
|
|
'class_id': class_id_val if class_id_val is not None else 0,
|
|
'type_id': type_id_val if type_id_val is not None else 0
|
|
})
|
|
if event_id_val > max_event_id:
|
|
max_event_id = event_id_val
|
|
elif result_code_text in ('-102', '-103', '4101', '4102'):
|
|
raise ConnectionResetError("SOAP Session is invalid or expired.")
|
|
else:
|
|
# Reportar error en la respuesta
|
|
data_str = ""
|
|
for child in result_node:
|
|
if child.tag.endswith('ResultData'):
|
|
data_str = child.text or ""
|
|
break
|
|
log_msg(f" ⚠️ GetLiveEvents falló con código {result_code_text}: {data_str}")
|
|
except ConnectionResetError:
|
|
raise
|
|
except Exception as e:
|
|
log_msg(f" ❌ Error al consultar GetLiveEvents: {e}")
|
|
|
|
return events, max_event_id
|
|
|
|
|
|
def get_soap_session_id(client, force_refresh=False):
|
|
"""Obtiene o refresca el SessionID GUID para SOAP usando el cliente REST."""
|
|
username = client.username
|
|
if not force_refresh:
|
|
guid = soap_session_ids.get(username)
|
|
if guid:
|
|
return guid
|
|
|
|
try:
|
|
from total_connect_client.const import HTTP_API_SESSION_DETAILS_ENDPOINT
|
|
sd_res = client.http_request(
|
|
endpoint=HTTP_API_SESSION_DETAILS_ENDPOINT,
|
|
method="GET",
|
|
params={"appId": client._app_id, "appVersion": client._app_version}
|
|
)
|
|
soap_guid = sd_res.get("SessionDetailsResult", {}).get("SessionID")
|
|
if soap_guid:
|
|
soap_session_ids[username] = soap_guid
|
|
soap_disabled_accounts.discard(username)
|
|
return soap_guid
|
|
except Exception as e:
|
|
log_msg(f" ⚠️ Error al obtener SessionID SOAP para {username}: {e}")
|
|
return None
|
|
|
|
|
|
def get_authenticated_client(acc, force_reauth=False):
|
|
"""Obtiene un cliente autenticado reutilizando la sesión activa en memoria si está viva."""
|
|
global client_pool, consecutive_errors
|
|
username = acc['username']
|
|
password = acc['password']
|
|
|
|
with client_pool_lock:
|
|
if force_reauth:
|
|
client_pool.pop(username, None)
|
|
|
|
client = client_pool.get(username)
|
|
if client and getattr(client, '_logged_in', False):
|
|
return client
|
|
|
|
print(f"🔑 Autenticando sesión para {acc['label']} ({username})...")
|
|
client = TotalConnectClient(
|
|
username=username,
|
|
password=password,
|
|
load_details=False
|
|
)
|
|
client_pool[username] = client
|
|
consecutive_errors[acc['label']] = 0
|
|
# Extraer SessionID GUID del endpoint sessiondetails para usarlo en SOAP GetLiveEvents
|
|
get_soap_session_id(client)
|
|
return client
|
|
|
|
|
|
def _apply_soap_events(events_list, next_event_id, acc, curr_last_event_id, device_to_location, locations_to_update, log_suffix=""):
|
|
"""Procesa eventos SOAP recibidos, actualiza locations_to_update y persiste el ID de referencia."""
|
|
suffix_str = f" {log_suffix}" if log_suffix else ""
|
|
if curr_last_event_id == 0:
|
|
last_event_ids[acc['username']] = next_event_id
|
|
save_last_event_ids(last_event_ids)
|
|
log_msg(f" ✓ Inicializado rastreador de eventos SOAP{suffix_str} en ID: {next_event_id} (sin procesar eventos previos)")
|
|
else:
|
|
if events_list:
|
|
log_msg(f" 🔔 Detectados {len(events_list)} eventos nuevos en la cuenta {acc['label']}{suffix_str}.")
|
|
for ev in events_list:
|
|
dev_id_or_loc_id = ev['location_id']
|
|
mapped_loc_id = device_to_location.get(dev_id_or_loc_id, dev_id_or_loc_id)
|
|
if mapped_loc_id > 0:
|
|
locations_to_update.add(mapped_loc_id)
|
|
last_event_ids[acc['username']] = next_event_id
|
|
save_last_event_ids(last_event_ids)
|
|
|
|
|
|
def run_update(accounts=None, exclude=None, full_refresh=False):
|
|
global current_loading_status
|
|
current_loading_status = "Iniciando cliente de consulta..."
|
|
log_msg("🚀 Iniciando cliente de consulta TotalConnect 2.0 (REST/OAuth2)...")
|
|
|
|
try:
|
|
if accounts is None or exclude is None:
|
|
accounts_cfg, _, exclude_cfg, _ = load_credentials_config()
|
|
if accounts is None:
|
|
accounts = accounts_cfg
|
|
if exclude is None:
|
|
exclude = exclude_cfg
|
|
|
|
if not accounts:
|
|
log_msg(f"❌ No se pudieron cargar las cuentas desde {CREDENTIALS_FILE}")
|
|
return False
|
|
|
|
# Cargar el último estado consolidado si existe
|
|
last_state = []
|
|
if os.path.exists(LAST_STATE_FILE):
|
|
try:
|
|
with open(LAST_STATE_FILE, 'r', encoding='utf-8') as f:
|
|
last_state = json.load(f)
|
|
except Exception as e:
|
|
log_msg(f"⚠️ No se pudo cargar el último estado: {e}")
|
|
|
|
# Reconstruir los objetos ArmingState y mapear sucursales por ID
|
|
last_loc_map = {}
|
|
for acc_state in last_state:
|
|
for loc in acc_state.get('locations', []):
|
|
if isinstance(loc['status'], int):
|
|
try:
|
|
loc['status'] = ArmingState(loc['status'])
|
|
except Exception:
|
|
loc['status'] = ArmingState.UNKNOWN
|
|
last_loc_map[str(loc['id'])] = loc
|
|
|
|
last_state_map = {acc_item['label']: acc_item for acc_item in last_state}
|
|
updated_labels = set()
|
|
|
|
# Cargar caché de ubicaciones conocidas
|
|
cache_data = {}
|
|
if os.path.exists(CACHE_FILE):
|
|
try:
|
|
with open(CACHE_FILE, 'r', encoding='utf-8') as f:
|
|
cache_data = json.load(f)
|
|
except Exception:
|
|
cache_data = {}
|
|
|
|
report_data = []
|
|
|
|
for acc in accounts:
|
|
current_loading_status = f"Conectando a cuenta: {acc['label']}"
|
|
log_msg(f"👤 Conectando a cuenta: {acc['label']} ({acc['username']})...")
|
|
try:
|
|
# Reutilizar sesión autenticada en memoria para velocidad instantánea
|
|
try:
|
|
client = get_authenticated_client(acc)
|
|
except Exception as auth_err:
|
|
log_msg(f" ⚠️ Reintentando autenticación limpia para {acc['label']}...")
|
|
client = get_authenticated_client(acc, force_reauth=True)
|
|
|
|
log_msg(f" ✓ Conexión establecida con éxito. ({len(client.locations)} sucursales)")
|
|
|
|
# Construir mapa de device_id a location_id para GetLiveEvents
|
|
device_to_location = {}
|
|
for loc_id, location in client.locations.items():
|
|
if getattr(location, 'security_device_id', None):
|
|
try:
|
|
dev_id = int(location.security_device_id)
|
|
device_to_location[dev_id] = loc_id
|
|
except Exception:
|
|
pass
|
|
|
|
# Obtener SessionID SOAP si está habilitado
|
|
soap_sess_id = None
|
|
if acc['username'] not in soap_disabled_accounts:
|
|
try:
|
|
soap_sess_id = get_soap_session_id(client)
|
|
except Exception as soap_init_err:
|
|
log_msg(f" ⚠️ SOAP no disponible para {acc['label']}: {soap_init_err}")
|
|
soap_disabled_accounts.add(acc['username'])
|
|
|
|
# 1. Agrupar sucursales activas (no excluidas)
|
|
active_locs_with_obj = []
|
|
for loc_id, location in client.locations.items():
|
|
loc_id_str = str(loc_id)
|
|
loc_name_str = location.location_name
|
|
if exclude:
|
|
if any(str(ex).strip() in loc_id_str or str(ex).strip() in loc_name_str for ex in exclude):
|
|
continue
|
|
active_locs_with_obj.append((loc_id, location))
|
|
|
|
# Reconstruir mapa de device_id a location_id (si se requiere)
|
|
device_to_location = {}
|
|
for loc_id, location in active_locs_with_obj:
|
|
if getattr(location, 'security_device_id', None):
|
|
try:
|
|
dev_id = int(location.security_device_id)
|
|
device_to_location[dev_id] = loc_id
|
|
except Exception:
|
|
pass
|
|
|
|
# 2. Ordenar por antigüedad de consulta profunda (last_download_epoch)
|
|
def get_download_epoch(loc_tuple):
|
|
loc_id_str = str(loc_tuple[0])
|
|
prev = last_loc_map.get(loc_id_str, {})
|
|
return prev.get('last_download_epoch', 0)
|
|
|
|
active_locs_with_obj.sort(key=get_download_epoch)
|
|
|
|
# 3. Construir Grupo 0 (Alarmas Activas - SIEMPRE incluidas, sin excepción)
|
|
# Se extraen ANTES de los demás grupos para garantizar presencia en cada ciclo.
|
|
group_0 = []
|
|
group_0_ids = set()
|
|
non_alarm_locs = []
|
|
for loc_id, location in active_locs_with_obj:
|
|
loc_id_str = str(loc_id)
|
|
prev = last_loc_map.get(loc_id_str, {})
|
|
is_triggered = (
|
|
len(prev.get('triggered_partitions', [])) > 0 or
|
|
any(z.get('status') == 'Alarma' for z in prev.get('active_zones', []))
|
|
)
|
|
if is_triggered:
|
|
group_0.append((loc_id, location))
|
|
group_0_ids.add(loc_id)
|
|
log_msg(f" 🚨 Grupo 0 (Alarma): '{location.location_name}' forzado en este ciclo.")
|
|
else:
|
|
non_alarm_locs.append((loc_id, location))
|
|
|
|
# 4. Construir Grupo A (Secuencial - 27 sucursales, solo NO alarmadas)
|
|
group_a = non_alarm_locs[:27]
|
|
group_a_ids = {x[0] for x in group_a}
|
|
|
|
# 5. Construir Grupo B (Alta Frecuencia - Hasta 7 sucursales no alarmadas)
|
|
group_b_pool = []
|
|
for loc_id, location in non_alarm_locs:
|
|
if loc_id in group_a_ids:
|
|
continue
|
|
loc_id_str = str(loc_id)
|
|
prev = last_loc_map.get(loc_id_str, {})
|
|
|
|
status_val = prev.get('status', 0)
|
|
if isinstance(status_val, ArmingState):
|
|
status_val = status_val.value
|
|
|
|
is_disarmed = (status_val == 10200 or str(status_val).upper() == "DISARMED")
|
|
has_faults = len(prev.get('active_zones', [])) > 0
|
|
|
|
if is_disarmed or has_faults:
|
|
severity_weight = 1 if has_faults else 2
|
|
group_b_pool.append((loc_id, location, severity_weight))
|
|
|
|
# Ordenar por severidad primero, luego por antigüedad
|
|
def sort_group_b(item):
|
|
loc_id_str = str(item[0])
|
|
prev = last_loc_map.get(loc_id_str, {})
|
|
return (item[2], prev.get('last_download_epoch', 0))
|
|
|
|
group_b_pool.sort(key=sort_group_b)
|
|
group_b = [(x[0], x[1]) for x in group_b_pool[:7]]
|
|
group_b_ids = {x[0] for x in group_b}
|
|
|
|
# 6. Construir Grupo C (Triggers de SOAP pendientes, excluye alarmadas, A y B)
|
|
group_c = []
|
|
for loc_id, location in active_locs_with_obj:
|
|
if loc_id in group_0_ids or loc_id in group_a_ids or loc_id in group_b_ids:
|
|
continue
|
|
loc_id_str = str(loc_id)
|
|
prev = last_loc_map.get(loc_id_str, {})
|
|
if prev.get('needs_full_update', False):
|
|
group_c.append((loc_id, location))
|
|
|
|
# Lote completo a procesar en este ciclo.
|
|
# group_0 va PRIMERO: sucursales en alarma activa garantizadas en cada ciclo.
|
|
lote_completo = group_0 + group_a + group_b + group_c
|
|
lote_ids = {x[0] for x in lote_completo}
|
|
|
|
loc_list = []
|
|
cache_locations = {}
|
|
full_status_calls = 0
|
|
MAX_FULL_CALLS_PER_CYCLE = 35
|
|
|
|
# Procesar todas las sucursales de la cuenta
|
|
for loc_id, location in client.locations.items():
|
|
loc_id_str = str(loc_id)
|
|
loc_name_str = location.location_name
|
|
if exclude:
|
|
if any(str(ex).strip() in loc_id_str or str(ex).strip() in loc_name_str for ex in exclude):
|
|
continue
|
|
|
|
prev_loc = last_loc_map.get(loc_id_str, {})
|
|
|
|
# Si no está en el lote de este ciclo, re-utilizar la caché de forma instantánea
|
|
if loc_id not in lote_ids:
|
|
status_val = prev_loc.get('status', ArmingState.UNKNOWN)
|
|
loc_list.append({
|
|
"id": loc_id,
|
|
"name": loc_name_str,
|
|
"status": status_val,
|
|
"triggered_partitions": prev_loc.get('triggered_partitions', []),
|
|
"active_zones": prev_loc.get('active_zones', []),
|
|
"last_download_epoch": prev_loc.get('last_download_epoch', 0),
|
|
"last_download_time": prev_loc.get('last_download_time', "Nunca"),
|
|
"last_update_epoch": prev_loc.get('last_update_epoch', 0),
|
|
"last_update_time": prev_loc.get('last_update_time', "Nunca"),
|
|
"needs_full_update": prev_loc.get('needs_full_update', False)
|
|
})
|
|
cache_locations[loc_id_str] = loc_name_str
|
|
continue
|
|
|
|
# Determinar si requiere Poll Full (REST) o Poll Lite (SOAP)
|
|
needs_full_update = prev_loc.get('needs_full_update', False)
|
|
last_dl_epoch = prev_loc.get('last_download_epoch', 0)
|
|
antiguedad_seg = time.time() - last_dl_epoch if last_dl_epoch else 999999
|
|
|
|
reason = None
|
|
if loc_id in group_0_ids:
|
|
# Sucursal con ALARMA ACTIVA: siempre Poll Full, sin importar antigüedad
|
|
reason = "Alarma activa — consulta forzada en cada ciclo"
|
|
elif full_refresh:
|
|
reason = "Refresco total manual solicitado"
|
|
elif not prev_loc:
|
|
reason = "Arranque en frío (sin caché previa)"
|
|
elif needs_full_update:
|
|
reason = "Cambio o evento previo detectado por SOAP"
|
|
elif antiguedad_seg > 600:
|
|
reason = f"Antigüedad de datos superó 10 minutos ({int(antiguedad_seg / 60)} min)"
|
|
|
|
# Limitador de velocidad: degradar a SOAP Lite si superamos el tope
|
|
if reason and full_status_calls >= MAX_FULL_CALLS_PER_CYCLE:
|
|
log_msg(f" ⚠️ Límite de Polls Full por ciclo alcanzado. Degradando '{loc_name_str}' a Poll Lite.")
|
|
reason = None
|
|
|
|
# --- CASO A: POLL FULL (REST fullStatus) ---
|
|
if reason:
|
|
full_status_calls += 1
|
|
log_msg(f" ⏳ Poll FULL en '{loc_name_str}' (Motivo: {reason})")
|
|
current_loading_status = f"Cargando sucursal: {loc_name_str}"
|
|
|
|
try:
|
|
endpoint = make_http_endpoint(f"api/v3/locations/{loc_id}/partitions/fullStatus")
|
|
result = client.http_request(endpoint=endpoint, method="GET")
|
|
client.raise_for_resultcode(result)
|
|
|
|
# Actualizar la librería
|
|
location._update_status(result)
|
|
location._update_partitions(result["PanelStatus"]["Partitions"])
|
|
location._update_zones(result["PanelStatus"]["Zones"])
|
|
|
|
triggered_parts = []
|
|
active_zones = []
|
|
|
|
# Particiones
|
|
for p_id, p in location.partitions.items():
|
|
if p.arming_state.is_triggered():
|
|
triggered_parts.append(p.name or f"Partición {p_id}")
|
|
|
|
# Zonas
|
|
raw_zones = result.get("PanelStatus", {}).get("Zones", [])
|
|
raw_zones_map = {int(z["ZoneID"]): z for z in raw_zones if "ZoneID" in z}
|
|
|
|
for z_id, z in location.zones.items():
|
|
partition_id = z.partition
|
|
partition = location.partitions.get(partition_id)
|
|
is_partition_triggered = partition.arming_state.is_triggered() if partition else location.arming_state.is_triggered()
|
|
|
|
is_alarm_state = z.is_triggered() and is_partition_triggered
|
|
is_fault_state = z.is_faulted()
|
|
|
|
if is_alarm_state or is_fault_state:
|
|
status_lbl = "Alarma" if is_alarm_state else "Abierta/Fallo"
|
|
raw_z = raw_zones_map.get(z_id, {})
|
|
trigger_time = raw_z.get("AlarmTriggerTimeLocalized", "") if is_alarm_state else ""
|
|
|
|
active_zones.append({
|
|
"id": z_id,
|
|
"name": z.description or f"Zona {z_id}",
|
|
"status": status_lbl,
|
|
"partition": z.partition,
|
|
"trigger_time": trigger_time
|
|
})
|
|
|
|
def get_zone_sort_key(z_item):
|
|
is_alarm = 0 if z_item["status"] == "Alarma" else 1
|
|
t_time = z_item["trigger_time"] or "9999-99-99T99:99:99"
|
|
return (is_alarm, t_time)
|
|
|
|
active_zones.sort(key=get_zone_sort_key)
|
|
|
|
effective_status = location.arming_state
|
|
if hasattr(effective_status, 'is_triggered') and effective_status.is_triggered() and not triggered_parts:
|
|
if location.partitions:
|
|
first_partition = next(iter(location.partitions.values()))
|
|
effective_status = first_partition.arming_state
|
|
|
|
now_dt = datetime.datetime.now(ZoneInfo("America/Mexico_City"))
|
|
now_str = now_dt.strftime('%d/%m/%Y %I:%M:%S %p')
|
|
|
|
# Determinar si el estado físico de armado cambió en este Full
|
|
status_changed = False
|
|
prev_status_val = prev_loc.get('status')
|
|
if isinstance(prev_status_val, ArmingState):
|
|
prev_status_val = prev_status_val.value
|
|
curr_status_val = effective_status.value if isinstance(effective_status, ArmingState) else effective_status
|
|
|
|
if prev_status_val != curr_status_val:
|
|
status_changed = True
|
|
|
|
last_up_epoch = time.time() if status_changed else prev_loc.get('last_update_epoch', time.time())
|
|
last_up_time = now_str if status_changed else prev_loc.get('last_update_time', now_str)
|
|
|
|
loc_list.append({
|
|
"id": loc_id,
|
|
"name": loc_name_str,
|
|
"status": effective_status,
|
|
"triggered_partitions": triggered_parts,
|
|
"active_zones": active_zones,
|
|
"last_download_epoch": time.time(),
|
|
"last_download_time": now_str,
|
|
"last_update_epoch": last_up_epoch,
|
|
"last_update_time": last_up_time,
|
|
"needs_full_update": False
|
|
})
|
|
|
|
except Exception as full_err:
|
|
log_msg(f" ❌ Falló Poll FULL en '{loc_name_str}': {full_err}. Usando caché.")
|
|
loc_list.append({
|
|
"id": loc_id,
|
|
"name": loc_name_str,
|
|
"status": prev_loc.get('status', ArmingState.UNKNOWN),
|
|
"triggered_partitions": prev_loc.get('triggered_partitions', []),
|
|
"active_zones": prev_loc.get('active_zones', []),
|
|
"last_download_epoch": prev_loc.get('last_download_epoch', 0),
|
|
"last_download_time": prev_loc.get('last_download_time', "Nunca"),
|
|
"last_update_epoch": prev_loc.get('last_update_epoch', 0),
|
|
"last_update_time": prev_loc.get('last_update_time', "Nunca"),
|
|
"needs_full_update": prev_loc.get('needs_full_update', False)
|
|
})
|
|
|
|
# --- CASO B: POLL LITE (SOAP GetLiveEvents) ---
|
|
else:
|
|
soap_success = False
|
|
if soap_sess_id:
|
|
if not isinstance(last_event_ids.get(acc['username']), dict):
|
|
last_event_ids[acc['username']] = {}
|
|
|
|
curr_last_event_id = last_event_ids[acc['username']].get(str(loc_id), 0)
|
|
|
|
try:
|
|
events_list, next_event_id = soap_get_events(soap_sess_id, curr_last_event_id, loc_id)
|
|
|
|
if curr_last_event_id == 0:
|
|
last_event_ids[acc['username']][str(loc_id)] = next_event_id
|
|
save_last_event_ids(last_event_ids)
|
|
else:
|
|
if events_list:
|
|
log_msg(f" 🔔 SOAP detectó {len(events_list)} eventos nuevos en '{loc_name_str}'.")
|
|
needs_full_update = True
|
|
|
|
now_dt = datetime.datetime.now(ZoneInfo("America/Mexico_City"))
|
|
now_str = now_dt.strftime('%d/%m/%Y %I:%M:%S %p')
|
|
prev_loc['last_update_epoch'] = time.time()
|
|
prev_loc['last_update_time'] = now_str
|
|
|
|
last_event_ids[acc['username']][str(loc_id)] = next_event_id
|
|
save_last_event_ids(last_event_ids)
|
|
|
|
soap_success = True
|
|
|
|
except (ConnectionResetError, OSError):
|
|
# El GUID de sesión SOAP caducó o fue rechazado por Honeywell.
|
|
# Se limpia del caché para que en el siguiente ciclo se obtenga uno nuevo.
|
|
soap_session_ids.pop(acc['username'], None)
|
|
log_msg(f" ⚠️ Sesión SOAP expirada al consultar '{loc_name_str}'. GUID limpiado — se renovará en el siguiente ciclo.")
|
|
except Exception as soap_err:
|
|
log_msg(f" ⚠️ Error de SOAP en '{loc_name_str}': {soap_err}")
|
|
|
|
loc_list.append({
|
|
"id": loc_id,
|
|
"name": loc_name_str,
|
|
"status": prev_loc.get('status', ArmingState.UNKNOWN),
|
|
"triggered_partitions": prev_loc.get('triggered_partitions', []),
|
|
"active_zones": prev_loc.get('active_zones', []),
|
|
"last_download_epoch": prev_loc.get('last_download_epoch', 0),
|
|
"last_download_time": prev_loc.get('last_download_time', "Nunca"),
|
|
"last_update_epoch": prev_loc.get('last_update_epoch', 0),
|
|
"last_update_time": prev_loc.get('last_update_time', "Nunca"),
|
|
"needs_full_update": needs_full_update
|
|
})
|
|
|
|
cache_locations[str(loc_id)] = loc_name_str
|
|
|
|
cache_data[acc['username']] = {
|
|
"label": acc['label'],
|
|
"locations": cache_locations
|
|
}
|
|
|
|
report_data.append({
|
|
"label": acc['label'],
|
|
"username": acc['username'],
|
|
"locations": loc_list
|
|
})
|
|
updated_labels.add(acc['label'])
|
|
log_msg(f" ✓ Procesamiento de {acc['label']} finalizado. (Consultas profundas: {full_status_calls})")
|
|
|
|
except Exception as e:
|
|
log_msg(f" ❌ Error al procesar cuenta {acc['label']}: {e}")
|
|
|
|
cached_acc = cache_data.get(acc['username'])
|
|
if cached_acc:
|
|
log_msg(f" ⚠️ Cargando {len(cached_acc['locations'])} sucursales desde la caché local...")
|
|
loc_list = []
|
|
for loc_id, loc_name in cached_acc['locations'].items():
|
|
loc_id_str = str(loc_id)
|
|
if exclude:
|
|
if any(str(ex).strip() in loc_id_str or str(ex).strip() in loc_name for ex in exclude):
|
|
continue
|
|
|
|
loc_list.append({
|
|
"id": loc_id,
|
|
"name": loc_name,
|
|
"status": "REVOKED",
|
|
"triggered_partitions": [],
|
|
"active_zones": [],
|
|
"last_download_epoch": 0,
|
|
"last_download_time": "Nunca",
|
|
"last_update_epoch": 0,
|
|
"last_update_time": "Nunca",
|
|
"needs_full_update": False
|
|
})
|
|
report_data.append({
|
|
"label": acc['label'],
|
|
"username": acc['username'],
|
|
"locations": loc_list
|
|
})
|
|
updated_labels.add(acc['label'])
|
|
|
|
# Intentar persistir la caché de nombres
|
|
if cache_data:
|
|
try:
|
|
with open(CACHE_FILE, 'w', encoding='utf-8') as f:
|
|
json.dump(cache_data, f, ensure_ascii=False, indent=2)
|
|
except Exception as e:
|
|
print(f" ⚠️ No se pudo guardar la caché de ubicaciones: {e}")
|
|
|
|
# Mezclar los datos de las cuentas que no se actualizaron en esta ejecución
|
|
for label, old_acc_data in last_state_map.items():
|
|
if label not in updated_labels:
|
|
report_data.append(old_acc_data)
|
|
|
|
# Guardar el estado consolidado actual en last_state.json
|
|
serializable_report = []
|
|
for acc_data in report_data:
|
|
serializable_locs = []
|
|
for loc in acc_data['locations']:
|
|
status_val = loc['status'].value if isinstance(loc['status'], ArmingState) else loc['status']
|
|
serializable_locs.append({
|
|
"id": loc['id'],
|
|
"name": loc['name'],
|
|
"status": status_val,
|
|
"triggered_partitions": loc['triggered_partitions'],
|
|
"active_zones": loc['active_zones'],
|
|
"last_download_epoch": loc.get('last_download_epoch', 0),
|
|
"last_download_time": loc.get('last_download_time', "Nunca"),
|
|
"last_update_epoch": loc.get('last_update_epoch', 0),
|
|
"last_update_time": loc.get('last_update_time', "Nunca"),
|
|
"needs_full_update": loc.get('needs_full_update', False)
|
|
})
|
|
serializable_report.append({
|
|
"label": acc_data['label'],
|
|
"username": acc_data['username'],
|
|
"locations": serializable_locs
|
|
})
|
|
|
|
try:
|
|
with open(LAST_STATE_FILE, 'w', encoding='utf-8') as f:
|
|
json.dump(serializable_report, f, ensure_ascii=False, indent=2)
|
|
except Exception as e:
|
|
print(f"⚠️ No se pudo guardar el archivo de estado consolidado: {e}")
|
|
|
|
if report_data:
|
|
generate_html_report(report_data)
|
|
return True
|
|
else:
|
|
print("❌ No se pudieron recuperar datos de ninguna cuenta.")
|
|
return False
|
|
finally:
|
|
current_loading_status = ""
|
|
|
|
|
|
def main():
|
|
# Asegurar que la documentación y plantillas existan en la carpeta de ejecución
|
|
auto_generate_docs()
|
|
|
|
# Cargar credenciales, puerto, exclusiones y opción de navegador
|
|
accounts, port, exclude, open_browser = load_credentials_config()
|
|
|
|
# Si se pasa --once o -o como argumento, corre una sola vez y sale
|
|
if len(sys.argv) > 1 and (sys.argv[1] == '--once' or sys.argv[1] == '-o'):
|
|
run_update(accounts, exclude)
|
|
else:
|
|
# Desactivar navegador si se pasa --no-browser o -nb por CLI
|
|
if "--no-browser" in sys.argv or "-nb" in sys.argv:
|
|
open_browser = False
|
|
|
|
# Modo por defecto: arrancar como servidor HTTP
|
|
# Si se especifica un puerto por parámetro de línea de comandos, se prioriza
|
|
cmd_port = None
|
|
for arg in sys.argv[1:]:
|
|
try:
|
|
cmd_port = int(arg)
|
|
except ValueError:
|
|
pass
|
|
|
|
final_port = cmd_port if cmd_port is not None else port
|
|
run_server(final_port, open_browser)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|