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 <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-29 17:59:17 -04:00
parent 7dc326c404
commit 5e54ea6f57
12 changed files with 1704 additions and 2 deletions

View file

@ -436,10 +436,32 @@ CORP_WS1_CLIENT_SECRET=...
CORP_WS1_TENANT_CODE=... 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 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 # ServiceChannel
# OAuth password grant against ServiceChannel's identity endpoint. # OAuth password grant against ServiceChannel's identity endpoint.

45
commands/atlasDiag.js Normal file
View file

@ -0,0 +1,45 @@
// commands/atlasDiag.js
//
// /atlasdiag <storeOrName>
//
// 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)}`);
}
}

View file

@ -20,6 +20,7 @@ const SHORT_HELP = {
avstatus: 'AV / device status for a store (alias: /wostatus)', avstatus: 'AV / device status for a store (alias: /wostatus)',
voicestatus: 'Quick DECT + IP phone status for a store', voicestatus: 'Quick DECT + IP phone status for a store',
wanstatus: 'Prisma SD-WAN health + voice traffic quality', 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)', phonediag: 'MPP desk phone relay diagnostics (CP-7841)',
dectdiag: 'Full DECT base dump via relay (handsets, RSSI, reboot cards)', dectdiag: 'Full DECT base dump via relay (handsets, RSSI, reboot cards)',
voicediag: 'Deep voice diagnostic: features + WAN + relay probes with fix 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`.', 'Web dashboard: `/phone-store-dashboard.html`.',
], ],
}, },
atlasdiag: {
title: '/atlasdiag',
usage: [
'/atlasdiag <store>',
'/atlasdiag <deviceName>',
],
examples: [
'/atlasdiag 2547',
'/atlasdiag US002547AMP',
],
notes: [
'Live AZM diagnostic via Atlas UI tunnel + WebSocket (3090s).',
'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: { wanstatus: {
title: '/wanstatus', title: '/wanstatus',
usage: [ usage: [
@ -352,7 +372,7 @@ const LONG_HELP = {
const GROUPS = [ const GROUPS = [
{ title: 'Work orders', keys: ['wohistory', 'wosummary', 'woattachments'] }, { 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: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] },
{ title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] }, { title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] },
{ title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] }, { title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] },

View file

@ -34,6 +34,7 @@ import { handleBulkAvSwitchCSV } from './bulkAvSwitchCSV.js';
import { handleTestDevicesByModel } from './testDevicesByModel.js'; import { handleTestDevicesByModel } from './testDevicesByModel.js';
import { handleCallTest } from './callTest.js'; import { handleCallTest } from './callTest.js';
import { handleCallReport } from './callReport.js'; import { handleCallReport } from './callReport.js';
import { handleAtlasDiag } from './atlasDiag.js';
/** /**
* Each entry: * Each entry:
@ -50,6 +51,7 @@ export const commands = [
{ name: 'avstatus', aliases: ['wostatus'], handler: handleAvStatus, mutating: false }, { name: 'avstatus', aliases: ['wostatus'], handler: handleAvStatus, mutating: false },
{ name: 'voicestatus', handler: handleVoiceStatus, mutating: false }, { name: 'voicestatus', handler: handleVoiceStatus, mutating: false },
{ name: 'wanstatus', handler: handleWanStatus, mutating: false }, { name: 'wanstatus', handler: handleWanStatus, mutating: false },
{ name: 'atlasdiag', handler: handleAtlasDiag, mutating: false },
{ name: 'phonediag', handler: handlePhoneDiag, mutating: false }, { name: 'phonediag', handler: handlePhoneDiag, mutating: false },
// /dectdiag — full DECT base dump via the on-prem relay + chat-only // /dectdiag — full DECT base dump via the on-prem relay + chat-only
// reboot/factory-reset cards. Read path is non-mutating; card submits // reboot/factory-reset cards. Read path is non-mutating; card submits

876
integrations/atlas/azmWs.js Normal file
View file

@ -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 (<none>) 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,
}),
};
}

View file

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

View file

@ -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;
}
}

View file

@ -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 };
}

View file

@ -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<string, string>|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 };

94
tests/atlasAzmWs.test.js Normal file
View file

@ -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: '<none>' },
}, {
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\*\*/);
});

View file

@ -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');
});

12
utils/fetchWithTimeout.js Normal file
View file

@ -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;