feat(polling): prioridad en Grupo 0 para alarmas y eventos SOAP e indicador de escaneo en vivo (v06.08.01)

This commit is contained in:
cheveguerra
2026-08-02 01:54:02 -06:00
parent 667ceeddc9
commit ba2f9d642c
22 changed files with 476 additions and 31882 deletions
+12
View File
@@ -0,0 +1,12 @@
# Reglas del Proyecto - Colaborando
1. **Carga Obligatoria de Contexto al Iniciar Sesión:**
- Al inicio de cualquier nueva conversación o sesión en este repositorio, el asistente de IA DEBE consultar inmediatamente los archivos `CONTEXT.md` y `.antigravityrules`.
- `CONTEXT.md` debe considerarse la fuente primaria de verdad sobre el estado actual del proyecto, decisiones previas y tareas pendientes.
2. **Verificación de Sincronización (Git vs CONTEXT.md):**
- Al leer `CONTEXT.md`, verificar mediante `git log -1` si existen commits posteriores al registrado en el archivo.
- Si `CONTEXT.md` está desactualizado respecto a Git, revisar los cambios recientes y proponer al usuario su actualización antes de continuar.
3. **Permisos y Modificaciones:**
- Respetar estrictamente las reglas de `.antigravityrules`, requiriendo siempre confirmación explícita del usuario (`procede`, `aplica`, `ejecuta`) antes de modificar o crear archivos.
Executable → Regular
+3 -7
View File
@@ -1,15 +1,11 @@
# Antigravity AI Rules
1. **Permiso explícito obligatorio para realizar modificaciones**:
1. **Permiso explícito obligatorio para realizar consultas**:
- El asistente de IA tiene permitido leer cualquier archivo y ejecutar comandos de lectura/diagnóstico sin necesidad de confirmación previa en el chat.
- El asistente **NUNCA** debe crear, modificar o eliminar ningún archivo (tanto dentro como fuera del espacio de trabajo) ni ejecutar comandos que modifiquen el sistema sin la autorización previa y explícita del usuario en el chat.
- Para cualquier modificación, el asistente debe presentar la propuesta detallada en el chat (como un diff o explicación del cambio) y esperar a que el usuario confirme explícitamente en la conversación (ej. "procede", "aplica", "ejecuta") antes de proceder.
2. **Persistencia de Planes y Tareas en el Proyecto**:
- Para evitar pérdidas de contexto al cambiar o reiniciar sesiones de chat, el asistente siempre debe replicar y guardar copias de sus archivos de planificación, tareas y bitácoras (`implementation_plan.md`, `task.md`, `walkthrough.md`) en la carpeta `doc/` ubicada en la raíz del proyecto.
- Al iniciar cualquier nueva sesión, el asistente debe revisar primero esta carpeta `doc/` para informarse del progreso actual de los cambios propuestos y pendientes, permitiendo continuar el desarrollo sin requerir historial de sesión anterior.
3. **Análisis Técnico Objetivo y Crítico**:
2. **Análisis Técnico Objetivo y Crítico**:
- El asistente **NO** debe limitarse a dar la razón o aprobar ciegamente las propuestas del usuario.
- Ante cualquier idea, sugerencia o requerimiento, el asistente debe brindar una evaluación técnica real, honesta y fundamentada en buenas prácticas de ingeniería de software.
- **Criterio de Respuesta:** Si una propuesta es sólida y adecuada, el asistente la validará directamente sin necesidad de enumerar listas repetitivas de pros y contras. Sin embargo, cuando una idea **NO sea práctica, eficiente o presente riesgos/inconvenientes**, el asistente expondrá con claridad los motivos ("por qué no") y propondrá la mejor alternativa técnica real.
- **Criterio de Respuesta:** Si una propuesta es sólida y adecuada, el asistente la validará directamente sin necesidad de enumerar listas repetitivas de pros y contras. Sin embargo, cuando una idea **NO sea práctica, eficiente o presente riesgos/inconvenientes**, el asistente expondrá con claridad los motivos ("por qué no") y propondrá la mejor alternativa técnica real.
Executable → Regular
View File
+37
View File
@@ -0,0 +1,37 @@
# Estado del Proyecto - TotalConnect 2.0
**Última actualización:** 2026-08-02
**Versión actual:** v06.08.01
---
## 📌 Resumen del Estado Actual
El proyecto **TotalConnect 2.0** es un panel web centralizado de alto rendimiento para el monitoreo de alarmas Honeywell / Resideo Total Connect 2.0.
### 🛠️ Características Principales e Infraestructura
1. **Motor Híbrido Dinámico de Monitoreo (`index.py`)**:
- **Grupo 0**: Prioridad absoluta para sucursales con alarmas activas (particiones, zonas y estado ALARMING) o eventos SOAP pendientes (`needs_full_update = True`).
- **Esquema 2:1 (SOAP + REST)**: 2 consultas ligeras vía SOAP por 1 consulta completa por REST para evitar el *rate-limiting* de Honeywell (máximo 35 consultas REST por ciclo).
- **Resiliencia de Sesión**: Purgado automático de tokens SOAP obsoletos y auto-recuperación ante errores de red (`ConnectionResetError`).
2. **Dashboard Web e Interfaz en Tiempo Real**:
- **Badge de Escaneo en Vivo**: Muestra en tiempo real la sucursal actual en proceso de carga/sondeo (`Cargando sucursal: 6001`).
- **Visor de Bitácora Interactiva**: Modal visual en vivo para consultar las últimas 150 líneas del archivo de log (`activity.log`).
- **Hard Reset**: Mecanismo de purga de caché y reinicio por clic prolongado (3s) en el botón de actualización.
- **Kill-Switch / Polling Toggle**: Control de inicio/pausa de sondeo en segundo plano con *Circuit Breaker* ante errores `401`/`429`.
3. **Portabilidad y Parámetros**:
- Eliminación de rutas absolutas en duro (`Z:\`, `E:\`), operando de manera 100% relativa a la ruta de ejecución actual.
- Binario autónomo `totalconnect.exe` para Windows y soporte para servicio Systemd en Linux.
---
## 🏷️ Historial de Tags / Versiones
* **`v06.08.01`**: Prioridad en Grupo 0 para alarmas y eventos SOAP, indicador de escaneo en vivo en UI, eliminación de rutas absolutas en duro.
---
## 📋 Tareas Pendientes / Próximos Pasos
- [ ] Monitorear estabilidad del motor híbrido en producción tras despliegue de `v06.08.01`.
- [ ] Validar rendimiento en entornos con elevado número de cuentas Total Connect.
Executable → Regular
View File
Binary file not shown.
-25
View File
@@ -1,25 +0,0 @@
import json
from total_connect_client.client import TotalConnectClient
from total_connect_client.const import make_http_endpoint
creds = json.load(open('credentials.json', encoding='utf-8'))['accounts'][0]
client = TotalConnectClient(creds['username'], creds['password'], load_details=False)
client.authenticate()
location_id = 1417027
endpoint = make_http_endpoint(f"api/v3/locations/{location_id}/partitions/fullStatus")
res = client.http_request(endpoint=endpoint, method="GET")
print("Keys at root of fullStatus response:")
print(list(res.keys()))
print("\nValue of ArmingState at root:")
print(res.get("ArmingState"))
print("\nValue of PanelStatus -> ArmingState:")
panel_status = res.get("PanelStatus", {})
print(panel_status.get("ArmingState"))
print("\nPartitions data:")
for p in panel_status.get("Partitions", []):
print(f" PartitionID={p.get('PartitionID')}, ArmingState={p.get('ArmingState')}")
View File
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
View File
Executable → Regular
View File
-517
View File
@@ -1,517 +0,0 @@
import fs from 'fs';
import path from 'path';
import axios from 'axios';
import xml2js from 'xml2js';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CREDENTIALS_FILE = path.join(__dirname, 'credentials.json');
const HTML_OUTPUT_FILE = path.join(__dirname, 'status.html');
const API_URL = 'https://rs.alarmnet.com/TC2API/TC2.asmx';
const APPLICATION_ID = 14588; // App ID estándar de Total Connect 2.0
const APPLICATION_VERSION = '3.0.0';
// Parser de XML configurado para remover prefijos de namespaces (como soap:)
const xmlParser = new xml2js.Parser({
explicitArray: false,
ignoreAttrs: true,
tagNameProcessors: [xml2js.processors.stripPrefix]
});
// Función para parsear XML a objeto JS de manera segura
async function parseXml(xmlString) {
return await xmlParser.parseStringPromise(xmlString);
}
// Inicializar archivo de credenciales de ejemplo si no existe
function initCredentialsFile() {
if (!fs.existsSync(CREDENTIALS_FILE)) {
const template = [
{
label: "Cuenta Principal",
username: "tu_usuario_1",
password: "tu_password_1"
},
{
label: "Cuenta Secundaria",
username: "tu_usuario_2",
password: "tu_password_2"
}
];
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(template, null, 2), 'utf8');
console.log(`⚠️ Se ha creado un archivo de plantilla en: ${CREDENTIALS_FILE}`);
console.log("Por favor edítalo con tus credenciales reales de TotalConnect 2.0 antes de ejecutar.");
process.exit(1);
}
}
// Traducir código de estado de alarma
function parseArmingState(stateCode) {
const code = parseInt(stateCode);
switch (code) {
// Códigos normales de desarmado
case 102:
case 10200:
case 10201: // Listo para armar
case 10203: // Alarma en memoria / aviso
return {
label: "Desarmado",
class: "state-disarmed",
icon: "🟢",
badge: "Listo"
};
case 10202: // No listo para armar (p.ej. sensor abierto)
return {
label: "Desarmado (No Listo / Zona Abierta)",
class: "state-fault",
icon: "🟡",
badge: "Revisar"
};
// Códigos de armado
case 101:
case 10100: // Armado fuera (Away)
return {
label: "Armado Fuera",
class: "state-armed-away",
icon: "🔴",
badge: "Armado (Away)"
};
case 103:
case 10300: // Armado casa (Stay)
return {
label: "Armado En Casa",
class: "state-armed-stay",
icon: "🟠",
badge: "Armado (Stay)"
};
case 104:
case 10400: // Armado noche (Night)
return {
label: "Armado Noche",
class: "state-armed-night",
icon: "🔵",
badge: "Armado (Night)"
};
default:
return {
label: `Estado Desconocido (${stateCode})`,
class: "state-unknown",
icon: "⚪",
badge: "Desconocido"
};
}
}
// 1. Realizar Login y obtener SessionID y lista de Sucursales (Locations)
async function loginAndGetLocations(username, password) {
const soapEnvelope = `<?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>
<LoginAndGetSessionDetails xmlns="https://services.alarmnet.com/TC2/">
<userName>${username}</userName>
<password>${password}</password>
<ApplicationID>${APPLICATION_ID}</ApplicationID>
<ApplicationVersion>${APPLICATION_VERSION}</ApplicationVersion>
<LocaleCode>en-US</LocaleCode>
</LoginAndGetSessionDetails>
</soap:Body>
</soap:Envelope>`;
const response = await axios.post(API_URL, soapEnvelope, {
headers: {
'Content-Type': 'text/xml; charset=utf-8',
'SOAPAction': 'https://services.alarmnet.com/TC2/LoginAndGetSessionDetails'
}
});
console.log("DEBUG RESPONSE:", response.data);
const parsed = await parseXml(response.data);
const result = parsed.Envelope.Body.LoginAndGetSessionDetailsResponse.LoginAndGetSessionDetailsResult;
if (result.ResultCode !== '0') {
throw new Error(`Fallo en autenticación: Código de error ${result.ResultCode} (${result.ResultData || 'Credenciales inválidas'})`);
}
const sessionId = result.SessionID;
// Normalizar la lista de locaciones (si es solo una, xml2js la deja como objeto en vez de array)
let locations = [];
const rawLocations = result.Locations?.LocationInfoBasic;
if (rawLocations) {
locations = Array.isArray(rawLocations) ? rawLocations : [rawLocations];
}
return { sessionId, locations };
}
// 2. Obtener el estatus de un panel específico (GetPanelMetaDataAndFullStatusEx_V2)
async function getPanelStatus(sessionId, locationId) {
const soapEnvelope = `<?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>
<GetPanelMetaDataAndFullStatusEx_V2 xmlns="https://services.alarmnet.com/TC2/">
<SessionID>${sessionId}</SessionID>
<LocationID>${locationId}</LocationID>
<LastSequenceNumber>0</LastSequenceNumber>
<LastUpdatedTimestampTicks>0</LastUpdatedTimestampTicks>
<PartitionID>
<int>1</int>
</PartitionID>
</GetPanelMetaDataAndFullStatusEx_V2>
</soap:Body>
</soap:Envelope>`;
const response = await axios.post(API_URL, soapEnvelope, {
headers: {
'Content-Type': 'text/xml; charset=utf-8',
'SOAPAction': 'https://services.alarmnet.com/TC2/GetPanelMetaDataAndFullStatusEx_V2'
}
});
const parsed = await parseXml(response.data);
const result = parsed.Envelope.Body.GetPanelMetaDataAndFullStatusEx_V2Response.GetPanelMetaDataAndFullStatusEx_V2Result;
if (result.ResultCode !== '0') {
throw new Error(`Fallo al consultar estatus del panel ${locationId}: Código ${result.ResultCode}`);
}
return result.ArmingState;
}
// 3. Generar el reporte HTML estático con diseño premium
function generateHtmlReport(data) {
const timeZone = 'America/Mexico_City';
const timestamp = new Date().toLocaleString('es-MX', {
timeZone,
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true
});
// Construir filas de la tabla de sucursales
let rowsHtml = '';
data.forEach(account => {
account.locations.forEach(loc => {
const state = parseArmingState(loc.status);
rowsHtml += `
<tr>
<td>
<div class="account-badge">${account.label}</div>
<div class="username-sub">${account.username}</div>
</td>
<td class="location-name">
🏢 ${loc.name}
<div class="location-id">ID: ${loc.id}</div>
</td>
<td>
<span class="status-badge ${state.class}">
<span class="status-dot"></span>
${state.label}
</span>
</td>
</tr>`;
});
});
const htmlContent = `<!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-gradient: linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%);
--panel-bg: rgba(30, 41, 59, 0.7);
--border-color: rgba(255, 255, 255, 0.08);
--text-main: #f8fafc;
--text-muted: #94a3b8;
/* Colores de estados */
--disarmed-color: #10b981;
--disarmed-bg: rgba(16, 185, 129, 0.15);
--fault-color: #f59e0b;
--fault-bg: rgba(245, 158, 11, 0.15);
--armed-away-color: #ef4444;
--armed-away-bg: rgba(239, 68, 68, 0.15);
--armed-stay-color: #f97316;
--armed-stay-bg: rgba(249, 115, 22, 0.15);
--armed-night-color: #3b82f6;
--armed-night-bg: rgba(59, 130, 246, 0.15);
}
body {
margin: 0;
padding: 0;
font-family: 'Outfit', sans-serif;
background: var(--bg-gradient);
color: var(--text-main);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.container {
width: 90%;
max-width: 900px;
margin: 40px auto;
background: var(--panel-bg);
backdrop-filter: blur(16px);
border: 1px solid var(--border-color);
border-radius: 24px;
padding: 40px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
}
header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color);
padding-bottom: 24px;
margin-bottom: 32px;
}
h1 {
margin: 0;
font-size: 2.2rem;
font-weight: 800;
background: linear-gradient(to right, #60a5fa, #c084fc);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.5px;
}
.timestamp {
text-align: right;
}
.timestamp-label {
font-size: 0.8rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
}
.timestamp-value {
font-size: 1rem;
font-weight: 600;
color: #c084fc;
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
th {
color: var(--text-muted);
font-weight: 600;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 1px;
padding: 12px 20px;
border-bottom: 1px solid var(--border-color);
}
td {
padding: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
vertical-align: middle;
}
tr:last-child td {
border-bottom: none;
}
.account-badge {
background: rgba(99, 102, 241, 0.15);
color: #a5b4fc;
padding: 4px 10px;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
display: inline-block;
margin-bottom: 4px;
}
.username-sub {
font-size: 0.8rem;
color: var(--text-muted);
}
.location-name {
font-size: 1.1rem;
font-weight: 600;
}
.location-id {
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 4px;
}
/* Estilo de Badges de Estado */
.status-badge {
display: inline-flex;
align-items: center;
padding: 8px 16px;
border-radius: 12px;
font-weight: 600;
font-size: 0.95rem;
gap: 8px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
box-shadow: 0 0 8px currentColor;
}
.state-disarmed {
background: var(--disarmed-bg);
color: var(--disarmed-color);
}
.state-fault {
background: var(--fault-bg);
color: var(--fault-color);
}
.state-armed-away {
background: var(--armed-away-bg);
color: var(--armed-away-color);
}
.state-armed-stay {
background: var(--armed-stay-bg);
color: var(--armed-stay-color);
}
.state-armed-night {
background: var(--armed-night-bg);
color: var(--armed-night-color);
}
.state-unknown {
background: rgba(255, 255, 255, 0.1);
color: var(--text-muted);
}
footer {
margin-top: 40px;
text-align: center;
font-size: 0.85rem;
color: var(--text-muted);
border-top: 1px solid var(--border-color);
padding-top: 20px;
}
/* Animación sutil */
.status-dot {
animation: pulse 2s infinite alternate;
}
@keyframes pulse {
0% { opacity: 0.6; }
100% { opacity: 1; }
}
</style>
</head>
<body>
<div class="container">
<header>
<div>
<h1>TotalConnect 2.0</h1>
<div style="font-size: 0.95rem; color: var(--text-muted); margin-top: 4px;">Monitoreo centralizado de alarmas</div>
</div>
<div class="timestamp">
<div class="timestamp-label">Última Actualización</div>
<div class="timestamp-value">${timestamp}</div>
</div>
</header>
<table>
<thead>
<tr>
<th>Cuenta</th>
<th>Sucursal / Locación</th>
<th>Estatus Alarma</th>
</tr>
</thead>
<tbody>
${rowsHtml}
</tbody>
</table>
<footer>
Generado automáticamente on-demand • TotalConnect API Client
</footer>
</div>
</body>
</html>`;
fs.writeFileSync(HTML_OUTPUT_FILE, htmlContent, 'utf8');
console.log(`\n🎉 Reporte HTML premium generado con éxito en: ${HTML_OUTPUT_FILE}`);
}
// Función principal
async function main() {
console.log("🚀 Iniciando cliente de consulta TotalConnect 2.0...");
initCredentialsFile();
const rawCreds = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
let accounts = JSON.parse(rawCreds);
if (!Array.isArray(accounts) && accounts.accounts) {
accounts = accounts.accounts;
}
const reportData = [];
for (const acc of accounts) {
console.log(`\n👤 Conectando a cuenta: ${acc.label} (${acc.username})...`);
try {
const { sessionId, locations } = await loginAndGetLocations(acc.username, acc.password);
console.log(` ✓ Sesión establecida con éxito.`);
console.log(` ✓ Se encontraron ${locations.length} sucursal(es).`);
const locDataList = [];
for (const loc of locations) {
console.log(` ⏳ Consultando estatus de sucursal: ${loc.LocationName}...`);
const armingState = await getPanelStatus(sessionId, loc.LocationID);
locDataList.push({
id: loc.LocationID,
name: loc.LocationName,
status: armingState
});
}
reportData.push({
label: acc.label,
username: acc.username,
locations: locDataList
});
} catch (err) {
console.error(` ❌ Error procesando cuenta ${acc.label}: ${err.message}`);
}
}
if (reportData.length > 0) {
generateHtmlReport(reportData);
} else {
console.error("❌ No se pudieron recuperar datos de ninguna cuenta.");
}
}
main().catch(err => {
console.error("❌ Error catastrófico en la ejecución principal:", err);
});
Executable → Regular
+316 -130
View File
@@ -98,8 +98,8 @@ Formato B (Formato original de lista - Puerto por defecto 8080):
--------------------------------------------------------------------------------
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
A) Motor Dinámico Híbrido (Lote 35 + 7 por ciclo de 30s):
- Cada 30 segundos, consulta a 35 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
@@ -177,7 +177,7 @@ 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"
APP_VERSION = "06.08.01"
polling_trigger_event = threading.Event()
soap_disabled_accounts = set()
soap_session_ids = {} # dict: username -> GUID SessionId para SOAP GetLiveEvents
@@ -215,27 +215,18 @@ def load_polling_state():
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}")
"""Guarda el estado del polling (activo/pausado) exclusivamente en credentials.json de la ruta actual."""
if os.path.exists(CREDENTIALS_FILE):
try:
with open(CREDENTIALS_FILE, 'r', encoding='utf-8') as f:
creds_data = json.load(f)
if isinstance(creds_data, dict):
creds_data["polling_enabled"] = enabled
with open(CREDENTIALS_FILE, '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: {CREDENTIALS_FILE}")
except Exception as e:
log_msg(f"⚠️ No se pudo actualizar polling_enabled en {CREDENTIALS_FILE}: {e}")
polling_enabled = load_polling_state()
@@ -376,7 +367,7 @@ El archivo `credentials.json` debe colocarse junto al ejecutable o script y sopo
## 🔒 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.
* **Motor Híbrido Dinámico (35 + 7):** En cada ciclo de 30s se procesan 35 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.
@@ -502,24 +493,67 @@ def format_trigger_time(raw_time_str):
return f" @ {raw_time_str}"
def format_short_date(date_str):
"""Convierte fechas del tipo '29/07/2026 06:42:05 PM' a formato corto de 24 horas: '29 Jul 18:42:05'."""
if not date_str or not isinstance(date_str, str):
return "Nunca"
s = date_str.strip()
if "/" in s:
try:
parts = s.split(" ", 1)
date_parts = parts[0].split("/")
if len(date_parts) == 3:
day, month, year = date_parts
MESES_3 = {
"01": "Ene", "02": "Feb", "03": "Mar", "04": "Abr", "05": "May", "06": "Jun",
"07": "Jul", "08": "Ago", "09": "Sep", "10": "Oct", "11": "Nov", "12": "Dic"
}
m_short = MESES_3.get(month.zfill(2), month)
time_part = parts[1] if len(parts) > 1 else ""
# Convertir hora AM/PM a formato de 24 horas
if time_part:
time_upper = time_part.upper()
is_pm = "PM" in time_upper
is_am = "AM" in time_upper
clean_time = time_upper.replace("AM", "").replace("PM", "").strip()
time_subparts = clean_time.split(":")
if len(time_subparts) >= 2:
hh = int(time_subparts[0])
mm = time_subparts[1]
ss = time_subparts[2] if len(time_subparts) > 2 else "00"
if is_pm and hh < 12:
hh += 12
elif is_am and hh == 12:
hh = 0
time_part = f"{hh:02d}:{mm}:{ss}"
else:
time_part = clean_time
return f"{int(day)} {m_short} {time_part}".strip()
except Exception:
pass
return s
def get_account_badge_style(label):
"""Devuelve un estilo distintivo para el badge de cada cuenta (solo el cuadrito)."""
"""Devuelve un estilo distintivo de alto contraste para el badge de cada cuenta (cuadrito)."""
clean = str(label).strip().lower()
# Paleta específica para cuentas conocidas
# Paleta específica de alto contraste para cuentas conocidas
if "2020" in clean:
# Azul Real / Índigo Elegante
return 'style="background: #eff6ff; color: #1d4ed8; border: 1px solid #bfdbfe;"'
# Azul Índigo Intenso
return 'style="background: #dbeafe; color: #1e40af; border: 1.5px solid #3b82f6; font-weight: 700; padding: 3px 9px; border-radius: 6px; font-size: 0.75rem;"'
elif "2019" in clean:
# Morado / Violeta Elegante
return 'style="background: #faf5ff; color: #7e22ce; border: 1px solid #e9d5ff;"'
# Morado Púrpura Intenso
return 'style="background: #f3e8ff; color: #6b21a8; border: 1.5px solid #a855f7; font-weight: 700; padding: 3px 9px; border-radius: 6px; font-size: 0.75rem;"'
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
'style="background: #dcfce7; color: #15803d; border: 1.5px solid #22c55e; font-weight: 700; padding: 3px 9px; border-radius: 6px; font-size: 0.75rem;"', # Verde
'style="background: #ffedd5; color: #c2410c; border: 1.5px solid #f97316; font-weight: 700; padding: 3px 9px; border-radius: 6px; font-size: 0.75rem;"', # Naranja
'style="background: #cffafe; color: #0e7490; border: 1.5px solid #06b6d4; font-weight: 700; padding: 3px 9px; border-radius: 6px; font-size: 0.75rem;"', # Cían
'style="background: #fce7f3; color: #be185d; border: 1.5px solid #ec4899; font-weight: 700; padding: 3px 9px; border-radius: 6px; font-size: 0.75rem;"', # Rosa
]
idx = sum(ord(c) for c in clean) % len(PALETTES)
return PALETTES[idx]
@@ -527,9 +561,9 @@ def get_account_badge_style(label):
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":
if loc_item.get('is_revoked') or (isinstance(loc_item.get('status'), str) and loc_item.get('status') == "REVOKED"):
return 3
state = loc_item['status']
if isinstance(state, int):
try:
state = ArmingState(state)
@@ -566,15 +600,18 @@ def generate_html_report(data):
all_locations = []
for account in data:
for loc in account['locations']:
is_rev = loc.get('is_revoked', False) or (isinstance(loc.get('status'), str) and loc.get('status') == "REVOKED")
all_locations.append({
"account_label": account['label'],
"id": loc['id'],
"name": loc['name'],
"status": loc['status'],
"is_revoked": is_rev,
"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')
"last_update_time": loc.get('last_update_time'),
"needs_full_update": loc.get('needs_full_update', False)
})
# Ordenar prioritariamente:
@@ -637,11 +674,11 @@ def generate_html_report(data):
</div>"""
card_class = "branch-card"
if isinstance(loc['status'], str) and loc['status'] == "REVOKED":
if loc.get("is_revoked"):
card_class += " branch-card-disabled"
dl_time = loc.get('last_download_time') or 'Nunca'
up_time = loc.get('last_update_time') or 'Nunca'
dl_time = format_short_date(loc.get('last_download_time'))
up_time = format_short_date(loc.get('last_update_time'))
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>
@@ -649,14 +686,27 @@ def generate_html_report(data):
</div>
"""
sync_tag = ""
if loc.get("needs_full_update"):
sync_tag = '<span style="font-size: 0.65rem; color: #d97706; font-weight: 600; background: #fef3c7; border: 1px solid #fde68a; padding: 2px 6px; border-radius: 4px; display: inline-flex; align-items: center; gap: 3px;" title="Cambio detectado por SOAP. Sincronización profunda programada en el siguiente ciclo.">⚡ (Actualización programada)</span>'
revoked_tag = ""
if loc.get("is_revoked"):
revoked_tag = '<span style="font-size: 0.65rem; color: #dc2626; font-weight: 700; background: #fef2f2; border: 1px solid #fca5a5; padding: 2px 6px; border-radius: 4px; display: inline-flex; align-items: center; gap: 3px;" title="La cuenta perdió la conexión o fue revocada por Honeywell. Mostrando último estado conocido.">⚠️ (Cuenta Desconectada)</span>'
acc_style = get_account_badge_style(loc['account_label'])
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 class="branch-header" style="display: flex; flex-wrap: wrap; gap: 6px; align-items: center; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 6px; flex-wrap: wrap;">
<span class="status-badge {state['class']}">
<span class="status-dot"></span>
{state['label']}
</span>
{sync_tag}
{revoked_tag}
</div>
<span class="account-badge" {acc_style}>{loc['account_label']}</span>
</div>
<div class="branch-name" title="{loc['name']}">🏢 {loc['name']}</div>
{details_html}
@@ -1190,7 +1240,13 @@ def generate_html_report(data):
<header>
<div>
<h1 title="v{APP_VERSION}" style="cursor: help;">TotalConnect 2.0</h1>
<div class="header-subtitle">Monitoreo centralizado de alarmas</div>
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 2px;">
<div class="header-subtitle" style="margin-top: 0;">Monitoreo centralizado de alarmas</div>
<span id="live-scan-badge" style="display: inline-flex; align-items: center; gap: 5px; font-size: 0.72rem; font-weight: 600; color: #475569; background: #f8fafc; border: 1px solid #e2e8f0; padding: 2px 8px; border-radius: 12px; transition: all 0.3s;" title="Sucursal siendo escaneada en este instante por el motor de polling">
<span id="live-scan-dot" style="width: 6px; height: 6px; border-radius: 50%; background: #10b981; display: inline-block; box-shadow: 0 0 5px #10b981;"></span>
<span id="live-scan-text">Escaneo en vivo: En espera</span>
</span>
</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;">
@@ -1402,6 +1458,46 @@ def generate_html_report(data):
}}
}});
// Monitor en tiempo real de la sucursal siendo escaneada (Polling corto ultra-limpio)
let liveScanTimer = null;
async function checkLiveScanStatus() {{
if (document.hidden) {{
liveScanTimer = setTimeout(checkLiveScanStatus, 3000);
return;
}}
let statusUrl = '/current-loading';
if (window.location.protocol === 'file:') {{
if (window.location.pathname.indexOf(':') !== -1) {{
statusUrl = 'http://localhost:8080/current-loading';
}} else {{
statusUrl = 'http://192.168.100.50:8080/current-loading';
}}
}}
try {{
const res = await fetch(statusUrl);
const data = await res.json();
const txtEl = document.getElementById('live-scan-text');
const dotEl = document.getElementById('live-scan-dot');
if (txtEl && data.status) {{
if (data.status.trim() !== '') {{
txtEl.textContent = data.status;
if (dotEl) {{
dotEl.style.background = '#2563eb';
dotEl.style.boxShadow = '0 0 6px #2563eb';
}}
}} else {{
txtEl.textContent = 'Escaneo en vivo: En espera';
if (dotEl) {{
dotEl.style.background = '#10b981';
dotEl.style.boxShadow = '0 0 5px #10b981';
}}
}}
}}
}} catch (e) {{}}
liveScanTimer = setTimeout(checkLiveScanStatus, 2000);
}}
checkLiveScanStatus();
async function fetchLogContent() {{
const terminal = document.getElementById('log-terminal-box');
if (!terminal) return;
@@ -1916,6 +2012,8 @@ def soap_get_events(session_id, last_event_id, location_id=0):
loc_id_val = None
class_id_val = None
type_id_val = None
event_text_val = ""
originator_val = ""
for child in elem:
tag_local = child.tag.split('}')[-1].lower() if '}' in child.tag else child.tag.lower()
@@ -1939,13 +2037,19 @@ def soap_get_events(session_id, last_event_id, location_id=0):
type_id_val = int(child.text)
except (ValueError, TypeError):
pass
elif tag_local in ('event', 'eventtext', 'description'):
event_text_val = child.text or ""
elif tag_local in ('originator', 'user'):
originator_val = child.text or ""
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
'type_id': type_id_val if type_id_val is not None else 0,
'event_text': event_text_val,
'originator': originator_val
})
if event_id_val > max_event_id:
max_event_id = event_id_val
@@ -1992,6 +2096,15 @@ def get_soap_session_id(client, force_refresh=False):
return None
def invalidate_account_session(username):
"""Purga la sesión del cliente en memoria para forzar una re-autenticación limpia desde cero."""
with client_pool_lock:
c = client_pool.pop(username, None)
if c:
setattr(c, '_logged_in', False)
soap_session_ids.pop(username, 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
@@ -2000,7 +2113,10 @@ def get_authenticated_client(acc, force_reauth=False):
with client_pool_lock:
if force_reauth:
client_pool.pop(username, None)
c = client_pool.pop(username, None)
if c:
setattr(c, '_logged_in', False)
soap_session_ids.pop(username, None)
client = client_pool.get(username)
if client and getattr(client, '_logged_in', False):
@@ -2093,7 +2209,6 @@ def run_update(accounts=None, exclude=None, full_refresh=False):
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:
@@ -2131,17 +2246,7 @@ def run_update(accounts=None, exclude=None, full_refresh=False):
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)
# 2. Ordenar por antigüedad de consulta (last_download_epoch) - Oldest First
def get_download_epoch(loc_tuple):
loc_id_str = str(loc_tuple[0])
prev = last_loc_map.get(loc_id_str, {})
@@ -2149,30 +2254,48 @@ def run_update(accounts=None, exclude=None, full_refresh=False):
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.
# 3. Construir Grupo 0 (Alarmas Activas y Triggers SOAP - SIEMPRE incluidas obligatoriamente)
# Se extraen ANTES de los demás grupos para garantizar presencia y prioridad 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 = (
status_val = prev.get('status')
if isinstance(status_val, int):
try:
status_val = ArmingState(status_val)
except Exception:
pass
status_is_alarm = False
if hasattr(status_val, 'is_triggered') and status_val.is_triggered():
status_is_alarm = True
elif any(k in str(status_val).upper() for k in ("ALARM", "TRIGGERED")):
status_is_alarm = True
is_alarm = (
status_is_alarm or
len(prev.get('triggered_partitions', [])) > 0 or
any(z.get('status') == 'Alarma' for z in prev.get('active_zones', []))
)
if is_triggered:
has_soap_trigger = prev.get('needs_full_update', False)
if is_alarm or has_soap_trigger:
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.")
reason_label = "Alarma" if is_alarm else "Evento SOAP"
log_msg(f" 🚨 Grupo 0 ({reason_label}): '{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]
# 4. Grupo A: Secuencial (35 sucursales más antiguas, no alarmadas)
group_a = non_alarm_locs[:35]
group_a_ids = {x[0] for x in group_a}
# 5. Construir Grupo B (Alta Frecuencia - Hasta 7 sucursales no alarmadas)
# 5. Grupo B: Alta Frecuencia (hasta 7 sucursales no alarmadas desarmadas/fallos)
group_b_pool = []
for loc_id, location in non_alarm_locs:
if loc_id in group_a_ids:
@@ -2191,7 +2314,6 @@ def run_update(accounts=None, exclude=None, full_refresh=False):
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, {})
@@ -2201,27 +2323,24 @@ def run_update(accounts=None, exclude=None, full_refresh=False):
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)
# 6. Grupo C (Triggers de SOAP pendientes ya cubiertos con prioridad en Grupo 0)
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
# En refresco manual (full_refresh=True), se procesa el 100% de las sucursales de la cuenta.
# En polling automático en segundo plano, se procesa el lote rotativo (Grupo 0 + A + B + C).
if full_refresh:
lote_completo = active_locs_with_obj
else:
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
MAX_FULL_CALLS_PER_CYCLE = 45
# Procesar todas las sucursales de la cuenta
# Procesar sucursales de la cuenta
for loc_id, location in client.locations.items():
loc_id_str = str(loc_id)
loc_name_str = location.location_name
@@ -2244,46 +2363,51 @@ def run_update(accounts=None, exclude=None, full_refresh=False):
"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)
"needs_full_update": prev_loc.get('needs_full_update', False),
"lite_count": prev_loc.get('lite_count', 0)
})
cache_locations[loc_id_str] = loc_name_str
continue
# Determinar si requiere Poll Full (REST) o Poll Lite (SOAP)
# Evaluador de motivo para Poll FULL
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
curr_lite_count = prev_loc.get('lite_count', 0)
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:
elif curr_lite_count >= 3:
reason = f"Límite de Polls Lite alcanzado ({curr_lite_count} lites previos) → Full preventivo"
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:
# Limitador de velocidad: degradar a SOAP Lite si superamos el tope (solo en polling automático)
max_full_limit = 999999 if full_refresh else MAX_FULL_CALLS_PER_CYCLE
if reason and full_status_calls >= max_full_limit:
log_msg(f" ⚠️ Límite de Polls Full por ciclo alcanzado. Degradando '{loc_name_str}' a Poll Lite.")
reason = None
now_dt = datetime.datetime.now(ZoneInfo("America/Mexico_City"))
now_str = now_dt.strftime('%d/%m/%Y %I:%M:%S %p')
# --- 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}"
loc_num = loc_name_str.split(' ')[0] if ' ' in loc_name_str else loc_name_str
current_loading_status = f"Cargando sucursal: {loc_num}"
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"])
@@ -2291,12 +2415,10 @@ def run_update(accounts=None, exclude=None, full_refresh=False):
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}
@@ -2334,10 +2456,6 @@ def run_update(accounts=None, exclude=None, full_refresh=False):
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):
@@ -2360,11 +2478,30 @@ def run_update(accounts=None, exclude=None, full_refresh=False):
"last_download_time": now_str,
"last_update_epoch": last_up_epoch,
"last_update_time": last_up_time,
"needs_full_update": False
"needs_full_update": False,
"lite_count": 0 # Resetea contador tras Full
})
except Exception as full_err:
log_msg(f" ❌ Falló Poll FULL en '{loc_name_str}': {full_err}. Usando caché.")
err_str = str(full_err).lower()
log_msg(f" ❌ Falló Poll FULL en '{loc_name_str}': {full_err}.")
# Si el error es de sesión o autenticación, purgar sesión e intentar re-autenticar inmediatamente
if any(k in err_str for k in ("401", "invalid session", "unauthorized", "token", "connectionreset", "login")):
log_msg(f" ⚠️ Sesión revocada/expirada detectada en '{loc_name_str}'. Purgando sesión e intentando re-autenticación...")
invalidate_account_session(acc['username'])
try:
# Intentar re-autenticación limpia inmediata
client = get_authenticated_client(acc, force_reauth=True)
log_msg(f" ✓ Re-autenticación exitosa. Reintentando consulta para '{loc_name_str}'...")
location.get_panel_meta_data()
except Exception as reauth_err:
log_msg(f" ❌ Re-autenticación rechazada para {acc['label']}: {reauth_err}. Abortando cuenta para marcar como REVOKED.")
# La cuenta fue revocada: lanzar excepción para ir al handler general que marca todas como REVOKED
raise Exception(f"Cuenta revocada o credenciales inválidas: {reauth_err}")
else:
log_msg(f" ⚠️ Usando caché local para '{loc_name_str}'.")
loc_list.append({
"id": loc_id,
"name": loc_name_str,
@@ -2375,12 +2512,19 @@ def run_update(accounts=None, exclude=None, full_refresh=False):
"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)
"needs_full_update": prev_loc.get('needs_full_update', False),
"lite_count": prev_loc.get('lite_count', 0)
})
# --- CASO B: POLL LITE (SOAP GetLiveEvents) ---
# --- CASO B: POLL LITE (SOAP GetLiveEvents) ---
else:
loc_num = loc_name_str.split(' ')[0] if ' ' in loc_name_str else loc_name_str
current_loading_status = f"Cargando sucursal: {loc_num}"
soap_success = False
new_lite_count = curr_lite_count + 1
last_up_epoch = prev_loc.get('last_update_epoch', 0)
last_up_time = prev_loc.get('last_update_time', "Nunca")
if soap_sess_id:
if not isinstance(last_event_ids.get(acc['username']), dict):
last_event_ids[acc['username']] = {}
@@ -2393,15 +2537,39 @@ def run_update(accounts=None, exclude=None, full_refresh=False):
if curr_last_event_id == 0:
last_event_ids[acc['username']][str(loc_id)] = next_event_id
save_last_event_ids(last_event_ids)
log_msg(f" 🔹 Poll LITE (SOAP) en '{loc_name_str}' → Inicializado (ID: {next_event_id})")
else:
if events_list:
log_msg(f" 🔔 SOAP detectó {len(events_list)} eventos nuevos en '{loc_name_str}'.")
latest_ev = events_list[0]
ev_desc = latest_ev.get('event_text', '')
ev_type = latest_ev.get('type_id', 0)
log_msg(f" 🔔 SOAP detectó {len(events_list)} evento(s) en '{loc_name_str}'. Primer cambio: '{ev_desc}'")
# Deducir estado estimado a partir del evento SOAP para actualización rápida instantánea
est_status = None
desc_upper = ev_desc.upper()
if ev_type in (10200, 10226) or "DISARMED" in desc_upper:
est_status = ArmingState.DISARMED
elif ev_type in (10201, 10205) or "ARMED AWAY" in desc_upper:
est_status = ArmingState.ARMED_AWAY
elif ev_type == 10203 or "ARMED STAY" in desc_upper:
est_status = ArmingState.ARMED_STAY
elif ev_type == 10207 or "ALARM" in desc_upper:
est_status = ArmingState.ALARMING
if est_status:
prev_loc['status'] = est_status
needs_full_update = True
new_lite_count = 0 # Forzará Full en la siguiente vuelta
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_up_epoch = time.time()
last_up_time = now_str
else:
log_msg(f" 🔹 Poll LITE (SOAP) en '{loc_name_str}' → Sin eventos nuevos (OK)")
last_event_ids[acc['username']][str(loc_id)] = next_event_id
save_last_event_ids(last_event_ids)
@@ -2409,8 +2577,6 @@ def run_update(accounts=None, exclude=None, full_refresh=False):
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:
@@ -2422,11 +2588,12 @@ def run_update(accounts=None, exclude=None, full_refresh=False):
"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
"last_download_epoch": time.time(), # Actualiza timestamp para rotación unificada
"last_download_time": now_str,
"last_update_epoch": last_up_epoch,
"last_update_time": last_up_time,
"needs_full_update": needs_full_update,
"lite_count": new_lite_count
})
cache_locations[str(loc_id)] = loc_name_str
@@ -2447,27 +2614,44 @@ def run_update(accounts=None, exclude=None, full_refresh=False):
except Exception as e:
log_msg(f" ❌ Error al procesar cuenta {acc['label']}: {e}")
locs_to_revoke = []
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 = []
if cached_acc and cached_acc.get('locations'):
for loc_id, loc_name in cached_acc['locations'].items():
locs_to_revoke.append((loc_id, loc_name))
elif acc['label'] in last_state_map:
old_acc = last_state_map[acc['label']]
for loc in old_acc.get('locations', []):
locs_to_revoke.append((loc['id'], loc['name']))
if locs_to_revoke:
log_msg(f" ⚠️ Marcando {len(locs_to_revoke)} sucursales de {acc['label']} como REVOKED en el tablero (conservando último estado conocido)...")
loc_list = []
for loc_id, loc_name in locs_to_revoke:
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
prev_loc = last_loc_map.get(loc_id_str, {})
prev_status = prev_loc.get('status', ArmingState.UNKNOWN)
if prev_status == "REVOKED":
prev_status = ArmingState.UNKNOWN
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
"status": prev_status,
"is_revoked": True,
"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', "Desconectado"),
"last_update_epoch": prev_loc.get('last_update_epoch', 0),
"last_update_time": prev_loc.get('last_update_time', "Desconectado"),
"needs_full_update": False,
"lite_count": 0
})
report_data.append({
"label": acc['label'],
@@ -2485,13 +2669,14 @@ def run_update(accounts=None, exclude=None, full_refresh=False):
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)
final_report = list(report_data)
for acc_label, old_acc_data in last_state_map.items():
if acc_label not in updated_labels:
final_report.append(old_acc_data)
# Guardar el estado consolidado actual en last_state.json
serializable_report = []
for acc_data in report_data:
for acc_data in final_report:
serializable_locs = []
for loc in acc_data['locations']:
status_val = loc['status'].value if isinstance(loc['status'], ArmingState) else loc['status']
@@ -2499,13 +2684,15 @@ def run_update(accounts=None, exclude=None, full_refresh=False):
"id": loc['id'],
"name": loc['name'],
"status": status_val,
"is_revoked": loc.get('is_revoked', False),
"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)
"needs_full_update": loc.get('needs_full_update', False),
"lite_count": loc.get('lite_count', 0)
})
serializable_report.append({
"label": acc_data['label'],
@@ -2528,7 +2715,6 @@ def run_update(accounts=None, exclude=None, full_refresh=False):
finally:
current_loading_status = ""
def main():
# Asegurar que la documentación y plantillas existan en la carpeta de ejecución
auto_generate_docs()
-379
View File
@@ -1,379 +0,0 @@
{
"name": "totalconnect",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "totalconnect",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"axios": "^1.18.1",
"xml2js": "^0.6.2"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
"integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
}
}
}
-17
View File
@@ -1,17 +0,0 @@
{
"name": "totalconnect",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"axios": "^1.18.1",
"xml2js": "^0.6.2"
}
}
-19
View File
@@ -1,19 +0,0 @@
import json
import os
state_file = 'last_state.json'
if os.path.exists(state_file):
with open(state_file, 'r', encoding='utf-8') as f:
data = json.load(f)
print("Content of last_state.json:")
for acc in data:
print(f"\nAccount: {acc.get('label')} ({acc.get('username')})")
for loc in acc.get('locations', []):
loc_id = loc.get('id')
loc_name = loc.get('name')
if '0181' in loc_name or loc_id == 1417027:
print(f" 🏢 MATCH: ID={loc_id}, Name='{loc_name}', Status={loc.get('status')}")
print(f" Triggered Partitions: {loc.get('triggered_partitions')}")
print(f" Active Zones: {loc.get('active_zones')}")
else:
print("last_state.json does not exist!")
BIN
View File
Binary file not shown.
+58
View File
@@ -0,0 +1,58 @@
No te preocupes por el formato del bloque de código. Aquí tienes el reporte detallado escrito directamente en texto plano estructurado, fácil de leer y sin riesgo de que se desborde o se corte:
REPORTE TÉCNICO: ARQUITECTURA E IMPLEMENTACIÓN DEL MOTOR HÍBRIDO DE MONITOREO (TOTALCONNECT 2.0)
Este documento detalla las optimizaciones, razones de diseño y componentes implementados en el núcleo del sistema de monitoreo (index.py) para garantizar estabilidad, prevención de bloqueos (rate-limits) y eficiencia en el uso de la API de Honeywell.
1. Resumen Ejecutivo de Cambios
Se rediseñó el ciclo de consulta en segundo plano para transicionar de un modelo lineal pesado a un motor híbrido dinámico (Poll Lite SOAP + Poll Full REST con control de rotación y contadores 2:1). Además, se dotó al panel web de un visor de bitácoras interactivo en vivo y de un mecanismo de recuperación de fallos (Hard Reset).
2. Detalle de Cambios Implementados y Razones Técnicas
A. Motor Híbrido Dinámico (Lotes Optimizados y Grupos de Prioridad)
Implementación: Se dividió el procesamiento de las sucursales en cuatro grupos lógicos dentro de cada ciclo de 30 segundos: Grupo 0 (Alarma Activa con prioridad absoluta y forzada al 100%), Grupo A (Turno Secuencial Ordinario), Grupo B (Alta Frecuencia para desarmadas/fallos) y Grupo C (Triggers SOAP).
Razón: Las llamadas REST (fullStatus) consultan el panel físico y tardan ~1.1 segundos. Consultar las 285 sucursales de golpe en cada ciclo saturaría la red y provocaría un bloqueo por rate-limiting en Honeywell. Este sistema por grupos prioriza las emergencias y distribuye la carga inteligentemente.
B. Ciclo 2:1 (Poll LITE por SOAP y Poll FULL Preventivo)
Implementación: Se introdujo un contador interno (lite_count) por sucursal persistido en la base de datos de estado (last_state.json). Cuando una sucursal es revisada mediante un Poll LITE (SOAP) rápido (~200ms), el contador se incrementa (+1). Si alcanza 3 Lites consecutivos, el sistema fuerza un Poll FULL (REST) preventivo de mantenimiento y reinicia el contador a 0. Si SOAP detecta un cambio de estado en vivo, se fuerza un Full inmediato en la siguiente vuelta.
Razón: El uso exclusivo de Poll Full estancaba la rotación o saturaba las peticiones. Combinar 2 revisiones ligeras por SOAP y 1 profunda por REST reduce en más de un 60% las consultas pesadas a Honeywell manteniendo la precisión intacta.
C. Sincronización y Unificación de Timestamps de Rotación (last_download_epoch)
Implementación: Tanto en los bloques de Poll FULL como de Poll LITE (SOAP), se actualiza el registro de tiempo de verificación a la hora actual (time.time() y now_str).
Razón: En la arquitectura preliminar, los Polls Lite no actualizaban el reloj de antigüedad, lo que provocaba que las mismas sucursales se quedaran atoradas al frente de la cola del Round-Robin de forma infinita. Al actualizar el timestamp en ambas modalidades, la sucursal avanza de manera fluida al final de la fila tras cada chequeo, permitiendo una rotación limpia del catálogo completo.
D. Resilencia ante Caducidad de Sesiones SOAP (ConnectionResetError / OSError)
Implementación: Se añadió un manejador de excepciones específico en las peticiones SOAP para capturar la expiración del token de sesión (GUID) de AlarmNet. Al detectarlo, se ejecuta un .pop() para purgar el token obsoleto de la memoria en caliente, forzando a que el siguiente ciclo solicite uno totalmente fresco.
Razón: Los identificadores de sesión SOAP caducan de forma impredecible en los servidores de Honeywell. Sin esta limpieza automática, el script se quedaba intentando reutilizar el mismo token muerto indefinidamente.
E. Visor de Logs Interactivo en el Dashboard (/activity-log)
Implementación: Se añadió un botón en la cabecera del panel web ("Ver Log") que despliega una ventana modal con terminal oscura, conectada al endpoint backend /activity-log, el cual devuelve de forma limpia las últimas 150 líneas del archivo de actividad (activity.log).
Razón: Facilita la auditoría en tiempo real del comportamiento del servidor, el estado de los polls y la detección de eventos sin necesidad de conectarse por SSH al servidor o revisar archivos de texto manualmente.
F. Autogestión y Límite de Tamaño de Logs (activity.log)
Implementación: Se programó una función de rotación automática en caliente dentro del método de escritura log_msg().
Razón: Cada vez que el log de actividad de la aplicación supera los 5 MB, el sistema lo renombra automáticamente a activity.log.1 (preservando un respaldo único) y abre un archivo limpio, previniendo que el almacenamiento de la VM o del servidor colapse por acumulación desatendida.
G. Mecanismo de Hard Reset (Reinicio de Fábrica por Clic Largo)
Implementación: Se programó una interacción táctil y de ratón en el botón "Actualizar" de la interfaz con un retardo visual de 1 segundo (el indicador rojo de advertencia aparece únicamente tras cruzar los primeros 1000 ms de presión continua). Si se mantiene presionado por 3.0 segundos o más, borra físicamente del disco los archivos temporales (last_state.json, locations_cache.json, last_event_ids.json), vacía el caché de clientes en memoria y reinicia el escaneo de cero tras una confirmación de seguridad.
Razón: Permite resolver de forma limpia y directa cualquier inconsistencia severa de datos o corrupción temporal de caché sin requerir intervención técnica avanzada por comandos.
H. Protección de Espacio en server.log (Opción 1 Implementada)
Implementación: Se modificó la redirección del comando de arranque en segundo plano en Linux (nohup ... > /dev/null 2> server.log &) y se sobreescribió el método interno log_message del servidor HTTP para silenciar las peticiones secundarias de red repetitivas.
Razón: Evita que el archivo server.log crezca gigabytes con peticiones HTTP ordinarias o duplicados de consola, manteniéndolo en 0 bytes y reservado exclusivamente para atrapar trazas de error (tracebacks de excepciones críticas) si el sistema llegara a fallar.
+50
View File
@@ -0,0 +1,50 @@
@echo off
echo =======================================================
echo Actualizando Repositorio Git y creando Tag v06.08.01
echo =======================================================
git add .
git commit -m "feat(polling): prioridad en Grupo 0 para alarmas y eventos SOAP e indicador de escaneo en vivo (v06.08.01)" -m "- Logica de Grupo 0:^
* Se unificaron las alarmas activas (particiones, zonas y estado ALARMING) y los eventos pendientes de SOAP (needs_full_update = True) dentro del Grupo 0.^
* Garantiza ejecucion al inicio del ciclo de polling en las primeras posiciones, evitando la degradacion por el limite de 35 consultas rutinarias.^
* Mantiene auto-limpieza dinamica: al recibir confirmacion de restablecimiento via REST, la sucursal sale del Grupo 0 automaticamente.^
^
- UI & Monitor en Tiempo Real:^
* Agregado badge visual de escaneo en vivo en el encabezado del tablero.^
* Muestra la sucursal actual en formato compacto (Cargando sucursal: 6001).^
* Implementacion en JS usando setTimeout recursivo, manipulacion directa de textContent y suspension en pestanas inactivas (Page Visibility API).^
^
- Correcciones de Infraestructura:^
* Se eliminaron rutas absolutas en duro (Z:\, E:\) en save_polling_state().^
* La configuracion del estado del polling ahora escribe exclusivamente en credentials.json de la ruta de ejecucion actual.^
* Actualizada version a 06.08.01."
if %ERRORLEVEL% NEQ 0 (
echo Error al realizar el commit o no hay cambios por commitear.
)
echo Creando Tag v06.08.01...
git tag -a v06.08.01 -m "Version 06.08.01 - Prioridad Grupo 0 e indicador de escaneo"
echo Subiendo cambios y tag a GitHub...
git push origin main
git push origin v06.08.01
echo =======================================================
echo Proceso completado exitosamente.
echo =======================================================
pause
-30788
View File
File diff suppressed because it is too large Load Diff