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:
parent
7dc326c404
commit
5e54ea6f57
12 changed files with 1704 additions and 2 deletions
24
.env.example
24
.env.example
|
|
@ -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
45
commands/atlasDiag.js
Normal 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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 (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: {
|
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'] },
|
||||||
|
|
|
||||||
|
|
@ -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
876
integrations/atlas/azmWs.js
Normal 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,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
102
integrations/atlas/checkStore.js
Normal file
102
integrations/atlas/checkStore.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
11
integrations/atlas/errors.js
Normal file
11
integrations/atlas/errors.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
107
integrations/atlas/resolveStoreDevice.js
Normal file
107
integrations/atlas/resolveStoreDevice.js
Normal 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 };
|
||||||
|
}
|
||||||
371
integrations/atlas/uiSession.js
Normal file
371
integrations/atlas/uiSession.js
Normal 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
94
tests/atlasAzmWs.test.js
Normal 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\*\*/);
|
||||||
|
});
|
||||||
40
tests/atlasResolveStoreDevice.test.js
Normal file
40
tests/atlasResolveStoreDevice.test.js
Normal 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
12
utils/fetchWithTimeout.js
Normal 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;
|
||||||
Loading…
Reference in a new issue