UNPKG

rhombus-node-mcp

Version:
526 lines (525 loc) 21.9 kB
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, })); }