rhombus-node-mcp
Version:
MCP server for Rhombus API
526 lines (525 loc) • 21.9 kB
JavaScript
import z from "zod";
import { FIVE_SECONDS_MS, THREE_HOURS_MS } from "../constants.js";
import { postApi } from "../network/network.js";
import { formatTimestamp } from "../util.js";
import { tempFunc, TempUnit } from "../utils/temp.js";
// Type definitions
/** One entry from the camera VOD footage seekpoint index (many activity types). */
export const CameraFootageEvent = z.object({
activity: z
.string()
.describe("Activity type on the recording timeline (e.g. MOTION_HUMAN, MOTION_CAR, LICENSEPLATE_IDENTIFIED—exact set depends on the camera and analytics)."),
timestamp: z.number().describe("Unix timestamp in milliseconds."),
id: z.number().optional().describe("Seekpoint id when the API provides one."),
licensePlate: z
.string()
.nullable()
.optional()
.describe("Plate text on this seekpoint when present. For org LPR saved vehicles, labels, and plate search APIs, use lpr-tool."),
vehicleName: z.string().nullable().optional().describe("Vehicle display name on this seekpoint when present."),
faceNames: z.string().nullable().optional().describe("Face names on this seekpoint when present."),
});
const ACCESS_CONTROL_EVENT_BATCH_SIZE = 1000;
const ACCESS_CONTROL_EVENT_TYPE_FILTER = ["CredentialReceivedEvent"];
function mapAccessControlEvent(credEvent, timeZone) {
return {
authenticationResult: credEvent?.authenticationResult,
authorizationResult: credEvent?.authorizationResult,
doorUuid: credEvent?.componentCompositeUuid,
locationUuid: credEvent?.locationUuid,
user: credEvent?.originator?.username,
credSource: credEvent?.credSource,
timestampMs: credEvent?.timestampMs,
datetime: credEvent?.timestampMs ? formatTimestamp(credEvent.timestampMs, timeZone) : undefined,
};
}
async function getAccessControlEventsForDoor(doorUuid, startTime, endTime, timeZone, requestModifiers, sessionId) {
const allEvents = [];
let createdBeforeMs = endTime;
while (true) {
const body = {
accessControlledDoorUuid: doorUuid,
...(startTime !== undefined ? { createdAfterMs: startTime } : {}),
...(createdBeforeMs !== undefined ? { createdBeforeMs } : {}),
limit: ACCESS_CONTROL_EVENT_BATCH_SIZE,
typeFilter: ACCESS_CONTROL_EVENT_TYPE_FILTER,
};
const response = await postApi({
route: "/component/findComponentEventsByAccessControlledDoor",
body,
modifiers: requestModifiers,
sessionId,
});
const componentEvents = response.componentEvents || [];
if (componentEvents.length === 0) {
break;
}
const mappedEvents = componentEvents
.map((credEvent) => mapAccessControlEvent(credEvent, timeZone))
.filter(Boolean);
allEvents.push(...mappedEvents);
if (componentEvents.length < ACCESS_CONTROL_EVENT_BATCH_SIZE) {
break;
}
const oldestTimestamp = componentEvents.reduce((oldest, event) => {
const ts = event?.timestampMs;
if (typeof ts !== "number") {
return oldest;
}
if (oldest === null || ts < oldest) {
return ts;
}
return oldest;
}, null);
if (oldestTimestamp === null) {
break;
}
// createdBeforeMs is exclusive. Moving the window to the oldest seen timestamp
// prevents duplicate pages and keeps fetching older events until the range is exhausted.
if (createdBeforeMs !== undefined && oldestTimestamp >= createdBeforeMs) {
break;
}
if (startTime !== undefined && oldestTimestamp <= startTime) {
break;
}
createdBeforeMs = oldestTimestamp;
}
return allEvents;
}
export async function getBrivoAccessControlEvents(startTime, endTime, timeZone, requestModifiers, sessionId) {
// Step 1: fetch Brivo integration config to get configured doors and their location UUIDs
const integrationResponse = await postApi({
route: "/integrations/accessControl/getBrivoIntegrationV2",
body: {},
modifiers: requestModifiers,
sessionId,
});
const brivoSettings = integrationResponse.orgIntegrationV2;
const integrationEnabled = brivoSettings?.enabled ?? false;
const doorInfoMap = brivoSettings?.doorInfoMap ?? {};
const brivoDoors = Object.entries(doorInfoMap)
.filter(([, info]) => info != null)
.map(([brivoDoornId, info]) => ({
brivoDoornId,
doorName: info.doorName ?? undefined,
locationUuid: info.locationUuid ?? undefined,
}));
if (brivoDoors.length === 0) {
return {
integrationEnabled,
brivoDoorsConfigured: 0,
brivoDoors: [],
events: [],
};
}
// Step 2: collect unique location UUIDs from configured Brivo doors
const locationUuids = [...new Set(brivoDoors.map(d => d.locationUuid).filter((id) => !!id))];
// Step 3: query CredentialReceivedEvents for each location
const MAX_LIMIT = 1000;
const allEvents = [];
await Promise.all(locationUuids.map(async (locationUuid) => {
const body = {
locationUuid,
typeFilter: ["CredentialReceivedEvent"],
...(startTime ? { createdAfterMs: startTime } : {}),
...(endTime ? { createdBeforeMs: endTime } : {}),
limit: MAX_LIMIT,
};
const response = await postApi({
route: "/component/findComponentEventsByLocation",
body,
modifiers: requestModifiers,
sessionId,
});
const mapped = (response.componentEvents || []).map(event => mapAccessControlEvent(event, timeZone));
allEvents.push(...mapped);
}));
allEvents.sort((a, b) => (b.timestampMs || 0) - (a.timestampMs || 0));
return {
integrationEnabled,
brivoDoorsConfigured: brivoDoors.length,
brivoDoors,
events: allEvents.map(e => ({
authenticationResult: e.authenticationResult ?? undefined,
authorizationResult: e.authorizationResult ?? undefined,
doorUuid: e.doorUuid ?? undefined,
locationUuid: e.locationUuid ?? undefined,
user: e.user ?? undefined,
credSource: e.credSource ?? undefined,
timestampMs: e.timestampMs ?? undefined,
datetime: e.datetime,
})),
};
}
export async function getFaceEvents(_locationUuid, timeZone, requestModifiers, sessionId) {
const nowMs = Date.now();
const rangeStartMs = nowMs - THREE_HOURS_MS;
const rangeEndMs = nowMs - FIVE_SECONDS_MS;
const body = {
pageRequest: {
lastEvaluatedKey: undefined,
maxPageSize: 75,
},
searchFilter: {
deviceUuids: [],
faceNames: [],
labels: [],
locationUuids: [],
personUuids: [],
timestampFilter: {
rangeStart: rangeStartMs,
rangeEnd: rangeEndMs,
},
},
};
const response = await postApi({
route: "/faceRecognition/faceEvent/findFaceEventsByOrg",
body,
modifiers: requestModifiers,
sessionId,
}).then(response => {
return {
faceEvents: (response.faceEvents || []).map(event => ({
...event,
eventTimestamp: event.eventTimestamp
? formatTimestamp(event.eventTimestamp, timeZone)
: undefined,
})),
};
});
return response;
}
export async function getAccessControlEvents(doorUuids, startTime, endTime, timeZone, requestModifiers, sessionId) {
const responses = await Promise.all(doorUuids.map(doorUuid => getAccessControlEventsForDoor(doorUuid, startTime, endTime, timeZone, requestModifiers, sessionId)));
// Flatten all componentEvents into a single array
const accessControlEvents = responses.flatMap(events => events);
accessControlEvents.sort((a, b) => (b.timestampMs || 0) - (a.timestampMs || 0));
console.error(`componentEvents: ${JSON.stringify(accessControlEvents)}`);
return accessControlEvents;
}
export async function getCameraFootageSeekpointEvents(cameraUuid, duration, startTime, requestModifiers, sessionId) {
const body = {
cameraUuid,
duration,
startTime: Math.round(startTime / 1000),
};
const response = await postApi({
route: "/camera/getFootageSeekpointsV2",
body,
modifiers: requestModifiers,
sessionId,
});
const seekPoints = response.footageSeekPoints ?? [];
const seen = new Set();
const cameraFootageEvents = [];
for (const point of seekPoints) {
if (typeof point.ts !== "number" || point.a == null) {
continue;
}
const idPart = point.id != null ? String(point.id) : "noid";
const dedupeKey = `${idPart}_${point.ts}_${point.a}`;
if (seen.has(dedupeKey)) {
continue;
}
seen.add(dedupeKey);
cameraFootageEvents.push({
activity: String(point.a),
timestamp: point.ts,
...(point.id != null ? { id: point.id } : {}),
...(point.lp !== undefined ? { licensePlate: point.lp } : {}),
...(point.vn !== undefined ? { vehicleName: point.vn } : {}),
...(point.fn !== undefined ? { faceNames: point.fn } : {}),
});
}
cameraFootageEvents.sort((a, b) => b.timestamp - a.timestamp);
return { cameraUuid, cameraFootageEvents };
}
const EVENT_COUNT_MAX_PER_RESPONSE = 2000;
export async function getEventsForEnvironmentalGateway(deviceUuid, startTime, endTime, timeZone, tempUnit, requestModifiers, sessionId) {
let allEvents = [];
let hasMore = true;
let lastEvaluatedKey = undefined;
while (hasMore) {
const body = {
deviceUuid,
...(startTime ? { createdAfterMs: startTime } : {}),
...(endTime ? { createdBeforeMs: endTime } : {}),
maxPageSize: EVENT_COUNT_MAX_PER_RESPONSE,
...(lastEvaluatedKey ? { lastEvaluatedKey } : {}),
};
const response = await postApi({
route: "/climate/getEventsForEnvironmentalGateway",
body,
modifiers: requestModifiers,
sessionId,
}).then(response => {
const tempFunc = tempUnit === TempUnit.FAHRENHEIT
? (temp) => (temp * 9) / 5 + 32
: (temp) => temp;
return {
events: (response.events || []).map(event => ({
timestampString: event.timestampMs
? formatTimestamp(event.timestampMs, timeZone)
: undefined,
temp: tempFunc(event.co2Sense?.tempC ?? 0),
probeTemp: tempFunc(event.tempProbe?.tempC ?? 0),
humidity: event.co2Sense?.relHumid,
pm25: event.pmSense?.pm2p5,
co2: event.co2Sense?.co2Ppm,
vapeDetected: event.derivedValues?.vapeDetected,
})),
lastEvaluatedKey: response.lastEvaluatedKey,
};
});
// Accumulate the events
if (response.events) {
allEvents.push(...response.events);
}
// Check if we should continue pagination
if (response.events &&
response.events.length === EVENT_COUNT_MAX_PER_RESPONSE &&
response.lastEvaluatedKey &&
response.lastEvaluatedKey !== null) {
// Update the lastEvaluatedKey for the next iteration
lastEvaluatedKey = response.lastEvaluatedKey;
}
else {
hasMore = false;
}
}
return {
events: allEvents,
lastEvaluatedKey: undefined, // Don't return lastEvaluatedKey since we've fetched all pages
};
}
export async function getClimateEventsForSensor(sensorUuid, startTime, endTime, limit, timeZone, tempUnit, requestModifiers, sessionId) {
let allClimateEvents = [];
let hasMore = true;
let remainingLimit = limit || 1000; // Default to 1000 if no limit specified
while (hasMore && allClimateEvents.length < remainingLimit) {
const currentBatchSize = Math.min(100, remainingLimit - allClimateEvents.length); // Max 100 per request
const body = {
sensorUuid,
...(startTime ? { createdAfterMs: startTime } : {}),
...(endTime ? { createdBeforeMs: endTime } : {}),
limit: currentBatchSize,
};
const response = await postApi({
route: "/climate/getClimateEventsForSensor",
body,
modifiers: requestModifiers,
sessionId,
});
if (response?.climateEvents) {
// Map the climate events to include formatted timestamp and relevant fields
const mappedEvents = response.climateEvents.map(event => ({
timestampString: event.timestampMs
? formatTimestamp(event.timestampMs, timeZone)
: undefined,
timestampMs: event.timestampMs,
temp: event.temp,
probeTemp: tempFunc(event.probeTempC ?? 0, tempUnit),
// probeTempC: event.probeTempC,
// probeTempF: event.probeTempC ? (event.probeTempC * 9) / 5 + 32 : undefined,
humidity: event.humidity,
pm25: event.pm25,
co2: event.co2,
tvoc: event.tvoc,
iaq: event.iaq,
ethanol: event.ethanol,
heatIndexDegF: event.heatIndexDegF,
heatIndexRangeWarning: event.heatIndexRangeWarning,
vapeSmokeDetected: event.vapeSmokeDetected,
vapeSmokePercent: event.vapeSmokePercent,
thcDetected: event.thcDetected,
thcPercent: event.thcPercent,
tampered: event.tampered,
batteryPercentage: event.batteryPercentage,
locationUuid: event.locationUuid,
orgUuid: event.orgUuid,
}));
allClimateEvents.push(...mappedEvents);
// Check if we got a full batch and haven't reached the limit
if (response.climateEvents.length === currentBatchSize &&
allClimateEvents.length < remainingLimit) {
// Prepare for the next batch by updating the endTime to the oldest event's timestamp
if (mappedEvents.length > 0) {
const oldestEventTimestamp = mappedEvents[mappedEvents.length - 1].timestampMs;
if (oldestEventTimestamp) {
endTime = oldestEventTimestamp - 1; // Subtract 1ms to avoid duplicates
}
else {
hasMore = false;
}
}
else {
hasMore = false;
}
}
else {
hasMore = false;
}
}
else {
hasMore = false;
}
}
// Trim to the requested limit if we got more than needed
if (limit && allClimateEvents.length > limit) {
allClimateEvents = allClimateEvents.slice(0, limit);
}
return allClimateEvents;
}
export async function getComponentEventsByLocation(locationUuid, eventTypes, // Still accept string[] for flexibility
startTime, endTime, timeZone, requestModifiers, sessionId) {
const MAX_LIMIT = 1000; // API limit for component events
const body = {
locationUuid,
typeFilter: eventTypes.length > 0 ? eventTypes : undefined, // API accepts string array
...(startTime ? { createdAfterMs: startTime } : {}),
...(endTime ? { createdBeforeMs: endTime } : {}),
limit: MAX_LIMIT,
};
const response = await postApi({
route: "/component/findComponentEventsByLocation",
body,
modifiers: requestModifiers,
sessionId,
});
const events = (response.componentEvents || []).map(event => {
// Map different event types to a common structure
const baseEvent = {
eventType: event.type,
componentUuid: event.componentUuid,
locationUuid: event.locationUuid,
orgUuid: event.orgUuid,
correlationId: event.correlationId,
ownerDeviceUuid: event.ownerDeviceUuid,
datetime: event.timestampMs ? formatTimestamp(event.timestampMs, timeZone) : undefined,
timestampMs: event.timestampMs,
uuid: event.uuid,
};
// Add event-specific fields based on type
if (event.type === "CredentialReceivedEvent") {
const credEvent = event;
return {
...baseEvent,
authenticationResult: credEvent?.authenticationResult,
authorizationResult: credEvent?.authorizationResult,
user: credEvent?.originator?.username,
credSource: credEvent?.credSource,
doorUuid: credEvent?.componentCompositeUuid,
};
}
else if (event.type === "DoorbellEvent") {
return {
...baseEvent,
doorbellCameraUuid: event.componentUuid,
};
}
else if (event.type === "DoorStateChangeEvent") {
const doorEvent = event;
return {
...baseEvent,
previousState: doorEvent?.previousState,
newState: doorEvent?.newState,
reason: doorEvent?.reason,
};
}
else if (event.type === "ButtonEvent" || event.type === "PanicButtonEvent") {
const buttonEvent = event;
return {
...baseEvent,
buttonState: buttonEvent?.buttonState,
};
}
// For other event types, return the base event
return baseEvent;
});
// Sort events by timestamp (newest first)
events.sort((a, b) => (b.timestampMs || 0) - (a.timestampMs || 0));
console.error(`componentEvents: ${JSON.stringify(events)}`);
return events;
}
export async function getButtonPressEvents(sensorUuid, startTime, endTime, timeZone, requestModifiers, sessionId) {
const body = {
sensorUuid,
...(startTime ? { createdAfterMs: startTime } : {}),
...(endTime ? { createdBeforeMs: endTime } : {}),
};
const response = await postApi({
route: "/button/getButtonPressEventsForSensor",
body,
modifiers: requestModifiers,
sessionId,
});
return (response.events || []).map(event => ({
timestampMs: event?.timestampMs ?? undefined,
datetime: event?.timestampMs ? formatTimestamp(event.timestampMs, timeZone) : undefined,
sensorUuid: event?.componentUuid ?? undefined,
buttonState: event?.buttonPress ?? undefined,
}));
}
export async function getOccupancyEvents(sensorUuid, startTime, endTime, timeZone, requestModifiers, sessionId) {
const body = {
sensorUuid,
...(startTime ? { createdAfterMs: startTime } : {}),
...(endTime ? { createdBeforeMs: endTime } : {}),
};
const response = await postApi({
route: "/occupancy/getOccupancyEventsForSensor",
body,
modifiers: requestModifiers,
sessionId,
});
return (response.occupancyEvents || response.events || []).map(event => ({
timestampMs: event.timestampMs ?? undefined,
datetime: event.timestampMs ? formatTimestamp(event.timestampMs, timeZone) : undefined,
sensorUuid: event.sensorUuid ?? event.componentUuid ?? undefined,
count: event.count ?? undefined,
}));
}
export async function getProximityEvents(tagUuids, startTime, endTime, timeZone, requestModifiers, sessionId) {
const allEvents = [];
for (const tagUuid of tagUuids) {
const body = {
tagUuid,
...(startTime ? { createdAfterMs: startTime } : {}),
...(endTime ? { createdBeforeMs: endTime } : {}),
};
const response = await postApi({
route: "/proximity/getProximityEventsForTag",
body,
modifiers: requestModifiers,
sessionId,
});
const mapped = (response.proximityEvents || []).map(event => ({
timestampMs: event.startTimeMs ?? undefined,
datetime: event.startTimeMs ? formatTimestamp(event.startTimeMs, timeZone) : undefined,
tagUuid: event.bleDeviceUuid ?? undefined,
rssi: event.bleRssi ?? undefined,
}));
allEvents.push(...mapped);
}
return allEvents;
}
export async function getDoorbellEvents(doorbellCameraUuid, startTime, endTime, timeZone, requestModifiers, sessionId) {
const body = {
doorbellCameraUuid,
limit: 100,
...(startTime ? { createdAfterMs: startTime } : {}),
...(endTime ? { createdBeforeMs: endTime } : {}),
};
const response = await postApi({
route: "/doorbellcamera/findComponentEventsForDoorbellCamera",
body,
modifiers: requestModifiers,
sessionId,
});
return (response.componentEvents || []).map(event => ({
timestampMs: event.timestampMs ?? undefined,
datetime: event.timestampMs ? formatTimestamp(event.timestampMs, timeZone) : undefined,
doorbellCameraUuid: event.componentUuid ?? undefined,
eventType: event.type ?? undefined,
}));
}