mirror of
https://github.com/cheveguerra/TotalConnect.git
synced 2026-08-19 00:46:37 +00:00
Initial commit: Hybrid polling, interactive log modal and 3-second hard reset
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user