mirror of
https://github.com/cheveguerra/TotalConnect.git
synced 2026-08-19 00:46:37 +00:00
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user