Endpoint Macros
A customer gave me this macro, and I tweaked it slightly. This macro automatically disconnects a call if the room has been empty for 20 minutes.
The macro watches RoomAnalytics.PeopleCount while a call is connected. If the room reports zero people for 20 minutes, it shows an on-screen countdown for the final 60 seconds, then disconnects the call. Anyone in the room can dismiss the countdown and stay connected.
Why use this?
Rooms get left in an active call when a meeting ends, and nobody hangs up - screen shares left up, calls left running unattended, licenses/bandwidth tied up for no reason. Rather than relying on people to remember to hang up, the device can detect an empty room itself and end the call after a grace period, with a clear on-screen warning so nobody gets disconnected mid-meeting by mistake.
Supported models
This depends on Status.RoomAnalytics.PeopleCountwhich Cisco only implements on devices with a room-sensing camera:
Supported: Room Kit, Room Kit Mini, Room Kit Plus, Room 55, Room 55 Dual, Room 70, Room 70 G2, Room Panorama, Room 70 Panorama, Desk Pro, Boards
Not supported: DX70, DX80, Desk/Desk Mini, and other personal/desk endpoints, plus legacy SX/MX codecs - these have no PeopleCount sensor, so the status read fails and the macro becomes a no-op.
Macro
/**
* 20-Minute Empty-Room Auto-Disconnect
* -------------------------------------
* Watches RoomAnalytics.PeopleCount and the active call state. If the room
* has been empty (PeopleCount == 0) for EMPTY_TIMEOUT_MS while a call is
* connected, the macro warns the room with an on-screen countdown for the
* final COUNTDOWN_SECONDS and then disconnects all active calls.
*
* Design notes (see inline comments for detail):
* - State is driven by BOTH the PeopleCount change event (fast path) and a
* periodic poll inside the main scheduler (safety net), since change
* events can occasionally be missed or delayed.
* - A sensor read error is treated as "unknown", never as "empty" - a flaky
* sensor should never cause a false disconnect. Fail-safe = fail open.
* - Pressing "Stay Connected" grants a temporary override window
* (OVERRIDE_SNOOZE_MS) during which the macro will not re-enter the empty
* state, even if PeopleCount is still reporting 0.
*
* Supported models:
* This macro depends on Status.RoomAnalytics.PeopleCount, which Cisco only
* implements on devices with a room-sensing camera. Confirmed supported:
* Room Kit, Room Kit Mini, Room Kit Plus, Room 55, Room 55 Dual,
* Room 70, Room 70 G2, Room Panorama, Room 70 Panorama, Desk Pro, Boards
* NOT supported (no PeopleCount sensor - getPeopleCount() will always
* return null via the catch block, so the room is never auto-detected as
* empty and the macro effectively becomes a no-op):
* DX70, DX80, Desk/Desk Mini, and other personal/desk endpoints, plus
* legacy SX/MX codecs
* If deploying to an unsupported model, either remove the PeopleCount
* dependency or drive emptiness off call duration alone.
*/
import xapi from 'xapi';
// ---------------- Configuration ----------------
const EMPTY_TIMEOUT_MS = 20 * 60 * 1000; // Disconnect after 20 empty minutes
const COUNTDOWN_SECONDS = 60; // Show on-screen countdown for the final 60s
const TICK_MS = 5 * 1000; // Main scheduler / poll interval
const OVERRIDE_SNOOZE_MS = 5 * 60 * 1000; // Grace period after "Stay Connected" before re-arming
// ---------------- State ----------------
let emptySinceMs = null; // Timestamp the room was first detected empty, or null
let scheduledDisconnectMs = null; // emptySinceMs + EMPTY_TIMEOUT_MS, or null
let countdownInterval = null; // setInterval handle for the on-screen countdown
let lastPeopleCount = null; // Most recently observed PeopleCount (event or poll)
let lastStateLoggedAt = 0; // Throttle for the "still empty" progress log
let overrideActiveUntilMs = null; // While set (and in the future), empty-state entry is suppressed
function log(msg) {
console.log(`[AutoDisconnect] ${msg}`);
}
// ---------------- Safe Status Helpers ----------------
// Returns the current people count, or null if the read failed / was unusable.
// Returning null (rather than 0) matters: callers must not treat "the sensor
// couldn't be read" the same as "the room is confirmed empty".
async function getPeopleCount() {
try {
const raw = await xapi.Status.RoomAnalytics.PeopleCount.Current.get();
const count = Number(raw);
return Number.isFinite(count) ? count : null;
} catch (e) {
log(`PeopleCount read error: ${e}`);
return null;
}
}
async function getCallsSafe() {
try {
const calls = await xapi.Status.Call.get();
if (!calls) return [];
return Array.isArray(calls) ? calls : [calls];
} catch (e) {
log(`Call status read error: ${e}`);
return [];
}
}
async function hasActiveCall() {
const calls = await getCallsSafe();
return calls.length > 0;
}
async function disconnectCalls() {
const calls = await getCallsSafe();
if (calls.length === 0) {
log('Disconnect requested but no calls present');
return;
}
for (const call of calls) {
// Explicit undefined/null check - CallId 0 is a valid id and would be
// wrongly skipped by a plain truthy check like `if (call.id)`.
if (call.id !== undefined && call.id !== null) {
log(`Disconnecting CallId ${call.id}`);
try {
await xapi.Command.Call.Disconnect({ CallId: call.id });
} catch (e) {
log(`Disconnect failed for CallId ${call.id}: ${e}`);
}
}
}
}
// ---------------- Countdown UI ----------------
function showCountdown(seconds) {
xapi.Command.UserInterface.Message.Prompt.Display({
Title: 'Room Empty',
Text: `No people detected.\n\nDisconnecting in ${seconds} seconds.`,
FeedbackId: 'empty_room_countdown',
'Option.1': 'Stay Connected'
}).catch(() => {});
}
function clearCountdown() {
xapi.Command.UserInterface.Message.Prompt.Clear({
FeedbackId: 'empty_room_countdown'
}).catch(() => {});
}
function startCountdown(secondsRemaining) {
if (countdownInterval) return; // already running - don't stack timers
log(`Countdown starting (${secondsRemaining}s remaining)`);
let remaining = secondsRemaining;
showCountdown(remaining);
countdownInterval = setInterval(() => {
remaining -= 1;
if (remaining <= 0) {
// The actual disconnect is triggered by the main scheduler's
// remainingMs<=0 branch; this timer only owns the on-screen display.
clearInterval(countdownInterval);
countdownInterval = null;
return;
}
showCountdown(remaining);
}, 1000);
}
function stopCountdown() {
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
clearCountdown();
}
// ---------------- Empty State Control ----------------
function enterEmptyState(reason) {
if (emptySinceMs !== null) return; // already tracking emptiness - don't reset the clock
// Respect an active "Stay Connected" override: don't let a poll or event
// immediately re-arm the timer while the user's grace window is running.
if (overrideActiveUntilMs !== null) {
if (Date.now() < overrideActiveUntilMs) {
return;
}
overrideActiveUntilMs = null; // grace window has expired, clear it
}
emptySinceMs = Date.now();
scheduledDisconnectMs = emptySinceMs + EMPTY_TIMEOUT_MS;
stopCountdown();
log(`EMPTY state entered (${reason})`);
log(`Scheduled disconnect at ${new Date(scheduledDisconnectMs).toLocaleTimeString()}`);
}
function exitEmptyState(reason) {
if (emptySinceMs === null) return;
const durationSec = Math.floor((Date.now() - emptySinceMs) / 1000);
log(`EMPTY state exited (${reason}) after ${durationSec}s`);
emptySinceMs = null;
scheduledDisconnectMs = null;
stopCountdown();
}
// ---------------- PeopleCount Events (fast path) ----------------
// Fires on every change, so the common case reacts immediately. The
// scheduler below polls the same status as a fallback in case an event is
// ever missed or delayed by the endpoint.
xapi.Status.RoomAnalytics.PeopleCount.Current.on(raw => {
const count = Number(raw);
if (!Number.isFinite(count)) {
log(`PeopleCount event unusable: raw="${raw}"`);
return;
}
lastPeopleCount = count;
log(`PeopleCount event raw="${raw}" -> ${count}`);
if (count === 0) enterEmptyState('people_count_zero_event');
else exitEmptyState('people_detected_event');
});
// ---------------- SIP Call Connected ----------------
xapi.Event.CallSuccessful.on(async () => {
log('Call connected');
const people = await getPeopleCount();
if (people === null) {
// Unknown occupancy - fail safe and assume occupied rather than start a
// disconnect timer off the back of a bad sensor reading.
log('People count unavailable at call connect; assuming occupied');
return;
}
log(`People at call connect: ${people}`);
if (people === 0) {
enterEmptyState('call_connected_empty_room');
}
});
// ---------------- User Override ----------------
xapi.Event.UserInterface.Message.Prompt.Response.on(event => {
if (event.FeedbackId === 'empty_room_countdown') {
log(`User pressed Stay Connected (override) - snoozing re-arm for ${OVERRIDE_SNOOZE_MS / 1000}s`);
overrideActiveUntilMs = Date.now() + OVERRIDE_SNOOZE_MS;
exitEmptyState('user_override');
}
});
// ---------------- Main Scheduler ----------------
setInterval(async () => {
try {
// --- Poll safety net -------------------------------------------------
// Reconciles state even if a PeopleCount change event was missed.
// Harmless to run alongside the event handler above: enter/exitEmptyState
// both no-op if the state is already correct.
const polledPeople = await getPeopleCount();
if (polledPeople !== null) {
lastPeopleCount = polledPeople;
if (polledPeople === 0) enterEmptyState('poll_zero');
else exitEmptyState('poll_nonzero');
}
if (emptySinceMs === null) return;
const now = Date.now();
const elapsed = now - emptySinceMs;
const remainingMs = scheduledDisconnectMs - now;
// Log progress once per minute
if (now - lastStateLoggedAt > 60 * 1000) {
lastStateLoggedAt = now;
const mins = Math.floor(elapsed / 60000);
const secs = Math.floor((elapsed % 60000) / 1000);
log(`EMPTY timer running: ${mins}m ${secs}s elapsed`);
}
const active = await hasActiveCall();
if (!active) {
overrideActiveUntilMs = null; // don't let a stale override leak into the next call
exitEmptyState('call_ended');
return;
}
// Start countdown during final minute
if (remainingMs <= COUNTDOWN_SECONDS * 1000 && remainingMs > 0) {
const secondsRemaining = Math.ceil(remainingMs / 1000);
startCountdown(secondsRemaining);
}
// Time to disconnect
if (remainingMs <= 0) {
log('20-minute timeout reached');
log(`Active call at timeout: ${active}`);
await disconnectCalls();
overrideActiveUntilMs = null;
exitEmptyState('disconnect_complete');
}
} catch (e) {
log(`Scheduler error: ${e}`);
}
}, TICK_MS);
// ---------------- Startup Sync ----------------
(async function init() {
log('Macro loaded');
// Clear any prompt left on screen from a previous run of this macro
// (e.g. after an edit/reload while a countdown was showing).
stopCountdown();
const people = await getPeopleCount();
const active = await hasActiveCall();
log(`Startup -> People: ${people ?? 'unknown'} | Call Active: ${active}`);
if (people === 0 && active) {
enterEmptyState('startup_empty_and_in_call');
}
})();
How it works
- A call connects on the endpoint.
- The macro checks
RoomAnalytics.PeopleCount- both via the change event (fast path) and a poll every 5 seconds (safety net, in case an event is missed). - If the count is 0, the macro starts a 20-minute “empty” timer.
- If a person is detected before the timer expires, the timer is cancelled.
- In the final 60 seconds, an on-screen prompt shows a live countdown with a Stay Connected button.
- Pressing Stay Connected cancels the disconnect and suppresses re-arming the timer for 5 minutes, so the room isn’t immediately flagged empty again if PeopleCount is still reading 0.
- If the timer reaches zero, the macro disconnects all active calls on the endpoint.
- A sensor read failure is treated as “unknown occupancy,” never as “empty” - the macro will not disconnect a call it can’t confirm is unattended.
Deployment
Add the macro through the endpoint’s Macros editor in the device web admin page, or through Control Hub where macro management is available.
After adding the code:
- Save the macro.
- Enable it.
- Adjust
EMPTY_TIMEOUT_MS,COUNTDOWN_SECONDS, andOVERRIDE_SNOOZE_MSat the top if you want different timing. - Join a test call, clear the room, and watch the macro console logs for the
EMPTY state entered/countdown/disconnect sequence.
Notes
This only works on models that implement RoomAnalytics.PeopleCount - Room Kit, Room 55/70 series, Room Panorama, Desk Pro, and Boards. On personal/desk endpoints like the DX70/DX80 (no room-sensing camera), the status read fails, and the macro is effectively a no-op. Check the “Supported models” note at the top of the macro before deploying broadly.
It’s also worth testing on your specific RoomOS release and room layout before rolling it out - PeopleCount accuracy varies with camera placement and room geometry.
Hopefully this helps anyone looking to automatically clean up abandoned calls without requiring someone to remember to hang up.