From 5e54ea6f572ae01db2c743b1ec24b2d1cec15785 Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Wed, 29 Jul 2026 17:59:17 -0400 Subject: [PATCH] Add /atlasdiag live AZM diagnostic with source alerts and monitor auto-off. Ports Atlas /check as /atlasdiag: Hub resolve, UI tunnel, WebSocket report for sources/zones/accessories, low source dB warnings, and auto-disable of the monitor speaker when left on after troubleshooting. Co-authored-by: Cursor --- .env.example | 24 +- commands/atlasDiag.js | 45 ++ commands/help.js | 22 +- commands/registry.js | 2 + integrations/atlas/azmWs.js | 876 +++++++++++++++++++++++ integrations/atlas/checkStore.js | 102 +++ integrations/atlas/errors.js | 11 + integrations/atlas/resolveStoreDevice.js | 107 +++ integrations/atlas/uiSession.js | 371 ++++++++++ tests/atlasAzmWs.test.js | 94 +++ tests/atlasResolveStoreDevice.test.js | 40 ++ utils/fetchWithTimeout.js | 12 + 12 files changed, 1704 insertions(+), 2 deletions(-) create mode 100644 commands/atlasDiag.js create mode 100644 integrations/atlas/azmWs.js create mode 100644 integrations/atlas/checkStore.js create mode 100644 integrations/atlas/errors.js create mode 100644 integrations/atlas/resolveStoreDevice.js create mode 100644 integrations/atlas/uiSession.js create mode 100644 tests/atlasAzmWs.test.js create mode 100644 tests/atlasResolveStoreDevice.test.js create mode 100644 utils/fetchWithTimeout.js diff --git a/.env.example b/.env.example index 53fb363..8e4021f 100644 --- a/.env.example +++ b/.env.example @@ -436,10 +436,32 @@ CORP_WS1_CLIENT_SECRET=... CORP_WS1_TENANT_CODE=... # ----------------------------------------------------------------------------- -# Atlas +# Atlas (Hub API + /atlasdiag live AZM tunnel) # ----------------------------------------------------------------------------- +# Organization Hub API key for device list/detail (used by /avstatus and /atlasdiag). ATLAS_AUTH_KEY=your-atlas-key +# --- /atlasdiag: UI session (tunnel URLs) --- +# Preferred: email/password login (auto-refreshes Devise-token headers). +# ATLAS_UI_EMAIL= +# ATLAS_UI_PASSWORD= +# Optional Descope project id if your tenant requires it for UI login. +# ATLAS_UI_DESCOPE_PROJECT_ID= + +# Alternative: paste static DevTools headers from a logged-in hub.xyte.io session. +# ATLAS_UI_ACCESS_TOKEN= +# ATLAS_UI_CLIENT= +# ATLAS_UI_EXPIRY= +# ATLAS_UI_UID= +# ATLAS_UI_TENANT= +# ATLAS_UI_TENANT_TYPE=organization +# ATLAS_UI_TOKEN_TYPE=Bearer + +# --- /atlasdiag: AZM WebSocket device login --- +# Credentials for the AZM JSON-RPC session through the tunnel (not the Hub API). +ATLAS_DEVICE_USERNAME= +ATLAS_DEVICE_PASSWORD= + # ----------------------------------------------------------------------------- # ServiceChannel # OAuth password grant against ServiceChannel's identity endpoint. diff --git a/commands/atlasDiag.js b/commands/atlasDiag.js new file mode 100644 index 0000000..dcf91d2 --- /dev/null +++ b/commands/atlasDiag.js @@ -0,0 +1,45 @@ +// commands/atlasDiag.js +// +// /atlasdiag +// +// Live AZM report via Atlas UI tunnel + WebSocket: sources, zones, +// accessories, uptime. Sources in the -80 to -70 dB band are flagged. + +import { checkStore } from '../integrations/atlas/checkStore.js'; +import { logger } from '../utils/logger.js'; + +export async function handleAtlasDiag(bot, trigger) { + const query = trigger.query || {}; + const args = trigger.args || []; + const storeOrName = ( + args[0]?.trim() || + query.storeNum || + query.store || + query.s || + '' + ).trim(); + + if (!storeOrName) { + await bot.say( + 'markdown', + 'Please provide a store number or AZM device name.\n' + + 'Examples:\n' + + '- `/atlasdiag 2547`\n' + + '- `/atlasdiag US002547AMP`\n' + + 'HTTP: `?storeNum=2547`', + ); + return; + } + + logger('atlas:diag', `Starting live AZM check for ${storeOrName}`); + + await bot.say('markdown', `⏳ Checking store **${storeOrName}** (live AZM tunnel, may take up to 90s)…`); + + try { + const result = await checkStore(storeOrName); + await bot.say('markdown', result.markdown); + } catch (err) { + logger('atlas:diag', `Check failed for ${storeOrName}: ${err.message}`, 'warn'); + await bot.say('markdown', `❌ Atlas diag failed: ${err.message || String(err)}`); + } +} diff --git a/commands/help.js b/commands/help.js index 3b9b308..7de8a7d 100644 --- a/commands/help.js +++ b/commands/help.js @@ -20,6 +20,7 @@ const SHORT_HELP = { avstatus: 'AV / device status for a store (alias: /wostatus)', voicestatus: 'Quick DECT + IP phone status for a store', wanstatus: 'Prisma SD-WAN health + voice traffic quality', + atlasdiag: 'Live AZM report via Atlas tunnel (sources, zones, accessories)', phonediag: 'MPP desk phone relay diagnostics (CP-7841)', dectdiag: 'Full DECT base dump via relay (handsets, RSSI, reboot cards)', voicediag: 'Deep voice diagnostic: features + WAN + relay probes with fix cards', @@ -75,6 +76,25 @@ const LONG_HELP = { 'Web dashboard: `/phone-store-dashboard.html`.', ], }, + atlasdiag: { + title: '/atlasdiag', + usage: [ + '/atlasdiag ', + '/atlasdiag ', + ], + examples: [ + '/atlasdiag 2547', + '/atlasdiag US002547AMP', + ], + notes: [ + 'Live AZM diagnostic via Atlas UI tunnel + WebSocket (30–90s).', + 'Reports sources, zones, accessories, uptime, and monitor speaker status.', + 'Sources with input level between -80 and -70 dB are flagged as low.', + 'Monitor speaker is auto-disabled when left on (should only be on during onsite troubleshooting).', + 'Requires ATLAS_AUTH_KEY, UI session creds (ATLAS_UI_EMAIL/PASSWORD or static ATLAS_UI_* headers), and ATLAS_DEVICE_USERNAME/PASSWORD.', + 'HTTP: `?storeNum=2547`.', + ], + }, wanstatus: { title: '/wanstatus', usage: [ @@ -352,7 +372,7 @@ const LONG_HELP = { const GROUPS = [ { title: 'Work orders', keys: ['wohistory', 'wosummary', 'woattachments'] }, - { title: 'AV & phones', keys: ['avstatus', 'voicestatus', 'wanstatus', 'phonediag', 'dectdiag', 'voicediag', 'callreport', 'calltest'] }, + { title: 'AV & phones', keys: ['avstatus', 'voicestatus', 'wanstatus', 'atlasdiag', 'phonediag', 'dectdiag', 'voicediag', 'callreport', 'calltest'] }, { title: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] }, { title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] }, { title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] }, diff --git a/commands/registry.js b/commands/registry.js index 287fc92..b1083da 100644 --- a/commands/registry.js +++ b/commands/registry.js @@ -34,6 +34,7 @@ import { handleBulkAvSwitchCSV } from './bulkAvSwitchCSV.js'; import { handleTestDevicesByModel } from './testDevicesByModel.js'; import { handleCallTest } from './callTest.js'; import { handleCallReport } from './callReport.js'; +import { handleAtlasDiag } from './atlasDiag.js'; /** * Each entry: @@ -50,6 +51,7 @@ export const commands = [ { name: 'avstatus', aliases: ['wostatus'], handler: handleAvStatus, mutating: false }, { name: 'voicestatus', handler: handleVoiceStatus, mutating: false }, { name: 'wanstatus', handler: handleWanStatus, mutating: false }, + { name: 'atlasdiag', handler: handleAtlasDiag, mutating: false }, { name: 'phonediag', handler: handlePhoneDiag, mutating: false }, // /dectdiag — full DECT base dump via the on-prem relay + chat-only // reboot/factory-reset cards. Read path is non-mutating; card submits diff --git a/integrations/atlas/azmWs.js b/integrations/atlas/azmWs.js new file mode 100644 index 0000000..3cf6008 --- /dev/null +++ b/integrations/atlas/azmWs.js @@ -0,0 +1,876 @@ +// integrations/atlas/azmWs.js — Atmosphere AZM JSON-RPC over WebSocket (/ws) through a Xyte tunnel + +import crypto from 'crypto'; +import WebSocket from 'ws'; + +const AUTH_KEY = Buffer.from('M1T3kU5@W31c0m35Ev3ry0n3', 'utf8'); +const AUTH_IV = Buffer.from('Ev3ry0n3', 'utf8'); + +const METER_PARAMS = new Set(['Source Meter', 'Out Group Meter']); + +export function computeAuthReply(challenge) { + const cipher = crypto.createCipheriv('des-ede3-cbc', AUTH_KEY, AUTH_IV); + return cipher.update(String(challenge), 'utf8', 'base64') + cipher.final('base64'); +} + +export function encryptUserCredentials(userName, password) { + const payload = JSON.stringify({ userName, password }); + const cipher = crypto.createCipheriv('des-ede3-cbc', AUTH_KEY, AUTH_IV); + return cipher.update(payload, 'utf8', 'base64') + cipher.final('base64'); +} + +export function decryptUserBlob(encrypted) { + if (!encrypted || encrypted === 'Not Found') return null; + try { + const decipher = crypto.createDecipheriv('des-ede3-cbc', AUTH_KEY, AUTH_IV); + const raw = decipher.update(String(encrypted), 'base64', 'utf8') + decipher.final('utf8'); + return JSON.parse(raw); + } catch { + return null; + } +} + +export function getDeviceCredentials() { + const userName = process.env.ATLAS_DEVICE_USERNAME; + const password = process.env.ATLAS_DEVICE_PASSWORD; + if (!userName || !password) return null; + return { userName, password }; +} + +function paramKey(p) { + const parts = [p.obj, p.param]; + if (p.x !== undefined && p.x !== '*') parts.push(`x=${p.x}`); + if (p.channel !== undefined && p.channel !== '*') parts.push(`ch=${p.channel}`); + if (p.device !== undefined && p.device !== '*') parts.push(`dev=${p.device}`); + return parts.join('|'); +} + +function upsertMetric(byKey, p) { + if (!p?.obj || !p?.param) return; + byKey.set(paramKey(p), p); +} + +function appendMeterSample(meterSamples, p) { + if (!p?.obj || !METER_PARAMS.has(p.param)) return; + const key = paramKey(p); + const n = p.val != null ? Number(p.val) : Number(p.str); + if (!Number.isFinite(n)) return; + if (!meterSamples.has(key)) meterSamples.set(key, []); + meterSamples.get(key).push(n); +} + +function summarizeSamples(samples) { + if (!samples?.length) return null; + const low = Math.min(...samples); + const high = Math.max(...samples); + const avg = samples.reduce((a, b) => a + b, 0) / samples.length; + return { low, high, avg, samples: samples.length }; +} + +export function fetchAzmMetrics(wsUrl, { + cookie, + origin, + sessionName = 'test-ops', + gets = DEFAULT_GETS, + followUpGets = null, + followUpCollectMs = 1500, + meterSubscribes = null, + meterSampleMs = 3000, + collectMs = 0, + timeoutMs = 30000, + username, + password, + login = true, +} = {}) { + return new Promise((resolve, reject) => { + if (!cookie) { + reject(new Error('cookie (xyte_auth) is required')); + return; + } + + const envCreds = getDeviceCredentials(); + const userName = username || envCreds?.userName; + const pass = password || envCreds?.password; + const doLogin = login && userName && pass; + + const headers = { Cookie: cookie }; + if (origin) headers.Origin = origin; + + const ws = new WebSocket(wsUrl, { headers }); + const byKey = new Map(); + const meterSamples = new Map(); + let phase = 'challenge'; + let settled = false; + let deviceUser = null; + let collectTimer = null; + + const resultPayload = () => ({ + metrics: [...byKey.values()], + meterSamples: Object.fromEntries( + [...meterSamples.entries()].map(([k, samples]) => [k, summarizeSamples(samples)]), + ), + sessionName, + deviceLogin: doLogin + ? { ok: true, userName: deviceUser?.userName || userName, role: deviceUser?.role } + : null, + }); + + const finish = (err, result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (collectTimer) clearTimeout(collectTimer); + try { ws.close(); } catch { /* ignore */ } + if (err) reject(err); + else resolve(result); + }; + + const timer = setTimeout(() => { + if (phase === 'device-login') { + finish(new Error( + `Device login timed out for user "${userName}" (no successful User Accounts/Validate response)`, + )); + } else if (phase === 'metrics' || phase === 'meters' || phase === 'follow-up') { + if (byKey.size || meterSamples.size) finish(null, resultPayload()); + else finish(new Error(`AZM WS timeout in phase=${phase}`)); + } else { + finish(new Error(`AZM WS timeout in phase=${phase}`)); + } + }, timeoutMs); + + const send = (obj) => ws.send(JSON.stringify(obj)); + + const completeMetrics = () => finish(null, resultPayload()); + + const startFollowUp = () => { + if (!followUpGets?.length) { + completeMetrics(); + return; + } + phase = 'follow-up'; + try { + const params = followUpGets.map(p => ({ ...p, sub: p.sub ?? 1 })); + send({ jsonrpc: '2.0', method: 'subscribe', params }); + } catch { + completeMetrics(); + return; + } + collectTimer = setTimeout(completeMetrics, followUpCollectMs); + }; + + const startMeterSampling = () => { + if (!meterSubscribes?.length) { + startFollowUp(); + return; + } + phase = 'meters'; + const params = meterSubscribes.map(p => ({ ...p, sub: p.sub ?? 0 })); + send({ jsonrpc: '2.0', method: 'subscribe', params }); + collectTimer = setTimeout(() => { + collectTimer = null; + startFollowUp(); + }, meterSampleMs); + }; + + const requestMetrics = () => { + phase = 'metrics'; + if (gets?.length) { + send({ jsonrpc: '2.0', method: 'get', params: gets }); + if (collectMs > 0) { + collectTimer = setTimeout(() => { + collectTimer = null; + startMeterSampling(); + }, collectMs); + } + } else if (meterSubscribes?.length) { + startMeterSampling(); + } else if (followUpGets?.length) { + startFollowUp(); + } else { + finish(null, { + metrics: [], + meterSamples: {}, + sessionName, + deviceLogin: doLogin ? { ok: !!deviceUser, userName: deviceUser?.userName || userName } : null, + }); + } + }; + + const startDeviceLogin = () => { + phase = 'device-login'; + const encrypted = encryptUserCredentials(userName, pass); + send({ + jsonrpc: '2.0', + method: 'set', + params: [{ obj: 'User Accounts', param: 'Validate', str: encrypted }], + }); + setTimeout(() => { + if (phase === 'device-login') { + send({ + jsonrpc: '2.0', + method: 'get', + params: [{ obj: 'User Accounts', param: 'Validate' }], + }); + } + }, 400); + }; + + ws.on('open', () => { + send({ jsonrpc: '2.0', method: 'auth', params: {} }); + }); + + ws.on('message', (buf) => { + let msg; + try { + msg = JSON.parse(buf.toString()); + } catch (err) { + finish(err); + return; + } + + if (phase === 'challenge' && msg.method === 'authResp') { + const challenge = msg.params?.resp; + if (!challenge || challenge === 'Welcome!') { + finish(new Error(`Unexpected auth challenge: ${JSON.stringify(msg.params)}`)); + return; + } + phase = 'welcome'; + send({ + jsonrpc: '2.0', + method: 'auth', + params: { reply: computeAuthReply(challenge) }, + }); + return; + } + + if (phase === 'welcome' && msg.method === 'authResp') { + if (msg.params?.resp !== 'Welcome!') { + finish(new Error(`Auth rejected: ${JSON.stringify(msg.params)}`)); + return; + } + send({ jsonrpc: '2.0', method: 'setSessionName', params: { name: sessionName } }); + if (doLogin) startDeviceLogin(); + else requestMetrics(); + return; + } + + if (phase === 'device-login' && msg.method === 'paramUpdate') { + const params = Array.isArray(msg.params) ? msg.params : [msg.params]; + const validate = params.find(p => p?.obj === 'User Accounts' && p?.param === 'Validate'); + if (!validate) return; + + if (!validate.str || validate.str === 'Not Found') { + return; + } + + deviceUser = decryptUserBlob(validate.str); + if (!deviceUser?.userName) { + deviceUser = { userName, raw: true }; + } + requestMetrics(); + return; + } + + if ((phase === 'metrics' || phase === 'meters' || phase === 'follow-up') && msg.method === 'paramUpdate') { + const params = Array.isArray(msg.params) ? msg.params : [msg.params]; + const useful = params.filter(p => p && !(p.obj === 'User Accounts' && p.param === 'Validate')); + for (const p of useful) { + if (phase === 'meters' && METER_PARAMS.has(p.param)) { + appendMeterSample(meterSamples, p); + upsertMetric(byKey, p); + } else { + upsertMetric(byKey, p); + if (METER_PARAMS.has(p.param)) appendMeterSample(meterSamples, p); + } + } + + if ( + phase === 'metrics' && + collectMs <= 0 && + !followUpGets?.length && + !meterSubscribes?.length && + useful.length + ) { + completeMetrics(); + } + } + }); + + ws.on('error', (err) => finish(err)); + ws.on('close', (code, reason) => { + if (!settled && (phase === 'metrics' || phase === 'meters' || phase === 'follow-up') && (byKey.size || meterSamples.size)) { + completeMetrics(); + return; + } + if (!settled && phase !== 'metrics' && phase !== 'meters' && phase !== 'follow-up') { + finish(new Error(`WS closed early code=${code} reason=${reason?.toString?.() || ''} phase=${phase}`)); + } + }); + }); +} + +export const DEFAULT_GETS = [ + { obj: 'System Info', param: 'Model' }, + { obj: 'System Info', param: 'Uptime' }, + { obj: 'System Info', param: 'Firmware Version' }, + { obj: 'System Info', param: 'Main Cpu Smoothed' }, + { obj: 'Project Info', param: 'AZM Name' }, + { obj: 'Project Info', param: 'Project Name' }, + { obj: 'Network Settings', param: 'Wired IP Address' }, + { obj: 'Error Log', param: 'All Live Errors' }, + { obj: 'User Accounts', param: 'Validate' }, +]; + +export const MONITOR_SPEAKER_GETS = [ + { obj: 'MonitorRouter', param: 'Input Channel' }, + { obj: 'MonitorRouter', param: 'Output Gain' }, +]; + +/** Atmosphere UI: Off = last enum () at pct 100 on MonitorRouter/Input Channel. */ +export const MONITOR_OFF_SET = { obj: 'MonitorRouter', param: 'Input Channel', pct: 100 }; + +export const METER_SUBSCRIBES = [ + { obj: 'Link Manager', param: 'Source Meter', x: '*', sub: 1 }, + { obj: 'Link Manager', param: 'Out Group Meter', x: '*', sub: 1 }, +]; + +export const CHECK_GETS = [ + { obj: 'Project Info', param: 'Project Name' }, + { obj: 'Project Info', param: 'AZM Name' }, + { obj: 'System Info', param: 'Uptime' }, + { obj: 'System Info', param: 'Model' }, + { obj: 'System Info', param: 'Firmware Version' }, + + { obj: 'Link Manager', param: 'Source Defined', x: '*' }, + { obj: 'Link Manager', param: 'Source Name', x: '*' }, + { obj: 'Link Manager', param: 'Source DSP Chans', x: '*' }, + + { obj: 'Link Manager', param: 'Out Group Defined', x: '*' }, + { obj: 'Link Manager', param: 'Out Group Name', x: '*' }, + { obj: 'Link Manager', param: 'Out Group Source', x: '*' }, + { obj: 'Link Manager', param: 'Out Group Mute', x: '*' }, + { obj: 'Link Manager', param: 'Out Group Master Gain', x: '*' }, + + { obj: 'InGain', param: 'Mute', channel: '*' }, + { obj: 'InGain', param: 'Gain', channel: '*' }, + + { obj: 'Control Wallplate', param: 'Name', device: '*' }, + { obj: 'Control Wallplate', param: 'Online', device: '*' }, + { obj: 'Control Wallplate', param: 'Type', device: '*' }, +]; + +function strVal(p) { + if (p == null) return null; + if (p.str !== undefined && p.str !== null && p.str !== '') return String(p.str); + if (p.val !== undefined && p.val !== null) return String(p.val); + return null; +} + +function isYes(v) { + const s = String(v || '').trim().toLowerCase(); + return s === 'yes' || s === 'on' || s === 'true' || s === '1' || s === 'enabled'; +} + +function isMuted(v) { + const s = String(v || '').trim().toLowerCase(); + if (s === 'yes' || s === 'on' || s === 'true' || s === '1' || s === 'muted') return true; + if (s === 'no' || s === 'off' || s === 'false' || s === '0' || s === 'unmuted') return false; + return null; +} + +function findParam(metrics, obj, param, indexKey, indexVal) { + return metrics.find(p => { + if (p.obj !== obj || p.param !== param) return false; + if (indexKey == null) return true; + return String(p[indexKey]) === String(indexVal); + }) || null; +} + +function indexSet(metrics, obj, param, indexKey) { + const set = new Set(); + for (const p of metrics) { + if (p.obj === obj && p.param === param && p[indexKey] !== undefined && p[indexKey] !== '*') { + set.add(String(p[indexKey])); + } + } + return [...set].sort((a, b) => Number(a) - Number(b) || a.localeCompare(b)); +} + +export function resolveMonitorSpeaker(metrics) { + const input = (metrics || []).find( + p => p?.obj === 'MonitorRouter' && p?.param === 'Input Channel', + ); + const gain = (metrics || []).find( + p => p?.obj === 'MonitorRouter' && p?.param === 'Output Gain', + ); + + if (!input) { + return { status: 'Not present' }; + } + + const raw = strVal(input); + if (raw == null || raw === '' || raw === 'Not Found') { + return { status: 'Not present' }; + } + + const gainStr = strVal(gain); + const isOff = (input.pct != null && Number(input.pct) >= 99.5); + + if (isOff) { + return { + status: 'Off', + gain: gainStr, + inputChannel: raw, + detail: `MonitorRouter/Input Channel=${raw} (none)`, + }; + } + + return { + status: 'On', + gain: gainStr, + inputChannel: raw, + detail: `MonitorRouter/Input Channel=${raw}`, + }; +} + +export function normalizeCheckReport(metrics = [], meterSamples = {}) { + const projectName = strVal(findParam(metrics, 'Project Info', 'Project Name')) || null; + const deviceName = strVal(findParam(metrics, 'Project Info', 'AZM Name')) || null; + const uptimeParam = findParam(metrics, 'System Info', 'Uptime'); + const uptimeHours = uptimeParam?.val != null ? Number(uptimeParam.val) : null; + const model = strVal(findParam(metrics, 'System Info', 'Model')) || null; + const firmware = strVal(findParam(metrics, 'System Info', 'Firmware Version')) || null; + + const meterFor = (obj, param, x) => { + const key = paramKey({ obj, param, x }); + return meterSamples[key] || null; + }; + + const sourceIndexes = indexSet(metrics, 'Link Manager', 'Source Defined', 'x'); + const sources = []; + for (const x of sourceIndexes) { + const defined = strVal(findParam(metrics, 'Link Manager', 'Source Defined', 'x', x)); + if (!isYes(defined)) continue; + const name = strVal(findParam(metrics, 'Link Manager', 'Source Name', 'x', x)) || `Source ${x}`; + const dspChans = strVal(findParam(metrics, 'Link Manager', 'Source DSP Chans', 'x', x)); + const channel = dspChans ? String(dspChans).split(/[,\s]+/).filter(Boolean)[0] : x; + const muteRaw = strVal(findParam(metrics, 'InGain', 'Mute', 'channel', channel)); + const gainRaw = strVal(findParam(metrics, 'InGain', 'Gain', 'channel', channel)); + const volume = meterFor('Link Manager', 'Source Meter', x); + sources.push({ + index: Number(x) || x, + name, + channel, + muted: isMuted(muteRaw), + db: gainRaw, + volume, + muteRaw, + gainRaw, + }); + } + + const zoneIndexes = indexSet(metrics, 'Link Manager', 'Out Group Defined', 'x'); + const sourceByIndex = new Map(sources.map(s => [String(s.index), s])); + const zones = []; + for (const x of zoneIndexes) { + const defined = strVal(findParam(metrics, 'Link Manager', 'Out Group Defined', 'x', x)); + if (!isYes(defined)) continue; + const name = strVal(findParam(metrics, 'Link Manager', 'Out Group Name', 'x', x)) || `Zone ${x}`; + const sourceIdx = strVal(findParam(metrics, 'Link Manager', 'Out Group Source', 'x', x)); + const source = sourceByIndex.get(String(sourceIdx)); + const muteRaw = strVal(findParam(metrics, 'Link Manager', 'Out Group Mute', 'x', x)); + const gainRaw = strVal(findParam(metrics, 'Link Manager', 'Out Group Master Gain', 'x', x)); + const volume = meterFor('Link Manager', 'Out Group Meter', x); + zones.push({ + index: Number(x) || x, + name, + sourceIndex: sourceIdx, + sourceName: source?.name || (sourceIdx ? `Source ${sourceIdx}` : null), + muted: isMuted(muteRaw), + db: gainRaw, + volume, + muteRaw, + gainRaw, + }); + } + + const deviceIndexes = indexSet(metrics, 'Control Wallplate', 'Name', 'device'); + for (const d of indexSet(metrics, 'Control Wallplate', 'Online', 'device')) { + if (!deviceIndexes.includes(d)) deviceIndexes.push(d); + } + deviceIndexes.sort((a, b) => Number(a) - Number(b) || a.localeCompare(b)); + + const accessories = []; + for (const device of deviceIndexes) { + const name = strVal(findParam(metrics, 'Control Wallplate', 'Name', 'device', device)); + const online = strVal(findParam(metrics, 'Control Wallplate', 'Online', 'device', device)); + const type = strVal(findParam(metrics, 'Control Wallplate', 'Type', 'device', device)); + if (!name && !online) continue; + const typeNorm = String(type || '').trim().toLowerCase(); + const nameNorm = String(name || '').trim(); + if (typeNorm === 'none' || nameNorm === '0' || nameNorm === '') continue; + accessories.push({ + index: Number(device) || device, + name: nameNorm, + online: online || 'Unknown', + type: type || null, + }); + } + + return { + projectName, + deviceName, + model, + firmware, + uptimeHours: Number.isFinite(uptimeHours) ? uptimeHours : null, + sources, + zones, + accessories, + monitorSpeaker: resolveMonitorSpeaker(metrics), + }; +} + +export function formatUptime(hours) { + if (hours == null || !Number.isFinite(hours)) return 'Unknown'; + if (hours > 24) return `${(hours / 24).toFixed(1)} days`; + return `${hours.toFixed(1)} hours`; +} + +function muteIcon(muted) { + if (muted === true) return '🔇'; + if (muted === false) return '🔊'; + return '❔'; +} + +function volumeLabel(volume, fallbackDb) { + if (volume && Number.isFinite(volume.avg)) { + return `low ${volume.low.toFixed(1)} / avg ${volume.avg.toFixed(1)} / high ${volume.high.toFixed(1)} dB`; + } + if (fallbackDb == null || fallbackDb === '') return 'vol unknown'; + const n = Number(fallbackDb); + if (Number.isFinite(n)) return `gain ${n.toFixed(1)} dB`; + return `gain ${fallbackDb} dB`; +} + +/** Prefer live meter avg; fall back to InGain gain setpoint. */ +export function getSourceLevelDb(source) { + if (source?.volume && Number.isFinite(source.volume.avg)) { + return source.volume.avg; + } + const n = Number(source?.db); + return Number.isFinite(n) ? n : null; +} + +/** Weak-signal band: between -80 dB and -70 dB inclusive. */ +export function isLowSourceLevel(levelDb) { + if (levelDb == null || !Number.isFinite(levelDb)) return false; + return levelDb >= -80 && levelDb <= -70; +} + +function formatSourceLine(s) { + const level = getSourceLevelDb(s); + const lowAlert = isLowSourceLevel(level); + const vol = volumeLabel(s.volume, s.db); + if (lowAlert) { + return `• ⚠️ ${muteIcon(s.muted)} ${s.name} — ${vol} (low level: ${level.toFixed(1)} dB)`; + } + return `• ${muteIcon(s.muted)} ${s.name} — ${vol}`; +} + +export function formatCheckMarkdown(report, { storeNumber, deviceStatus: status, monitorDisable } = {}) { + const storeLabel = storeNumber != null + ? `Store ${String(storeNumber).replace(/^0+/, '') || storeNumber}` + : (report.projectName || 'Store'); + const title = report.projectName + ? `**${storeLabel}** — ${report.projectName}` + : `**${storeLabel}**`; + + const lines = [ + title, + `Device: ${report.deviceName || 'Unknown'}` + + (report.model ? ` (${report.model})` : '') + + ` | Uptime: ${formatUptime(report.uptimeHours)}` + + (status ? ` | Hub: ${status}` : ''), + '', + ]; + + const lowSourceCount = (report.sources || []).filter(s => isLowSourceLevel(getSourceLevelDb(s))).length; + + lines.push('**Sources**'); + if (lowSourceCount > 0) { + lines.push(`⚠️ ${lowSourceCount} source(s) with low input level (-80 to -70 dB)`); + } + if (!report.sources?.length) { + lines.push('• _(none defined)_'); + } else { + for (const s of report.sources) { + lines.push(formatSourceLine(s)); + } + } + lines.push(''); + + lines.push('**Zones**'); + if (!report.zones?.length) { + lines.push('• _(none defined)_'); + } else { + for (const z of report.zones) { + const src = z.sourceName || 'unassigned'; + lines.push(`• ${muteIcon(z.muted)} ${z.name} — source ${src} — ${volumeLabel(z.volume, z.db)}`); + } + } + lines.push(''); + + lines.push('**Accessories**'); + const mon = report.monitorSpeaker; + const monPresent = mon && mon.status !== 'Not present'; + if (!report.accessories?.length && !monPresent) { + lines.push('• _(none)_'); + } else { + for (const a of report.accessories || []) { + const type = a.type ? ` (${a.type})` : ''; + lines.push(`• ${a.name}${type} — ${a.online}`); + } + if (monPresent) { + let monLine = `• Monitor speaker — ${mon.status}`; + if (monitorDisable?.attempted) { + if (monitorDisable.ok) { + const was = monitorDisable.previousChannel || 'On'; + monLine = `• Monitor speaker — Off (auto-disabled; was listening to **${was}**)`; + } else { + if (mon.status === 'On' && mon.gain != null && mon.gain !== '') { + const g = Number(mon.gain); + monLine += Number.isFinite(g) ? ` (${g.toFixed(1)} dB)` : ` (${mon.gain} dB)`; + } + monLine += ` ⚠️ auto-disable failed${monitorDisable.error ? `: ${monitorDisable.error}` : ''}`; + } + } else if (mon.status === 'On' && mon.gain != null && mon.gain !== '') { + const g = Number(mon.gain); + monLine += Number.isFinite(g) ? ` (${g.toFixed(1)} dB)` : ` (${mon.gain} dB)`; + } + lines.push(monLine); + } + } + + return lines.join('\n'); +} + +/** + * Turn off the AZM monitor speaker (MonitorRouter → Input Channel = none). + * Opens a short-lived /ws session: auth → device login → set → verify. + */ +export function disableMonitorSpeaker(wsUrl, { + cookie, + origin, + sessionName = 'monitor-off', + timeoutMs = 25000, + verifyMs = 1500, + username, + password, + login = true, +} = {}) { + return new Promise((resolve, reject) => { + if (!cookie) { + reject(new Error('cookie (xyte_auth) is required')); + return; + } + + const envCreds = getDeviceCredentials(); + const userName = username || envCreds?.userName; + const pass = password || envCreds?.password; + const doLogin = login && userName && pass; + + const headers = { Cookie: cookie }; + if (origin) headers.Origin = origin; + + const ws = new WebSocket(wsUrl, { headers }); + const byKey = new Map(); + let phase = 'challenge'; + let settled = false; + let collectTimer = null; + + const finish = (err, result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (collectTimer) clearTimeout(collectTimer); + try { ws.close(); } catch { /* ignore */ } + if (err) reject(err); + else resolve(result); + }; + + const completeVerify = () => { + const monitorSpeaker = resolveMonitorSpeaker([...byKey.values()]); + const ok = monitorSpeaker.status === 'Off'; + finish(null, { + ok, + monitorSpeaker, + metrics: [...byKey.values()], + error: ok ? null : `Monitor still ${monitorSpeaker.status}`, + }); + }; + + const timer = setTimeout(() => { + if (phase === 'verify' && byKey.size) { + completeVerify(); + return; + } + if (phase === 'device-login') { + finish(new Error( + `Device login timed out for user "${userName}" (no successful User Accounts/Validate response)`, + )); + } else { + finish(new Error(`AZM WS timeout in phase=${phase}`)); + } + }, timeoutMs); + + const send = (obj) => ws.send(JSON.stringify(obj)); + + const startDisable = () => { + phase = 'disable'; + send({ jsonrpc: '2.0', method: 'set', params: [MONITOR_OFF_SET] }); + phase = 'verify'; + setTimeout(() => { + send({ + jsonrpc: '2.0', + method: 'subscribe', + params: MONITOR_SPEAKER_GETS.map(p => ({ ...p, sub: 1 })), + }); + collectTimer = setTimeout(completeVerify, verifyMs); + }, 300); + }; + + const startDeviceLogin = () => { + phase = 'device-login'; + const encrypted = encryptUserCredentials(userName, pass); + send({ + jsonrpc: '2.0', + method: 'set', + params: [{ obj: 'User Accounts', param: 'Validate', str: encrypted }], + }); + setTimeout(() => { + if (phase === 'device-login') { + send({ + jsonrpc: '2.0', + method: 'get', + params: [{ obj: 'User Accounts', param: 'Validate' }], + }); + } + }, 400); + }; + + ws.on('open', () => { + send({ jsonrpc: '2.0', method: 'auth', params: {} }); + }); + + ws.on('message', (buf) => { + let msg; + try { + msg = JSON.parse(buf.toString()); + } catch (err) { + finish(err); + return; + } + + if (phase === 'challenge' && msg.method === 'authResp') { + const challenge = msg.params?.resp; + if (!challenge || challenge === 'Welcome!') { + finish(new Error(`Unexpected auth challenge: ${JSON.stringify(msg.params)}`)); + return; + } + phase = 'welcome'; + send({ + jsonrpc: '2.0', + method: 'auth', + params: { reply: computeAuthReply(challenge) }, + }); + return; + } + + if (phase === 'welcome' && msg.method === 'authResp') { + if (msg.params?.resp !== 'Welcome!') { + finish(new Error(`Auth rejected: ${JSON.stringify(msg.params)}`)); + return; + } + send({ jsonrpc: '2.0', method: 'setSessionName', params: { name: sessionName } }); + if (doLogin) startDeviceLogin(); + else startDisable(); + return; + } + + if (phase === 'device-login' && msg.method === 'paramUpdate') { + const params = Array.isArray(msg.params) ? msg.params : [msg.params]; + const validate = params.find(p => p?.obj === 'User Accounts' && p?.param === 'Validate'); + if (!validate) return; + if (!validate.str || validate.str === 'Not Found') return; + startDisable(); + return; + } + + if (phase === 'verify' && msg.method === 'paramUpdate') { + const params = Array.isArray(msg.params) ? msg.params : [msg.params]; + for (const p of params) { + if (p?.obj === 'MonitorRouter') upsertMetric(byKey, p); + } + } + }); + + ws.on('error', (err) => finish(err)); + ws.on('close', (code, reason) => { + if (!settled && phase === 'verify' && byKey.size) { + completeVerify(); + return; + } + if (!settled && phase !== 'verify') { + finish(new Error(`WS closed early code=${code} reason=${reason?.toString?.() || ''} phase=${phase}`)); + } + }); + }); +} + +export async function fetchAzmCheckReport(wsUrl, opts = {}) { + const result = await fetchAzmMetrics(wsUrl, { + ...opts, + sessionName: opts.sessionName || 'check', + gets: opts.gets || CHECK_GETS, + meterSubscribes: opts.meterSubscribes ?? METER_SUBSCRIBES, + meterSampleMs: opts.meterSampleMs ?? 3000, + followUpGets: opts.followUpGets ?? MONITOR_SPEAKER_GETS, + followUpCollectMs: opts.followUpCollectMs ?? 1500, + collectMs: opts.collectMs ?? 4000, + timeoutMs: opts.timeoutMs ?? 45000, + }); + + const report = normalizeCheckReport(result.metrics, result.meterSamples || {}); + let monitorDisable = null; + let finalReport = report; + + if (report.monitorSpeaker?.status === 'On' && opts.autoDisableMonitor !== false) { + monitorDisable = { + attempted: true, + previousChannel: report.monitorSpeaker.inputChannel, + previousGain: report.monitorSpeaker.gain, + }; + try { + const off = await disableMonitorSpeaker(wsUrl, opts); + monitorDisable.ok = off.ok; + monitorDisable.error = off.error; + if (off.ok) { + finalReport = { ...report, monitorSpeaker: off.monitorSpeaker }; + } + } catch (err) { + monitorDisable.ok = false; + monitorDisable.error = err.message || String(err); + } + } + + return { + ...result, + report: finalReport, + monitorDisable, + markdown: formatCheckMarkdown(finalReport, { + storeNumber: opts.storeNumber, + deviceStatus: opts.deviceStatus, + monitorDisable, + }), + }; +} diff --git a/integrations/atlas/checkStore.js b/integrations/atlas/checkStore.js new file mode 100644 index 0000000..6eaccf8 --- /dev/null +++ b/integrations/atlas/checkStore.js @@ -0,0 +1,102 @@ +// integrations/atlas/checkStore.js +// /atlasdiag orchestrator: resolve → tunnel → AZM report → markdown + +import { openAuthenticatedTunnel } from './uiSession.js'; +import { fetchAzmCheckReport, formatCheckMarkdown } from './azmWs.js'; +import { + resolveStoreDevice, + isOffline, + deviceStatus, +} from './resolveStoreDevice.js'; +import { getAtlasDeviceList, getAtlasDeviceDetail } from './devices.js'; +import { XyteHttpError } from './errors.js'; + +/** + * Run a full store check against Atlas Hub + live AZM tunnel. + * + * @param {string|number} storeOrName + * @param {{ pollSeconds?: number }} [opts] + */ +export async function checkStore(storeOrName, { pollSeconds = 90 } = {}) { + const devices = await getAtlasDeviceList(); + const resolved = resolveStoreDevice(devices, storeOrName); + if (!resolved.device) { + return { + ok: false, + markdown: `❌ ${resolved.error || 'Device not found'}`, + error: resolved.error, + code: resolved.code, + matches: resolved.matches, + storePadded: resolved.storePadded, + }; + } + + let device = resolved.device; + const storeDisplay = resolved.storePadded + ? String(Number(resolved.storePadded)) + : storeOrName; + + try { + const fresh = await getAtlasDeviceDetail(device.id); + if (fresh) device = fresh; + } catch { + // Fall back to cached device if detail fetch fails. + } + + if (isOffline(device)) { + const st = deviceStatus(device) || 'offline'; + return { + ok: false, + markdown: `❌ **Store ${storeDisplay}** device **${device.name}** is ${st} — cannot open tunnel.`, + error: `Device offline (${st})`, + code: 'OFFLINE', + device, + storePadded: resolved.storePadded, + }; + } + + try { + const tunnel = await openAuthenticatedTunnel(device.id, { pollSeconds }); + const result = await fetchAzmCheckReport(tunnel.wsUrl, { + cookie: tunnel.cookie, + origin: tunnel.redirectUrl, + sessionName: 'check', + storeNumber: storeDisplay, + deviceStatus: deviceStatus(device), + }); + + const markdown = result.markdown || formatCheckMarkdown(result.report, { + storeNumber: storeDisplay, + deviceStatus: deviceStatus(device), + monitorDisable: result.monitorDisable, + }); + + return { + ok: true, + markdown, + device, + storePadded: resolved.storePadded, + report: result.report, + monitorDisable: result.monitorDisable, + deviceLogin: result.deviceLogin, + tunnel: { + redirectUrl: tunnel.redirectUrl, + wsUrl: tunnel.wsUrl, + connected: tunnel.status?.connected, + }, + }; + } catch (err) { + const message = err instanceof XyteHttpError + ? `${err.message}${err.status ? ` (${err.status})` : ''}` + : (err.message || String(err)); + + return { + ok: false, + markdown: `❌ **Store ${storeDisplay}** (${device.name}) check failed: ${message}`, + error: message, + code: err.code || 'CHECK_FAILED', + device, + storePadded: resolved.storePadded, + }; + } +} diff --git a/integrations/atlas/errors.js b/integrations/atlas/errors.js new file mode 100644 index 0000000..4bfc64b --- /dev/null +++ b/integrations/atlas/errors.js @@ -0,0 +1,11 @@ +// integrations/atlas/errors.js + +export class XyteHttpError extends Error { + constructor(message, { status, body, url } = {}) { + super(message); + this.name = 'XyteHttpError'; + this.status = status; + this.body = body; + this.url = url; + } +} diff --git a/integrations/atlas/resolveStoreDevice.js b/integrations/atlas/resolveStoreDevice.js new file mode 100644 index 0000000..a8704e5 --- /dev/null +++ b/integrations/atlas/resolveStoreDevice.js @@ -0,0 +1,107 @@ +// integrations/atlas/resolveStoreDevice.js +// Strict AMP store resolution for /atlasdiag (separate from avstatus includes() matching). + +export function deviceStatus(d) { + return String( + d?.status || d?.effective_status || d?.state?.status || d?.state?.effective_status || '', + ).toLowerCase().trim(); +} + +export function isOffline(d) { + const st = deviceStatus(d); + return st === 'offline' || st === 'disconnected' || st === 'never_seen'; +} + +/** + * Resolve by UUID, exact name, or unique partial name match. + * @throws {{ code: 'AMBIGUOUS' }} when multiple partial matches + */ +export function resolveDevice(devices, idOrName) { + if (!idOrName) return null; + const q = String(idOrName).trim(); + const byId = devices.find(d => d.id === q); + if (byId) return byId; + const lower = q.toLowerCase(); + const exact = devices.find(d => String(d.name || '').toLowerCase() === lower); + if (exact) return exact; + const partial = devices.filter(d => String(d.name || '').toLowerCase().includes(lower)); + if (partial.length === 1) return partial[0]; + if (partial.length > 1) { + const err = new Error( + `Ambiguous device "${q}": ${partial.slice(0, 5).map(d => d.name).join(', ')}`, + ); + err.code = 'AMBIGUOUS'; + err.matches = partial; + throw err; + } + return null; +} + +/** + * Pad a store number to 6 digits (2547 → 002547). + */ +export function padStoreNumber(storeNumber) { + const digits = String(storeNumber).replace(/\D/g, ''); + if (!digits) return null; + return digits.padStart(6, '0').slice(-6); +} + +/** + * Match device names like US002547AMP / CA000969AMP for a store number. + * + * @returns {{ device: object|null, storePadded: string|null, matches: object[], error?: string, code?: string }} + */ +export function resolveStoreDevice(devices, storeOrName) { + const q = String(storeOrName || '').trim(); + if (!q) { + return { device: null, storePadded: null, matches: [], error: 'Store number required', code: 'MISSING' }; + } + + if (!/^\d{1,6}$/.test(q)) { + try { + const device = resolveDevice(devices, q); + if (!device) { + return { device: null, storePadded: null, matches: [], error: `No device found for "${q}"`, code: 'NOT_FOUND' }; + } + const m = String(device.name || '').match(/^[A-Z]{2}(\d{6})AMP$/i); + return { device, storePadded: m ? m[1] : null, matches: [device] }; + } catch (err) { + if (err.code === 'AMBIGUOUS') { + return { + device: null, + storePadded: null, + matches: err.matches || [], + error: err.message, + code: 'AMBIGUOUS', + }; + } + throw err; + } + } + + const padded = padStoreNumber(q); + const re = new RegExp(`^[A-Z]{2}${padded}AMP$`, 'i'); + const matches = (devices || []).filter(d => re.test(String(d.name || ''))); + + if (matches.length === 0) { + return { + device: null, + storePadded: padded, + matches: [], + error: `No AZM found for store ${String(Number(padded))} (${padded})`, + code: 'NOT_FOUND', + }; + } + + if (matches.length > 1) { + return { + device: null, + storePadded: padded, + matches, + error: `Multiple AZMs for store ${String(Number(padded))}: ${matches.map(d => d.name).join(', ')}. Retry with a device name.`, + code: 'AMBIGUOUS', + }; + } + + return { device: matches[0], storePadded: padded, matches }; +} diff --git a/integrations/atlas/uiSession.js b/integrations/atlas/uiSession.js new file mode 100644 index 0000000..c398f9d --- /dev/null +++ b/integrations/atlas/uiSession.js @@ -0,0 +1,371 @@ +// integrations/atlas/uiSession.js +// Atmosphere / Xyte UI session API (tunnel URLs live here). +// Auth is Devise-token style headers from a logged-in UI session, NOT ATLAS_AUTH_KEY. + +import { fetchWithTimeout } from '../../utils/fetchWithTimeout.js'; +import { XyteHttpError } from './errors.js'; + +const HUB_BASE = 'https://hub.xyte.io'; +const UI_BASE = `${HUB_BASE}/ui/organization`; +const AUTH_BASE = `${HUB_BASE}/auth`; +const DESCOPE_API = 'https://api.descope.com/v1/auth'; +const PORTAL_ORIGIN = 'https://atmosphere.atlasied.com'; + +/** @type {Record|null} */ +let cachedSessionHeaders = null; + +export function getStaticUiSessionHeaders() { + const accessToken = process.env.ATLAS_UI_ACCESS_TOKEN; + const client = process.env.ATLAS_UI_CLIENT; + const expiry = process.env.ATLAS_UI_EXPIRY; + const uid = process.env.ATLAS_UI_UID; + const tenant = process.env.ATLAS_UI_TENANT; + if (!accessToken || !client || !expiry || !uid || !tenant) { + return null; + } + return buildSessionHeaders({ + token: accessToken, + client, + expiry, + uid, + tenant: { id: tenant, type: process.env.ATLAS_UI_TENANT_TYPE || 'organization' }, + }); +} + +export function hasUiEmailPassword() { + return !!(process.env.ATLAS_UI_EMAIL && process.env.ATLAS_UI_PASSWORD); +} + +export function hasUiSession() { + return hasUiEmailPassword() || !!getStaticUiSessionHeaders() || !!cachedSessionHeaders; +} + +export function getUiSessionHeaders() { + return cachedSessionHeaders || getStaticUiSessionHeaders(); +} + +function buildSessionHeaders({ token, client, expiry, uid, tenant }) { + const tenantId = typeof tenant === 'string' ? tenant : tenant?.id; + const tenantType = + (typeof tenant === 'object' && tenant?.type) || + process.env.ATLAS_UI_TENANT_TYPE || + 'organization'; + + const headers = { + 'Content-Type': 'application/json', + Accept: '*/*', + 'access-token': token, + client, + expiry: String(expiry), + uid, + 'token-type': process.env.ATLAS_UI_TOKEN_TYPE || 'Bearer', + origin: PORTAL_ORIGIN, + referer: `${PORTAL_ORIGIN}/`, + }; + + if (tenantId) { + headers.tenant = tenantId; + headers['tenant-type'] = tenantType; + } + + return headers; +} + +async function fetchPortalConfig() { + const response = await fetchWithTimeout( + `${UI_BASE}/portal_config`, + { + method: 'GET', + headers: { + Accept: 'application/json', + origin: PORTAL_ORIGIN, + referer: `${PORTAL_ORIGIN}/`, + }, + }, + 20000, + ); + const data = await response.json().catch(() => null); + if (!response.ok) { + throw new XyteHttpError( + `portal_config failed: ${response.status}`, + { status: response.status, body: data, url: `${UI_BASE}/portal_config` }, + ); + } + return data; +} + +export async function descopePasswordSignIn(email, password, projectId) { + const url = `${DESCOPE_API}/password/signin`; + const response = await fetchWithTimeout( + url, + { + method: 'POST', + headers: { + Authorization: `Bearer ${projectId}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ loginId: email, password }), + }, + 20000, + ); + const data = await response.json().catch(() => null); + if (!response.ok || !data?.sessionJwt) { + throw new XyteHttpError( + `Descope sign-in failed: ${response.status}`, + { status: response.status, body: data, url }, + ); + } + return data; +} + +export async function exchangeDescopeLogin(sessionJwt, { + tenantType = 'organization', + isSupportUser = false, + lastTenantId = null, +} = {}) { + const url = `${AUTH_BASE}/descope/login`; + const body = { + token: sessionJwt, + tenant_type: tenantType, + is_support_user: isSupportUser, + accepted_terms_and_conditions: true, + }; + if (lastTenantId) body.last_tenant_id = lastTenantId; + + const response = await fetchWithTimeout( + url, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + origin: PORTAL_ORIGIN, + referer: `${PORTAL_ORIGIN}/`, + }, + body: JSON.stringify(body), + }, + 20000, + ); + const data = await response.json().catch(() => null); + if (!response.ok || !data?.token) { + throw new XyteHttpError( + `Descope→UI login exchange failed: ${response.status}`, + { status: response.status, body: data, url }, + ); + } + return data; +} + +function resolveTenantId(login) { + if (process.env.ATLAS_UI_TENANT) return process.env.ATLAS_UI_TENANT; + if (login?.tenant?.id) return login.tenant.id; + if (typeof login?.tenant === 'string' && login.tenant) return login.tenant; + + const access = Array.isArray(login?.access) ? login.access : []; + const userId = login?.id; + const orgId = access.find(id => id && id !== userId); + return orgId || access[0] || null; +} + +export async function signInWithEmailPassword({ + email = process.env.ATLAS_UI_EMAIL, + password = process.env.ATLAS_UI_PASSWORD, + projectId = process.env.ATLAS_UI_DESCOPE_PROJECT_ID, + lastTenantId = process.env.ATLAS_UI_TENANT || null, +} = {}) { + if (!email || !password) { + const err = new Error('ATLAS_UI_EMAIL and ATLAS_UI_PASSWORD are required'); + err.code = 'MISSING_UI_CREDENTIALS'; + throw err; + } + + let descopeProjectId = projectId; + if (!descopeProjectId) { + const portal = await fetchPortalConfig(); + descopeProjectId = portal?.descope_project_id; + } + if (!descopeProjectId) { + throw new Error('Missing Descope project id (portal_config.descope_project_id)'); + } + + const descope = await descopePasswordSignIn(email, password, descopeProjectId); + const login = await exchangeDescopeLogin(descope.sessionJwt, { + tenantType: process.env.ATLAS_UI_TENANT_TYPE || 'organization', + lastTenantId, + }); + + const tenantId = resolveTenantId(login); + if (!tenantId) { + throw new Error( + 'UI login succeeded but no tenant id (set ATLAS_UI_TENANT to your org UUID)', + ); + } + + const headers = buildSessionHeaders({ + token: login.token, + client: login.client, + expiry: login.expiry, + uid: login.uid, + tenant: { id: tenantId, type: process.env.ATLAS_UI_TENANT_TYPE || 'organization' }, + }); + + cachedSessionHeaders = headers; + return { + headers, + login: { + email: login.email, + name: login.name, + uid: login.uid, + tenantId, + expiry: login.expiry, + }, + }; +} + +export async function ensureUiSession() { + if (cachedSessionHeaders) return cachedSessionHeaders; + + if (hasUiEmailPassword()) { + const { headers } = await signInWithEmailPassword(); + return headers; + } + + const staticHeaders = getStaticUiSessionHeaders(); + if (staticHeaders) { + cachedSessionHeaders = staticHeaders; + return staticHeaders; + } + + const err = new Error( + 'Missing UI session: set ATLAS_UI_EMAIL + ATLAS_UI_PASSWORD (preferred) or ATLAS_UI_* DevTools headers', + ); + err.code = 'MISSING_UI_SESSION'; + throw err; +} + +export function clearUiSessionCache() { + cachedSessionHeaders = null; +} + +async function uiFetch(path, { method = 'GET', body, headers, timeoutMs = 30000 } = {}) { + const session = headers || (await ensureUiSession()); + if (!session) { + const err = new Error( + 'Missing UI session (ATLAS_UI_EMAIL/PASSWORD or ATLAS_UI_* headers)', + ); + err.code = 'MISSING_UI_SESSION'; + throw err; + } + + const url = path.startsWith('http') ? path : `${UI_BASE}${path}`; + const options = { method, headers: { ...session } }; + if (body !== undefined) options.body = JSON.stringify(body); + + const response = await fetchWithTimeout(url, options, timeoutMs); + const text = await response.text().catch(() => ''); + let data = null; + if (text) { + try { + data = JSON.parse(text); + } catch { + data = text; + } + } + + if (!response.ok) { + throw new XyteHttpError( + `Xyte UI ${method} ${url} failed: ${response.status}`, + { status: response.status, body: data, url }, + ); + } + + return data; +} + +export async function uiOpenTunnel(deviceId, { command = 'Connect' } = {}) { + if (!deviceId) throw new Error('deviceId is required'); + const data = await uiFetch('/commands', { + method: 'POST', + body: { + command, + deviceIds: [deviceId], + extra_params: {}, + }, + }); + const cmd = Array.isArray(data) ? data[0] : data; + if (!cmd?.tunnel_redirect_url) { + throw new Error('UI Connect response missing tunnel_redirect_url'); + } + return cmd; +} + +export async function pollTunnelStatus(statusUrl, { timeoutSeconds = 90, intervalMs = 1000 } = {}) { + const deadline = Date.now() + timeoutSeconds * 1000; + let last = null; + while (Date.now() < deadline) { + const response = await fetchWithTimeout(statusUrl, { method: 'GET' }, 15000); + const text = await response.text(); + last = JSON.parse(text); + if (last?.connected) return last; + await new Promise(r => setTimeout(r, intervalMs)); + } + const err = new Error('Timed out waiting for tunnel connected=true'); + err.lastStatus = last; + throw err; +} + +function getSetCookies(res) { + if (typeof res.headers.getSetCookie === 'function') { + const arr = res.headers.getSetCookie(); + if (arr?.length) return arr; + } + const single = res.headers.get('set-cookie'); + return single ? [single] : []; +} + +export async function authenticateTunnel(authenticateUrl) { + const response = await fetchWithTimeout(authenticateUrl, { + method: 'GET', + headers: { Accept: 'text/html,application/xhtml+xml' }, + redirect: 'manual', + }, 20000); + + const setCookies = getSetCookies(response); + const cookie = setCookies.map(c => c.split(';')[0]).join('; '); + const location = response.headers.get('location'); + + if (response.status !== 307 && response.status !== 302) { + throw new XyteHttpError( + `Tunnel auth expected 307, got ${response.status}`, + { status: response.status, body: await response.text().catch(() => null), url: authenticateUrl }, + ); + } + if (!cookie.includes('xyte_auth=')) { + throw new Error('Tunnel auth did not return xyte_auth cookie'); + } + + return { + cookie, + redirectUrl: location, + status: response.status, + }; +} + +export async function openAuthenticatedTunnel(deviceId, { pollSeconds = 90 } = {}) { + await ensureUiSession(); + const command = await uiOpenTunnel(deviceId); + const status = await pollTunnelStatus(command.tunnel_status_url, { + timeoutSeconds: pollSeconds, + }); + const auth = await authenticateTunnel(command.tunnel_authenticate_url); + const redirectUrl = auth.redirectUrl || command.tunnel_redirect_url; + + return { + command, + status, + cookie: auth.cookie, + redirectUrl, + wsUrl: redirectUrl.replace(/^http/, 'ws') + '/ws', + }; +} + +export { UI_BASE, HUB_BASE, AUTH_BASE }; diff --git a/tests/atlasAzmWs.test.js b/tests/atlasAzmWs.test.js new file mode 100644 index 0000000..ebec5d4 --- /dev/null +++ b/tests/atlasAzmWs.test.js @@ -0,0 +1,94 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + formatCheckMarkdown, + formatUptime, + getSourceLevelDb, + isLowSourceLevel, +} from '../integrations/atlas/azmWs.js'; + +test('formatUptime: hours vs days', () => { + assert.equal(formatUptime(12), '12.0 hours'); + assert.equal(formatUptime(48), '2.0 days'); + assert.equal(formatUptime(null), 'Unknown'); +}); + +test('isLowSourceLevel: -80 to -70 dB band', () => { + assert.equal(isLowSourceLevel(-65), false); + assert.equal(isLowSourceLevel(-70), true); + assert.equal(isLowSourceLevel(-75), true); + assert.equal(isLowSourceLevel(-80), true); + assert.equal(isLowSourceLevel(-85), false); + assert.equal(isLowSourceLevel(null), false); +}); + +test('getSourceLevelDb: prefers meter avg over gain setpoint', () => { + assert.equal(getSourceLevelDb({ volume: { avg: -72.1 }, db: '-20' }), -72.1); + assert.equal(getSourceLevelDb({ db: '-74.5' }), -74.5); + assert.equal(getSourceLevelDb({}), null); +}); + +test('formatCheckMarkdown: shows monitor auto-disable success', () => { + const md = formatCheckMarkdown({ + projectName: 'Test Store', + deviceName: 'US002547AMP', + uptimeHours: 12, + sources: [], + zones: [], + accessories: [], + monitorSpeaker: { status: 'Off', inputChannel: '' }, + }, { + storeNumber: '2547', + monitorDisable: { + attempted: true, + ok: true, + previousChannel: 'Sales Floor', + }, + }); + + assert.match(md, /Monitor speaker — Off \(auto-disabled; was listening to \*\*Sales Floor\*\*\)/); +}); + +test('formatCheckMarkdown: shows monitor auto-disable failure', () => { + const md = formatCheckMarkdown({ + projectName: 'Test Store', + deviceName: 'US002547AMP', + uptimeHours: 12, + sources: [], + zones: [], + accessories: [], + monitorSpeaker: { status: 'On', gain: '-18', inputChannel: 'Sales Floor' }, + }, { + monitorDisable: { + attempted: true, + ok: false, + error: 'Monitor still On', + }, + }); + + assert.match(md, /Monitor speaker — On/); + assert.match(md, /auto-disable failed: Monitor still On/); +}); + +test('formatCheckMarkdown: flags low source levels', () => { + const md = formatCheckMarkdown({ + projectName: 'Test Store', + deviceName: 'US002547AMP', + model: 'AZM-8', + uptimeHours: 30, + sources: [ + { name: 'BGM', muted: false, db: '-20', volume: null }, + { name: 'Mic', muted: false, db: '-74', volume: { low: -76, avg: -74.2, high: -72 } }, + ], + zones: [], + accessories: [], + monitorSpeaker: { status: 'Not present' }, + }, { storeNumber: '2547', deviceStatus: 'online' }); + + assert.match(md, /⚠️ 1 source\(s\) with low input level/); + assert.match(md, /⚠️ .*Mic.*low level: -74\.2 dB/); + assert.doesNotMatch(md, /⚠️ .*BGM/); + assert.match(md, /\*\*Sources\*\*/); + assert.match(md, /\*\*Zones\*\*/); +}); diff --git a/tests/atlasResolveStoreDevice.test.js b/tests/atlasResolveStoreDevice.test.js new file mode 100644 index 0000000..2d08ab0 --- /dev/null +++ b/tests/atlasResolveStoreDevice.test.js @@ -0,0 +1,40 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + padStoreNumber, + resolveStoreDevice, +} from '../integrations/atlas/resolveStoreDevice.js'; + +const DEVICES = [ + { id: '1', name: 'US002547AMP', status: 'online' }, + { id: '2', name: 'US002548AMP', status: 'online' }, + { id: '3', name: 'US000305XYZ', status: 'online' }, +]; + +test('padStoreNumber: pads to 6 digits', () => { + assert.equal(padStoreNumber('2547'), '002547'); + assert.equal(padStoreNumber('305'), '000305'); + assert.equal(padStoreNumber(''), null); +}); + +test('resolveStoreDevice: strict AMP regex by store number', () => { + const hit = resolveStoreDevice(DEVICES, '2547'); + assert.equal(hit.device?.name, 'US002547AMP'); + assert.equal(hit.storePadded, '002547'); + + const miss = resolveStoreDevice(DEVICES, '305'); + assert.equal(miss.code, 'NOT_FOUND'); + assert.equal(miss.device, null); +}); + +test('resolveStoreDevice: resolves by full device name', () => { + const hit = resolveStoreDevice(DEVICES, 'US002548AMP'); + assert.equal(hit.device?.id, '2'); + assert.equal(hit.storePadded, '002548'); +}); + +test('resolveStoreDevice: missing store returns MISSING', () => { + const miss = resolveStoreDevice(DEVICES, ''); + assert.equal(miss.code, 'MISSING'); +}); diff --git a/utils/fetchWithTimeout.js b/utils/fetchWithTimeout.js new file mode 100644 index 0000000..7166cd3 --- /dev/null +++ b/utils/fetchWithTimeout.js @@ -0,0 +1,12 @@ +// utils/fetchWithTimeout.js +// Native fetch wrapper with AbortSignal timeout (Node 20+). + +export async function fetchWithTimeout(url, options = {}, timeoutMs = 15000) { + const response = await fetch(url, { + ...options, + signal: AbortSignal.timeout(timeoutMs), + }); + return response; +} + +export default fetchWithTimeout;