mirror of
https://github.com/cheveguerra/TotalConnect.git
synced 2026-08-19 00:46:37 +00:00
518 lines
16 KiB
JavaScript
Executable File
518 lines
16 KiB
JavaScript
Executable File
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);
|
|
});
|