collabSupport/integrations/atlas/azmWs.js
jmcqueen 5e54ea6f57 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>
2026-07-29 17:59:17 -04:00

876 lines
28 KiB
JavaScript

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