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>
876 lines
28 KiB
JavaScript
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,
|
|
}),
|
|
};
|
|
}
|