Initial commit: Hybrid polling, interactive log modal and 3-second hard reset

This commit is contained in:
cheveguerra
2026-07-24 15:31:56 +00:00
commit 7808501bf4
16 changed files with 34739 additions and 0 deletions
Executable
+517
View File
@@ -0,0 +1,517 @@
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);
});