// 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, };