Active Macro. Adds a “Zoom” button to the home screen.
-
Prompts for meeting ID (9–13 digits) and optional numeric passcode
-
Calls detectDualScreen() — queries all video output connectors
-
Calls resolveLayoutSuffix() — returns “608” if dual screen, “” if single
-
Builds dial string: ..@zoomcrc.com
-
Dials via xapi.Command.Dial with CallType: Video
Macro File:
/*!
*
* Adds a "Zoom" button to the home screen. Tapping it prompts for a Zoom
* meeting ID, then optionally a passcode, and dials the Zoom Cloud Room
* Connector.
*
* The SIP dial string uses dot-separated fields, any of which may be empty,
* per Zoom's documentation:
*
* <meetingId>.<passcode>.<layout>@zoomcrc.com
*
* 812345678 ID only
* 812345678.12345 ID + passcode
* 812345678..608 ID + dual-screen layout, no passcode
*
*
* Dial string reference:
* Starting a meeting from a SIP/H.323 endpoint
*
*
* Companion file: zoom-macro-settings.js (must be loaded as its own macro,
* named exactly "zoom-macro-settings", and may be left deactivated).
*/
import xapi from 'xapi';
const VERSION = 'v1.0.0';
const PANEL_ID = 'zoom_panel';
const SETTINGS_MACRO_NAME = 'zoom-macro-settings';
const SETTINGS_CONST_NAME = 'zoom_macro_settings';
// Max characters RoomOS will render in a TextInput body.
const MAX_TEXT_LENGTH = 128;
const DEFAULT_SETTINGS = {
service: {
domain: 'zoomcrc.com',
layoutSuffix: '',
dualScreen: 'Auto',
},
zoom: {
meetingId: {
regex: '^([0-9]{9,13})$',
regexDescription: 'Numeric Meeting ID. Example: 812 3456 7890',
invalidRegexMaxTries: 99,
},
passcode: {
regex: '^[0-9]{1,16}$',
regexDescription: 'Numeric passcode, or leave blank to skip',
invalidRegexMaxTries: 99,
required: false,
},
},
button: {
icon: 'Camera',
name: 'Zoom',
},
custom: {
name: '',
},
};
/* ------------------------------------------------------------------ *
* Logging
* ------------------------------------------------------------------ */
function log(level, method, msg, event) {
const detail = event ? ` ${JSON.stringify(event)}` : '';
console[level](`${VERSION} ${level.toUpperCase()} method="${method}": "${msg}"${detail}`);
}
const logger = {
info: (method, msg, event) => log('info', method, msg, event),
warn: (method, msg, event) => log('warn', method, msg, event),
error: (method, msg, event) => log('error', method, msg, event),
debug: (method, msg, event) => log('debug', method, msg, event),
};
// Never write a passcode to the console in clear text.
// Fields are <meetingId>.<passcode>.<layout> - mask field 2.
function maskSecrets(number) {
const at = String(number).indexOf('@');
const local = at === -1 ? String(number) : String(number).slice(0, at);
const domain = at === -1 ? '' : String(number).slice(at);
const masked = local
.split('.')
.map((field, index) => (index === 1 && field ? '*'.repeat(field.length) : field));
return masked.join('.') + domain;
}
/* ------------------------------------------------------------------ *
* Settings
* ------------------------------------------------------------------ */
function mergeDefaults(target, defaults) {
const out = Array.isArray(defaults) ? [] : {};
Object.keys(defaults).forEach((key) => {
const defaultValue = defaults[key];
const value = target ? target[key] : undefined;
if (defaultValue && typeof defaultValue === 'object' && !Array.isArray(defaultValue)) {
out[key] = mergeDefaults(value, defaultValue);
} else {
out[key] = value === undefined || value === null || value === '' ? defaultValue : value;
}
});
return out;
}
async function readSettings() {
try {
const result = await xapi.Command.Macros.Macro.Get({ Content: true, Name: SETTINGS_MACRO_NAME });
const content = result.Macro[0].Content;
const start = content.indexOf(SETTINGS_CONST_NAME);
if (start === -1) throw new Error(`Could not find "${SETTINGS_CONST_NAME}" in ${SETTINGS_MACRO_NAME}`);
const braceStart = content.indexOf('{', start);
if (braceStart === -1) throw new Error('Settings object is malformed (no opening brace)');
// Walk the braces so trailing content in the file does not break parsing.
let depth = 0;
let braceEnd = -1;
for (let i = braceStart; i < content.length; i += 1) {
if (content[i] === '{') depth += 1;
if (content[i] === '}') {
depth -= 1;
if (depth === 0) {
braceEnd = i;
break;
}
}
}
if (braceEnd === -1) throw new Error('Settings object is malformed (no closing brace)');
// Strip // comments so the settings file can stay documented.
const json = content
.slice(braceStart, braceEnd + 1)
.replace(/^\s*\/\/.*$/gm, '');
return mergeDefaults(JSON.parse(json), DEFAULT_SETTINGS);
} catch (error) {
logger.warn('readSettings', 'Could not read settings macro, using defaults', { error: error.message });
return mergeDefaults({}, DEFAULT_SETTINGS);
}
}
/* ------------------------------------------------------------------ *
* UI panel
* ------------------------------------------------------------------ */
function escapeXml(value) {
return String(value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
function buildPanelXml(button) {
return `<Extensions>
<Version>1.7</Version>
<Panel>
<PanelId>${PANEL_ID}</PanelId>
<Origin>local</Origin>
<Type>Home</Type>
<Icon>${escapeXml(button.icon)}</Icon>
<Color>#2D8CFF</Color>
<Name>${escapeXml(button.name)}</Name>
<ActivityType>Custom</ActivityType>
</Panel>
</Extensions>`;
}
async function savePanel(button) {
try {
await xapi.Command.UserInterface.Extensions.Panel.Save({ PanelId: PANEL_ID }, buildPanelXml(button));
logger.info('savePanel', 'Zoom panel saved', { panelId: PANEL_ID, name: button.name });
return true;
} catch (error) {
logger.error('savePanel', 'Failed to save Zoom panel', { error: error.message });
return false;
}
}
/* ------------------------------------------------------------------ *
* Text input prompts
* ------------------------------------------------------------------ */
function uuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
});
}
// Resolves with the entered string, or null if the user dismissed the dialog.
function promptText({ title, text, placeholder, submitText, inputType = 'Numeric', keyboardState = 'Open' }) {
return new Promise((resolve, reject) => {
const feedbackId = uuid();
let offResponse;
let offClear;
const finish = (value) => {
if (offResponse) offResponse();
if (offClear) offClear();
resolve(value);
};
offResponse = xapi.Event.UserInterface.Message.TextInput.Response.on((event) => {
if (event.FeedbackId === feedbackId) finish(event.Text === undefined ? '' : event.Text);
});
offClear = xapi.Event.UserInterface.Message.TextInput.Clear.on((event) => {
if (event.FeedbackId === feedbackId) finish(null);
});
xapi.Command.UserInterface.Message.TextInput.Display({
FeedbackId: feedbackId,
InputType: inputType,
KeyboardState: keyboardState,
Title: title,
Text: text,
Placeholder: placeholder,
SubmitText: submitText,
}).catch((error) => {
if (offResponse) offResponse();
if (offClear) offClear();
reject(error);
});
});
}
async function showError(text) {
await xapi.Command.UserInterface.Message.Alert.Display({
Title: 'Zoom',
Text: text,
Duration: 8,
}).catch(() => undefined);
}
/* ------------------------------------------------------------------ *
* Prompt flow
* ------------------------------------------------------------------ */
const MEETING_ID_FIRST = 'Enter the Zoom meeting ID:';
const MEETING_ID_RETRY = 'That meeting ID is not valid. Please try again.';
const PASSCODE_FIRST = 'Enter the meeting passcode:';
const PASSCODE_RETRY = 'That passcode is not valid. Please try again.';
// Keep the prompt body inside the RoomOS character limit.
function fitDescription(prompts, description) {
const longest = prompts.reduce((a, b) => (a.length > b.length ? a : b));
const budget = MAX_TEXT_LENGTH - longest.length - '<br>()'.length;
if (description.length <= budget) return description;
logger.warn('fitDescription', 'Regex description truncated to fit the dialog', {
length: description.length,
max: budget,
});
return `${description.slice(0, Math.max(budget - 1, 0))}…`;
}
// Returns the validated string, '' if skipped, or null if the user cancelled.
async function askValidated({
title,
prompts,
description,
placeholder,
submitText,
regex,
maxTries,
allowEmpty,
inputType,
}) {
const safeDescription = fitDescription(prompts, description);
const tries = Math.max(1, Number(maxTries) || 1);
for (let attempt = 0; attempt < tries; attempt += 1) {
const prompt = attempt === 0 ? prompts[0] : prompts[1];
const value = await promptText({
title,
text: `${prompt}<br>(${safeDescription})`,
placeholder,
submitText,
inputType,
});
if (value === null) return null;
const trimmed = value.trim().replace(/\s/g, '');
if (trimmed === '' && allowEmpty) return '';
if (regex.test(trimmed)) return trimmed;
}
throw new Error(`Maximum number of attempts reached (${tries})`);
}
/* ------------------------------------------------------------------ *
* Dialling
* ------------------------------------------------------------------ */
// <meetingId>.<passcode>.<layout>@domain
// Zoom tolerates empty fields between the dots, so any field can be omitted
// as long as the ones after it keep their position. Trailing empties are
// dropped so a plain join stays a plain "<meetingId>@domain".
function buildDialString({ meetingId, passcode, layoutSuffix, domain }) {
const fields = [meetingId, passcode || '', layoutSuffix || ''];
while (fields.length > 1 && fields[fields.length - 1] === '') fields.pop();
return `${fields.join('.')}@${String(domain).replace(/^@/, '')}`;
}
async function detectDualScreen() {
try {
const connectors = await xapi.Status.Video.Output.Connector.get();
const connected = connectors.filter((c) => c.Connected === 'True');
return connected.length >= 2;
} catch (e) {
logger.warn('detectDualScreen', 'Could not check video outputs, defaulting to single screen', { error: e.message });
return false;
}
}
async function resolveLayoutSuffix(settings) {
const mode = settings.service.dualScreen;
if (mode === 'On') return '608';
if (mode === 'Off') return settings.service.layoutSuffix;
const isDual = await detectDualScreen();
logger.info('resolveLayoutSuffix', 'Dual screen detection result', { isDual });
return isDual ? '608' : settings.service.layoutSuffix;
}
async function joinZoomCall(settings) {
try {
const meetingIdRules = settings.zoom.meetingId;
const passcodeRules = settings.zoom.passcode;
let meetingIdRegex;
try {
meetingIdRegex = new RegExp(meetingIdRules.regex);
} catch (e) {
logger.warn('joinZoomCall', 'Invalid meetingId regex in settings, using default', { error: e.message });
meetingIdRegex = new RegExp(DEFAULT_SETTINGS.zoom.meetingId.regex);
}
const meetingId = await askValidated({
title: 'Join a Zoom meeting',
prompts: [MEETING_ID_FIRST, MEETING_ID_RETRY],
description: meetingIdRules.regexDescription,
placeholder: 'Meeting ID',
submitText: 'Next',
regex: meetingIdRegex,
maxTries: meetingIdRules.invalidRegexMaxTries,
allowEmpty: false,
});
if (meetingId === null) {
logger.info('joinZoomCall', 'User cancelled at the meeting ID prompt');
return;
}
let passcodeRegex;
try {
passcodeRegex = new RegExp(passcodeRules.regex);
} catch (e) {
logger.warn('joinZoomCall', 'Invalid passcode regex in settings, using default', { error: e.message });
passcodeRegex = new RegExp(DEFAULT_SETTINGS.zoom.passcode.regex);
}
const passcode = await askValidated({
title: 'Join a Zoom meeting',
prompts: [PASSCODE_FIRST, PASSCODE_RETRY],
description: passcodeRules.regexDescription,
placeholder: 'Passcode',
submitText: 'Call',
regex: passcodeRegex,
maxTries: passcodeRules.invalidRegexMaxTries,
allowEmpty: passcodeRules.required !== true,
});
if (passcode === null) {
logger.info('joinZoomCall', 'User cancelled at the passcode prompt');
return;
}
const layoutSuffix = await resolveLayoutSuffix(settings);
const number = buildDialString({
meetingId,
passcode,
layoutSuffix,
domain: settings.service.domain,
});
const call = await xapi.Command.Dial({ Number: number, CallType: 'Video' });
logger.info('joinZoomCall', 'Call has been initiated', {
number: maskSecrets(number),
call,
});
} catch (error) {
logger.error('joinZoomCall', 'Failed to join Zoom call', { error: error.message });
await showError(`Could not start the Zoom call: ${error.message}`);
}
}
/* ------------------------------------------------------------------ *
* Main
* ------------------------------------------------------------------ */
async function main() {
logger.info('main', 'Starting macro', { name: 'Zoom-Macro', version: VERSION });
const settings = await readSettings();
const button = {
icon: settings.button.icon,
name: settings.custom.name || settings.button.name,
};
const saved = await savePanel(button);
if (!saved) return;
xapi.Event.UserInterface.Extensions.Panel.Clicked.on((event) => {
if (event.PanelId !== PANEL_ID) return;
logger.info('panelListener', 'Zoom panel clicked');
joinZoomCall(settings);
});
}
main();
Inactive companion config file. Read by zoom-macro.js at startup. Keeps tuneable values out of the macro logic so the macro doesn’t need to be edited for configuration changes.
Current settings:
-
service.domain: zoomcrc.com
-
service.layoutSuffix: “” (fallback when single screen)
-
service.dualScreen: “Auto” (detect at dial time)
-
zoom.meetingId.regex: ^([0-9]{9,13})$
-
zoom.passcode.required: false
-
button.icon: Camera, button.name: Zoom
Macro Settings File:
const zoom_macro_settings = {
"service": {
// Zoom Cloud Room Connector domain. Leave as zoomcrc.com for direct SIP
// dialling, or replace for another gateway domain.
"domain": "zoomcrc.com",
// Used when dualScreen is "Off". Set "608" for fixed dual-screen, "" for single.
"layoutSuffix": "",
// "Auto" = detect HDMI outputs at dial time; use 608 only if both connectors are connected.
// "On" = always dial with dual-screen layout (608).
// "Off" = always use the layoutSuffix value above.
"dualScreen": "Auto"
},
"zoom": {
"meetingId": {
"regex": "^([0-9]{9,13})$",
"regexDescription": 'Numeric Meeting ID. Example: 812 3456 7890',
"invalidRegexMaxTries": 99
},
"passcode": {
// Numeric passcodes only - the Zoom CRC dial string cannot carry letters.
"regex": "^[0-9]{1,16}$",
"regexDescription": 'Numeric passcode, or leave blank to skip',
"invalidRegexMaxTries": 99,
// false = user may submit an empty passcode and the macro dials without one
"required": false
}
},
"button": {
// Any built-in RoomOS panel icon: Camera, Handset, Home, Media, Tv,
// Webex, Proximity, Concierge, Info, General ...
"icon": "Camera",
"name": "Zoom"
},
"custom": {
"name": ""
}
}