gigafibre-fsm/services/modem-bridge/lib/tplink-session.js
louispaulb 41d9b5f316 feat: flow editor, Gemini QR scanner with offline queue, dispatch planning v2
Major additions accumulated over 9 days — single commit per request.

Flow editor (new):
- Generic visual editor for step trees, usable by project wizard + agent flows
- PROJECT_KINDS / AGENT_KINDS catalogs decouple UI from domain
- Drag-and-drop reorder via vuedraggable with scope isolation per peer group
- Chain-aware depends_on rewrite on reorder (sequential only — DAGs preserved)
- Variable picker with per-applies_to catalog (Customer / Quotation /
  Service Contract / Issue / Subscription), insert + copy-clipboard modes
- trigger_condition helper with domain-specific JSONLogic examples
- Global FlowEditorDialog mounted once in MainLayout, Odoo inline pattern
- Server: targo-hub flow-runtime.js, flow-api.js, flow-templates.js
- ERPNext: Flow Template/Run doctypes, scheduler, 5 seeded system templates
- depends_on chips resolve to step labels instead of opaque "s4" ids

QR/OCR scanner (field app):
- Camera capture → Gemini Vision via targo-hub with 8s timeout
- IndexedDB offline queue retries photos when signal returns
- Watcher merges late-arriving scan results into the live UI

Dispatch:
- Planning mode (draft → publish) with offer pool for unassigned jobs
- Shared presets, recurrence selector, suggested-slots dialog
- PublishScheduleModal, unassign confirmation

Ops app:
- ClientDetailPage composables extraction (useClientData, useDeviceStatus,
  useWifiDiagnostic, useModemDiagnostic)
- Project wizard: shared detail sections, wizard catalog/publish composables
- Address pricing composable + pricing-mock data
- Settings redesign hosting flow templates

Targo-hub:
- Contract acceptance (JWT residential + DocuSeal commercial tracks)
- Referral system
- Modem-bridge diagnostic normalizer
- Device extractors consolidated

Migration scripts:
- Invoice/quote print format setup, Jinja rendering
- Additional import + fix scripts (reversals, dates, customers, payments)

Docs:
- Consolidated: old scattered MDs → HANDOFF, ARCHITECTURE, DATA_AND_FLOWS,
  FLOW_EDITOR_ARCHITECTURE, BILLING_AND_PAYMENTS, CPE_MANAGEMENT,
  APP_DESIGN_GUIDELINES
- Archived legacy wizard PHP for reference
- STATUS snapshots for 2026-04-18/19

Cleanup:
- Removed ~40 generated PDFs/HTMLs (invoice_preview*, rendered_jinja*)
- .gitignore now covers invoice preview output + nested .DS_Store

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 10:44:17 -04:00

613 lines
25 KiB
JavaScript

// modem-session.js — Playwright-based multi-vendor modem session manager
// Supports: TP-Link XX230v (GDPR encrypt), Raisecom BOA, Raisecom PHP
const { chromium } = require('playwright');
const sessions = new Map();
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
const LOGIN_TIMEOUT_MS = 15 * 1000;
const NAV_TIMEOUT_MS = 20 * 1000;
let browser = null;
async function getBrowser() {
if (!browser || !browser.isConnected()) {
browser = await chromium.launch({
headless: true,
args: [
'--no-sandbox', '--disable-setuid-sandbox',
'--disable-dev-shm-usage', '--disable-gpu',
'--ignore-certificate-errors', '--disable-web-security',
]
});
console.log('[modem-bridge] Chromium launched');
}
return browser;
}
function resetIdleTimer(ip) {
const s = sessions.get(ip); if (!s) return;
clearTimeout(s.idleTimer);
s.idleTimer = setTimeout(() => {
console.log(`[modem-bridge] Session expired: ${ip}`);
closeSession(ip);
}, IDLE_TIMEOUT_MS);
}
function getSession(ip) {
const s = sessions.get(ip);
if (!s || !s.loggedIn) throw new Error(`No active session for ${ip}`);
s.lastActivity = Date.now();
resetIdleTimer(ip);
return s;
}
// --- Modem identification ---
// Probes the device via HTTP/HTTPS to detect vendor and firmware type
const MODEM_TYPES = {
TPLINK_XX230V: 'tplink_xx230v',
RAISECOM_BOA: 'raisecom_boa', // Type A: /admin/login.asp, BOA form, subcustom=raisecom
RAISECOM_PHP: 'raisecom_php', // Type B: /, PHP form, encrypt() JS
UNKNOWN: 'unknown',
};
async function identify(ip) {
const b = await getBrowser();
const context = await b.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 720 } });
const page = await context.newPage();
const result = { ip, type: MODEM_TYPES.UNKNOWN, title: '', protocol: 'https', loginPath: '/' };
// Try HTTPS first (TP-Link uses HTTPS)
for (const proto of ['https', 'http']) {
for (const path of ['/', '/superadmin', '/admin/login.asp']) {
try {
const resp = await page.goto(`${proto}://${ip}${path}`, { waitUntil: 'domcontentloaded', timeout: 8000 });
if (!resp || resp.status() >= 400) continue;
const html = await page.content();
const title = await page.title();
result.title = title;
result.protocol = proto;
result.loginPath = path;
// TP-Link XX230v: has $.tpLang or pc-login-password
if (html.includes('pc-login-password') || html.includes('tpLang') || html.includes('INCLUDE_LOGIN')) {
result.type = MODEM_TYPES.TPLINK_XX230V;
result.loginPath = path;
await context.close();
return result;
}
// Raisecom BOA: title "Home Gateway", subcustom="raisecom", formLogin action
if (html.includes('subcustom') && html.includes('raisecom') && html.includes('formLogin')) {
result.type = MODEM_TYPES.RAISECOM_BOA;
await context.close();
return result;
}
// Raisecom PHP: title "Web user login", encrypt() function, user_name field
if ((title.includes('Web user login') || html.includes('Web user login')) && html.includes('user_name') && html.includes('encrypt')) {
result.type = MODEM_TYPES.RAISECOM_PHP;
await context.close();
return result;
}
} catch { continue; }
}
}
await context.close();
return result;
}
// --- Login handlers per modem type ---
async function loginTplink(ip, user, pass, path) {
const b = await getBrowser();
const context = await b.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 720 }, userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' });
const page = await context.newPage();
await page.goto(`https://${ip}${path}`, { waitUntil: 'networkidle', timeout: NAV_TIMEOUT_MS });
await page.waitForSelector('#pc-login-password', { timeout: LOGIN_TIMEOUT_MS });
const hasUsername = await page.evaluate(() => {
const el = document.getElementById('pc-login-user-div');
return el && !el.classList.contains('nd');
});
if (hasUsername) await page.fill('#pc-login-user', user);
await page.fill('#pc-login-password', pass);
await page.waitForTimeout(300);
const navP = page.waitForNavigation({ waitUntil: 'networkidle', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
await page.click('#pc-login-btn');
await navP;
await page.waitForTimeout(2000);
// Force-logout dialog
const forceBtn = await page.$('#confirm-yes');
if (forceBtn && await forceBtn.isVisible()) {
const navP2 = page.waitForNavigation({ waitUntil: 'networkidle', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
await forceBtn.click();
await navP2;
await page.waitForTimeout(3000);
}
const loginResult = await page.evaluate(() => {
const loginDiv = document.getElementById('pc-login');
if (loginDiv && !loginDiv.classList.contains('nd')) {
const errEl = document.querySelector('.errDiv .errText, .errDivP .errTextP');
return { success: false, error: errEl ? errEl.textContent.trim() : 'Login failed' };
}
return { success: true, hasDm: typeof $ !== 'undefined' && $ && $.dm && typeof $.dm.get === 'function' };
});
if (!loginResult.success) { await context.close(); throw new Error(`Login failed for ${ip}: ${loginResult.error}`); }
return { context, page, hasDm: loginResult.hasDm, modemType: MODEM_TYPES.TPLINK_XX230V };
}
async function loginRaisecomBoa(ip, user, pass, path) {
const b = await getBrowser();
const context = await b.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 720 } });
const page = await context.newPage();
await page.goto(`http://${ip}${path}`, { waitUntil: 'domcontentloaded', timeout: NAV_TIMEOUT_MS });
await page.waitForSelector('input[name="username"]', { timeout: LOGIN_TIMEOUT_MS });
await page.fill('input[name="username"]', user);
await page.fill('input[name="psd"]', pass);
await page.waitForTimeout(200);
// BOA form submits to /boaform/admin/formLogin — triggers page reload
const navP = page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
await page.evaluate(() => document.forms[0].submit());
await navP;
await page.waitForTimeout(2000);
// Check if we're past login
const url = page.url();
const html = await page.content();
const stillOnLogin = html.includes('formLogin') || html.includes('User Login');
if (stillOnLogin) { await context.close(); throw new Error(`Raisecom BOA login failed for ${ip}`); }
return { context, page, hasDm: false, modemType: MODEM_TYPES.RAISECOM_BOA };
}
async function loginRaisecomPhp(ip, user, pass, protocol = 'https') {
const b = await getBrowser();
const context = await b.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 720 } });
const page = await context.newPage();
// WS2 models use HTTPS, WS1 PHP variants use HTTP — auto-detected by identify()
await page.goto(`${protocol}://${ip}/`, { waitUntil: 'networkidle', timeout: NAV_TIMEOUT_MS });
await page.waitForSelector('input[name="user_name"]', { timeout: LOGIN_TIMEOUT_MS });
await page.fill('input[name="user_name"]', user);
await page.fill('input[name="password"]', pass);
await page.waitForTimeout(300);
// Click the submit button to trigger JS encrypt() chain (mySubmit -> encrypt -> CryptoJS)
const navP = page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
await page.click('input[name="login"]').catch(() => {
// Fallback: evaluate mySubmit or native submit
return page.evaluate(() => {
if (typeof mySubmit === 'function') mySubmit(document.forms[0]);
else document.forms[0].submit();
});
});
await navP;
await page.waitForTimeout(3000);
const html = await page.content();
const stillOnLogin = html.includes('Web user login') && html.includes('user_name');
if (stillOnLogin) { await context.close(); throw new Error(`Raisecom PHP login failed for ${ip}`); }
return { context, page, hasDm: false, modemType: MODEM_TYPES.RAISECOM_PHP };
}
// --- Unified login ---
async function login(ip, user, pass, path = '/superadmin') {
if (sessions.has(ip)) await closeSession(ip);
let result;
// Always identify modem type first — don't assume from path alone
const id = await identify(ip);
console.log(`[modem-bridge] Identified ${ip} as ${id.type} (title: "${id.title}", path: ${id.loginPath})`);
if (id.type === MODEM_TYPES.TPLINK_XX230V) {
result = await loginTplink(ip, user, pass, id.loginPath || path);
} else if (id.type === MODEM_TYPES.RAISECOM_BOA) {
result = await loginRaisecomBoa(ip, user, pass, id.loginPath || '/');
} else if (id.type === MODEM_TYPES.RAISECOM_PHP) {
result = await loginRaisecomPhp(ip, user, pass, id.protocol || 'https');
} else {
throw new Error(`Unknown modem type at ${ip} (title: "${id.title}")`);
}
const session = {
ip, context: result.context, page: result.page,
loggedIn: true, loginTime: Date.now(), lastActivity: Date.now(),
hasDm: result.hasDm, modemType: result.modemType,
};
sessions.set(ip, session);
resetIdleTimer(ip);
console.log(`[modem-bridge] Logged in to ${ip} (type: ${result.modemType}, hasDm: ${result.hasDm})`);
return { ip, loggedIn: true, loginTime: session.loginTime, hasDm: result.hasDm, modemType: result.modemType };
}
// --- Data access (TP-Link specific: $.dm) ---
async function dmGet(ip, oid, opts = {}) {
const s = getSession(ip);
if (!s.hasDm) throw new Error(`Data manager not available on ${ip} (type: ${s.modemType})`);
const stack = opts.stack || '0,0,0,0,0,0';
const attrs = opts.attrs || null;
return await s.page.evaluate(({ oid, stack, attrs }) => {
return new Promise((resolve, reject) => {
if (!$ || !$.dm || !$.dm.get) { reject(new Error('Data manager not available')); return; }
const req = { oid, data: { stack }, callback: {
success: (data, others) => resolve({ success: true, data, others }),
fail: (errorcode, others, data) => resolve({ success: false, errorcode, data }),
error: (status) => reject(new Error('Request error: ' + status))
}};
if (attrs) req.data.attrs = attrs;
try { $.dm.get(req); } catch(e) { reject(e); }
setTimeout(() => reject(new Error('DM request timeout')), 8000);
});
}, { oid, stack, attrs });
}
async function cgiRequest(ip, oidPath) {
const s = getSession(ip);
if (!s.hasDm) throw new Error(`CGI not available on ${ip} (type: ${s.modemType})`);
return await s.page.evaluate((oidPath) => {
return new Promise((resolve, reject) => {
if (!$ || !$.dm || !$.dm.cgi) { reject(new Error('CGI not available')); return; }
$.dm.cgi({ oid: oidPath, callback: {
success: (data) => resolve({ success: true, data }),
fail: (errorcode) => resolve({ success: false, errorcode })
}});
setTimeout(() => reject(new Error('CGI request timeout')), 8000);
});
}, oidPath);
}
// --- TP-Link WiFi diagnostic ($.dm based) ---
async function wifiDiagnostic(ip) {
const s = getSession(ip);
if (s.modemType !== MODEM_TYPES.TPLINK_XX230V) throw new Error(`wifiDiagnostic not supported on ${s.modemType}`);
return await s.page.evaluate(() => {
const start = Date.now();
function dmQuery(method, oid, extraData) {
return new Promise((resolve) => {
if (!$ || !$.dm || !$.dm[method]) { resolve({ oid, error: `${method} not available` }); return; }
const req = { oid, callback: { success: (data) => resolve({ oid, data }), fail: (code) => resolve({ oid, error: code }) }};
if (extraData) req.data = extraData;
$.dm[method](req);
setTimeout(() => resolve({ oid, error: 'timeout' }), 8000);
});
}
return Promise.allSettled([
dmQuery('getSubList', 'DEV2_WIFI_RADIO'),
dmQuery('getSubList', 'DEV2_WIFI_APDEV'),
dmQuery('getSubList', 'DEV2_WIFI_APDEV_RADIO'),
dmQuery('getSubList', 'DEV2_WIFI_APDEV_ASSOCDEV'),
dmQuery('getSubList', 'DEV2_WIFI_DE_STA'),
dmQuery('getSubList', 'DEV2_WIFI_APDEV_STATS'),
dmQuery('getSubList', 'DEV2_IP_INTF_V4ADDR'),
dmQuery('get', 'DEV2_ONLINESTATUS', { stack: '0,0,0,0,0,0' }),
]).then(results => {
const keys = ['radios', 'meshNodes', 'nodeRadios', 'clients', 'clientStats', 'packetStats', 'wanIPs', 'onlineStatus'];
const out = { fetchedAt: new Date().toISOString(), durationMs: Date.now() - start, modemType: 'tplink_xx230v' };
results.forEach((r, i) => {
const val = r.status === 'fulfilled' ? r.value : { error: r.reason };
out[keys[i]] = val.error ? { error: val.error } : val.data;
});
return out;
});
});
}
// --- Raisecom diagnostic (targeted JS extraction) ---
// Raisecom BOA pages embed data as JS arrays: var arr=[]; arr.push(new it_nr("idx", new it("key", val), ...))
// We fetch each page's raw HTML and return it for server-side parsing by diagnostic-normalizer.js
const RAISECOM_BOA_PAGES = [
{ path: '/status_ethernet_info.asp', key: 'ethernet' }, // Ethernet ports + DHCP clients
{ path: '/status_device_basic_info.asp', key: 'deviceInfo' }, // Model, serial, firmware, CPU, uptime
{ path: '/status_wlan_info_11n.asp', key: 'wlan' }, // WiFi clients with RSSI, rates
{ path: '/status_net_connet_info.asp', key: 'wan' }, // WAN interfaces, IPs, gateways
{ path: '/status_gpon.asp', key: 'gpon' }, // GPON optical power, link state
];
// WS2 (PHP) uses /sys_manager/list_status.php?parts=X and /interface/ pages
const RAISECOM_PHP_PAGES = [
{ path: '/sys_manager/list_status.php?parts=hostinfo', key: 'deviceInfo' },
{ path: '/sys_manager/list_status.php?parts=waninfo', key: 'wan' },
{ path: '/sys_manager/list_status.php?parts=laninfo', key: 'ethernet' },
{ path: '/sys_manager/list_status.php?parts=wlaninfo', key: 'wlan' },
{ path: '/sys_manager/list_status.php?parts=poninfo', key: 'gpon' },
{ path: '/sys_manager/list_session.php', key: 'sessions' },
];
async function raisecomDiagnostic(ip) {
const s = getSession(ip);
if (!s.modemType.startsWith('raisecom')) throw new Error(`raisecomDiagnostic not supported on ${s.modemType}`);
const start = Date.now();
const pages = s.modemType === MODEM_TYPES.RAISECOM_BOA ? RAISECOM_BOA_PAGES : RAISECOM_PHP_PAGES;
const proto = s.modemType === MODEM_TYPES.RAISECOM_PHP ? 'https' : 'http';
const baseUrl = `${proto}://${ip}`;
const rawPages = {};
for (const pg of pages) {
try {
// Fetch page HTML directly (faster than navigating + waiting for render)
const html = await s.page.evaluate((url) => fetch(url).then(r => r.text()), `${baseUrl}${pg.path}`);
rawPages[pg.key] = html;
} catch (e) {
rawPages[pg.key] = null;
}
}
return {
fetchedAt: new Date().toISOString(),
durationMs: Date.now() - start,
modemType: s.modemType,
rawPages,
};
}
// --- Unified diagnostic (routes to correct handler, returns normalized data) ---
async function unifiedDiagnostic(ip) {
const s = getSession(ip);
const { normalizeRaisecomBoa, normalizeRaisecomPhp, normalizeTplink } = require('./diagnostic-normalizer');
if (s.modemType === MODEM_TYPES.TPLINK_XX230V) {
const raw = await wifiDiagnostic(ip);
const result = normalizeTplink(raw);
result.fetchedAt = raw.fetchedAt;
result.durationMs = raw.durationMs;
return result;
}
if (s.modemType.startsWith('raisecom')) {
const raw = await raisecomDiagnostic(ip);
const normalize = s.modemType === MODEM_TYPES.RAISECOM_PHP ? normalizeRaisecomPhp : normalizeRaisecomBoa;
const result = normalize(raw.rawPages);
result.fetchedAt = raw.fetchedAt;
result.durationMs = raw.durationMs;
// WS2 (PHP) only allows 1 session — auto-logout to free it for the user
if (s.modemType === MODEM_TYPES.RAISECOM_PHP) {
const proto = 'https';
s.page.evaluate((url) => fetch(url).catch(() => {}), `${proto}://${ip}/top_operation.php?top_parts=login_out_time`).catch(() => {});
closeSession(ip).catch(() => {});
console.log(`[modem-bridge] Auto-logout WS2 ${ip} after diagnostic`);
}
return result;
}
throw new Error(`Unsupported modem type: ${s.modemType}`);
}
// --- Stateless one-shot diagnostic (no session reuse) ---
// Single browser context: open page → detect type → login → scrape → logout → close
// No separate identify() call — avoids WS2 session-slot consumption
async function oneshotDiagnostic(ip, user, pass) {
const start = Date.now();
const { normalizeRaisecomBoa, normalizeRaisecomPhp, normalizeTplink } = require('./diagnostic-normalizer');
// Kill any existing session for this IP first
if (sessions.has(ip)) {
await closeSession(ip).catch(() => {});
}
const b = await getBrowser();
const context = await b.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 720 }, userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' });
const page = await context.newPage();
try {
// --- Phase 1: detect modem type from the same page/context we'll login with ---
let modemType = MODEM_TYPES.UNKNOWN;
let proto = 'https';
for (const p of ['https', 'http']) {
try {
await page.goto(`${p}://${ip}/`, { waitUntil: 'domcontentloaded', timeout: 10000 });
const html = await page.content();
const title = await page.title();
if (html.includes('pc-login-password') || html.includes('tpLang') || html.includes('INCLUDE_LOGIN')) {
modemType = MODEM_TYPES.TPLINK_XX230V; proto = p; break;
}
if (html.includes('subcustom') && html.includes('raisecom') && html.includes('formLogin')) {
modemType = MODEM_TYPES.RAISECOM_BOA; proto = p; break;
}
if ((title.includes('Web user login') || html.includes('Web user login')) && html.includes('user_name') && html.includes('encrypt')) {
modemType = MODEM_TYPES.RAISECOM_PHP; proto = p; break;
}
} catch { continue; }
}
console.log(`[modem-bridge] oneshot ${ip}: type=${modemType} proto=${proto}`);
if (modemType === MODEM_TYPES.UNKNOWN) throw new Error(`Unknown modem type at ${ip}`);
// --- Phase 2: login on the SAME page (no new context) ---
let hasDm = false;
if (modemType === MODEM_TYPES.TPLINK_XX230V) {
// TP-Link: navigate to superadmin, fill password
await page.goto(`${proto}://${ip}/superadmin`, { waitUntil: 'networkidle', timeout: NAV_TIMEOUT_MS });
await page.waitForSelector('#pc-login-password', { timeout: LOGIN_TIMEOUT_MS });
const hasUser = await page.evaluate(() => { const el = document.getElementById('pc-login-user-div'); return el && !el.classList.contains('nd'); });
if (hasUser) await page.fill('#pc-login-user', user);
await page.fill('#pc-login-password', pass);
await page.waitForTimeout(300);
const navP = page.waitForNavigation({ waitUntil: 'networkidle', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
await page.click('#pc-login-btn');
await navP;
await page.waitForTimeout(2000);
const forceBtn = await page.$('#confirm-yes');
if (forceBtn && await forceBtn.isVisible()) {
const navP2 = page.waitForNavigation({ waitUntil: 'networkidle', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
await forceBtn.click();
await navP2;
await page.waitForTimeout(3000);
}
hasDm = await page.evaluate(() => typeof $ !== 'undefined' && $ && $.dm && typeof $.dm.get === 'function');
} else if (modemType === MODEM_TYPES.RAISECOM_BOA) {
// BOA: already on login page from detection, fill form
await page.waitForSelector('input[name="username"]', { timeout: LOGIN_TIMEOUT_MS });
await page.fill('input[name="username"]', user);
await page.fill('input[name="psd"]', pass);
await page.waitForTimeout(200);
const navP = page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
await page.evaluate(() => document.forms[0].submit());
await navP;
await page.waitForTimeout(2000);
const html = await page.content();
if (html.includes('formLogin') || html.includes('User Login')) throw new Error(`Raisecom BOA login failed for ${ip}`);
} else if (modemType === MODEM_TYPES.RAISECOM_PHP) {
// WS2: already on login page from detection, fill and submit
await page.waitForSelector('input[name="user_name"]', { timeout: LOGIN_TIMEOUT_MS });
await page.fill('input[name="user_name"]', user);
await page.fill('input[name="password"]', pass);
await page.waitForTimeout(300);
const navP = page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
await page.click('input[name="login"]').catch(() => {
return page.evaluate(() => { if (typeof mySubmit === 'function') mySubmit(document.forms[0]); else document.forms[0].submit(); });
});
await navP;
await page.waitForTimeout(3000);
const html = await page.content();
if (html.includes('Web user login') && html.includes('user_name')) throw new Error(`Raisecom PHP login failed for ${ip}`);
}
console.log(`[modem-bridge] oneshot ${ip}: logged in (${modemType})`);
// --- Phase 3: register temp session, run diagnostic ---
const session = {
ip, context, page, loggedIn: true, loginTime: Date.now(),
lastActivity: Date.now(), hasDm, modemType,
};
sessions.set(ip, session);
let result;
if (modemType === MODEM_TYPES.TPLINK_XX230V) {
const raw = await wifiDiagnostic(ip);
result = normalizeTplink(raw);
result.fetchedAt = raw.fetchedAt;
} else {
const raw = await raisecomDiagnostic(ip);
const normalize = modemType === MODEM_TYPES.RAISECOM_PHP ? normalizeRaisecomPhp : normalizeRaisecomBoa;
result = normalize(raw.rawPages);
result.fetchedAt = new Date().toISOString();
}
result.durationMs = Date.now() - start;
// --- Phase 4: logout + close ---
if (modemType === MODEM_TYPES.RAISECOM_PHP) {
try {
// Navigate to logout page (not fetch — ensures modem processes it fully)
await page.goto(`${proto}://${ip}/top_operation.php?top_parts=login_out_time`, { waitUntil: 'domcontentloaded', timeout: 8000 }).catch(() => {});
// Give the modem time to release the session slot
await page.waitForTimeout(2000);
} catch {}
console.log(`[modem-bridge] oneshot ${ip}: WS2 logged out`);
}
return result;
} finally {
sessions.delete(ip);
await context.close().catch(() => {});
console.log(`[modem-bridge] oneshot ${ip}: done (${Date.now() - start}ms)`);
}
}
// --- Generic status (works for all types) ---
async function getStatus(ip) {
const s = getSession(ip);
if (s.modemType === MODEM_TYPES.TPLINK_XX230V) {
return await s.page.evaluate(() => {
const status = {};
try { if (typeof deviceInfo !== 'undefined') status.deviceInfo = deviceInfo; } catch(e) {}
try { if ($ && $.status) status.cached = $.status; } catch(e) {}
try {
status.dom = {};
document.querySelectorAll('[id*="status"], [id*="info"], [class*="status"]').forEach(el => {
if (el.textContent.trim().length < 200) status.dom[el.id || el.className] = el.textContent.trim();
});
} catch(e) {}
return status;
});
}
// Raisecom: return page scrape
return raisecomDiagnostic(ip);
}
async function screenshot(ip) {
const s = getSession(ip);
return await s.page.screenshot({ type: 'png', fullPage: false });
}
async function evaluate(ip, code) {
const s = getSession(ip);
return await s.page.evaluate(new Function('return (' + code + ')'));
}
async function getPageContent(ip) {
const s = getSession(ip);
return await s.page.content();
}
async function navigate(ip, path) {
const s = getSession(ip);
const proto = s.modemType.startsWith('raisecom') ? 'http' : 'https';
await s.page.goto(`${proto}://${ip}${path}`, { waitUntil: 'domcontentloaded', timeout: NAV_TIMEOUT_MS });
}
async function closeSession(ip) {
const s = sessions.get(ip);
if (!s) return;
clearTimeout(s.idleTimer);
try { await s.context.close(); }
catch(e) { console.warn(`[modem-bridge] Error closing ${ip}:`, e.message); }
sessions.delete(ip);
}
async function shutdown() {
for (const [ip] of sessions) await closeSession(ip);
if (browser) { await browser.close(); browser = null; }
}
function listSessions() {
return [...sessions.entries()].map(([ip, s]) => ({
ip, loggedIn: s.loggedIn, loginTime: s.loginTime,
lastActivity: s.lastActivity, idleMs: Date.now() - s.lastActivity,
hasDm: s.hasDm, modemType: s.modemType,
}));
}
module.exports = {
identify, login, dmGet, cgiRequest, getStatus,
wifiDiagnostic, raisecomDiagnostic, unifiedDiagnostic, oneshotDiagnostic,
evaluate, screenshot, getPageContent, navigate,
closeSession, shutdown, listSessions,
MODEM_TYPES,
};