rhombus-node-mcp 0.1.13 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +11 -3
  2. package/dist/api/camera-tool-api.js +108 -0
  3. package/dist/api/clips-tool-api.js +36 -0
  4. package/dist/api/create-camera-policy-tool-api.js +9 -0
  5. package/dist/api/create-tool-api.js +53 -0
  6. package/dist/api/entity-lookup-tool-api.js +37 -0
  7. package/dist/api/events-tool-api.js +320 -0
  8. package/dist/api/faces-tool-api.js +110 -0
  9. package/dist/api/get-entity-tool-api.js +176 -0
  10. package/dist/api/get-org-information-tool-api.js +9 -0
  11. package/dist/api/location-tool-api.js +9 -0
  12. package/dist/api/lpr-tool-api.js +68 -0
  13. package/dist/api/policy-alerts-tool-api.js +42 -0
  14. package/dist/api/reboot-cameras-tool-api.js +34 -0
  15. package/dist/api/report-tool-api.js +426 -0
  16. package/dist/api/time-tool-api.js +90 -0
  17. package/dist/api/update-tool-api.js +148 -0
  18. package/dist/createServer.js +50 -0
  19. package/dist/disabled-tools/endpoint-to-keys-tool.js +84 -0
  20. package/dist/disabled-tools/semantic-search-tool.js +90 -0
  21. package/dist/index.js +25 -32
  22. package/dist/logger.js +5 -4
  23. package/dist/network.js +45 -14
  24. package/dist/resources/routes.json.js +1 -1
  25. package/dist/services/embedding-service.js +153 -0
  26. package/dist/services/faiss-search-service.js +261 -0
  27. package/dist/tools/camera-tool.js +100 -0
  28. package/dist/tools/clips-tool.js +23 -38
  29. package/dist/tools/count-tool.js +25 -0
  30. package/dist/tools/create-camera-policy-tool.js +214 -0
  31. package/dist/tools/create-tool.js +25 -75
  32. package/dist/tools/entity-lookup-tool.js +35 -0
  33. package/dist/tools/events-tool.js +188 -93
  34. package/dist/tools/faces-tool.js +59 -133
  35. package/dist/tools/get-entity-tool.js +78 -0
  36. package/dist/tools/get-org-information-tool.js +18 -0
  37. package/dist/tools/location-tool.js +24 -32
  38. package/dist/tools/lpr-tool.js +71 -0
  39. package/dist/tools/policy-alerts-tool.js +32 -43
  40. package/dist/tools/reboot-cameras-tool.js +34 -0
  41. package/dist/tools/report-tool.js +190 -0
  42. package/dist/tools/time-conversion-tool.js +43 -0
  43. package/dist/tools/time-tool.js +17 -55
  44. package/dist/tools/update-tool.js +262 -0
  45. package/dist/transports/stdio.js +10 -0
  46. package/dist/transports/streamable-http.js +201 -0
  47. package/dist/{tools/devices/camera-tool/types.js → types/camera-tool-types.js} +13 -0
  48. package/dist/types/clips-tool-types.js +41 -0
  49. package/dist/types/create-camera-policy-tool-types.js +44 -0
  50. package/dist/types/create-tool-types.js +8 -0
  51. package/dist/types/deviceType.js +1 -0
  52. package/dist/types/endpoint-to-keys-tool-types.js +7 -0
  53. package/dist/types/entity-lookup-tool-types.js +70 -0
  54. package/dist/types/events-tools-types.js +257 -0
  55. package/dist/types/faces-tools-types.js +143 -0
  56. package/dist/types/get-entity-tool-types.js +25 -0
  57. package/dist/types/get-org-information-tool-types.js +3 -0
  58. package/dist/types/location-tool-types.js +11 -0
  59. package/dist/types/lpr-tool-types.js +97 -0
  60. package/dist/types/policy-alerts-tool-types.js +74 -0
  61. package/dist/types/reboot-cameras-tool-types.js +8 -0
  62. package/dist/types/report-tool-types.js +268 -0
  63. package/dist/types/schema-components.js +7093 -0
  64. package/dist/types/schema.js +1 -0
  65. package/dist/types/semantic-search-tool-types.js +5 -0
  66. package/dist/types/time-conversion-tool-types.js +8 -0
  67. package/dist/types/time-tool-types.js +11 -0
  68. package/dist/types/update-tool-types.js +186 -0
  69. package/dist/types/zod-schemas.js +21315 -0
  70. package/dist/types.js +17 -7
  71. package/dist/util.js +96 -14
  72. package/dist/utils/confirmation.js +1 -7
  73. package/dist/utils/reduce-output.js +35 -0
  74. package/dist/utils/remove-nulls.js +28 -0
  75. package/dist/utils/temp.js +8 -0
  76. package/dist/utils/timestampInput.js +12 -0
  77. package/package.json +28 -3
  78. package/dist/tools/devices/camera-tool/camera-tool.js +0 -218
  79. package/dist/tools/devices/get-entity-tool.js +0 -117
  80. package/dist/tools/get-org-information.js +0 -18
  81. package/dist/tools/reboot-cameras.js +0 -63
package/README.md CHANGED
@@ -49,7 +49,7 @@ Your insights will directly influence our development roadmap and help us create
49
49
  "--rm",
50
50
  "-e",
51
51
  "RHOMBUS_API_KEY=YOUR_API_KEY_HERE",
52
- "johnrhombusdocker/mcp-server-rhombus"
52
+ "rhombussystems/mcp-server-rhombus"
53
53
  ],
54
54
  "env": {
55
55
  "RHOMBUS_API_KEY": "YOUR_API_KEY_HERE"
@@ -106,7 +106,7 @@ docker build -t mcp-server-rhombus .
106
106
  ### 2. Update Your Claude Config for Local Use ⚡
107
107
  Now, you'll need to adjust your `claude_desktop_config.json` to point to your newly built local Docker image.
108
108
 
109
- > ***Note:*** When running locally, the Docker image name changes to `mcp-server-rhombus` from `johnrhombusdocker/mcp-server-rhombus`. Make sure to update this in your configuration!
109
+ > ***Note:*** When running locally, the Docker image name changes to `mcp-server-rhombus` from `rhombussystems/mcp-server-rhombus`. Make sure to update this in your configuration!
110
110
 
111
111
  ```json
112
112
  {
@@ -129,7 +129,15 @@ Now, you'll need to adjust your `claude_desktop_config.json` to point to your ne
129
129
  }
130
130
  ```
131
131
 
132
- ### Hitting a Snag? We've Got You! 🛟
132
+ ## Creating a new tool
133
+
134
+ We've written a script that you can run to get kickstarted on developing a new tool in addition to our existing tool set. You can simply call:
135
+
136
+ ```sh
137
+ npm run create:tool
138
+ ```
139
+
140
+ ## Hitting a Snag? We've Got You! 🛟
133
141
 
134
142
  Check out Claude's fantastic [troubleshooting guide](https://modelcontextprotocol.io/docs/tools/debugging) for quick fixes! Still stuck? Our team of experts is ready to help!
135
143
 
@@ -0,0 +1,108 @@
1
+ import { getLogger } from "../logger.js";
2
+ import { appendQueryParams, AUTH_HEADERS, postApi, STATIC_HEADERS } from "../network.js";
3
+ import { removeNullFields } from "../util.js";
4
+ const logger = getLogger("camera-tool");
5
+ export async function getImageForCameraAtTime(cameraUuid, timestampMs, requestModifiers, sessionId) {
6
+ const body = {
7
+ cameraUuid: cameraUuid,
8
+ downscaleFactor: 10,
9
+ jpgQuality: 70,
10
+ permyriadCropHeight: 10000,
11
+ permyriadCropWidth: 5625,
12
+ permyriadCropX: 2188,
13
+ permyriadCropY: 0,
14
+ timestampMs: timestampMs,
15
+ };
16
+ logger.debug(`Getting frameUri from UUID: ${cameraUuid} at timestampMs: ${timestampMs}`);
17
+ const base64Image = await postApi({
18
+ route: "/video/getExactFrameUri",
19
+ body,
20
+ modifiers: requestModifiers,
21
+ sessionId,
22
+ }).then(async (res) => {
23
+ logger.debug(`Received frameUri ${res.frameUri}`);
24
+ // construct request headers
25
+ let requestHeaders = {
26
+ ...(requestModifiers?.headers ?? AUTH_HEADERS),
27
+ ...STATIC_HEADERS,
28
+ };
29
+ // add query params
30
+ if (requestModifiers?.query) {
31
+ res.frameUri = appendQueryParams(res.frameUri, requestModifiers.query);
32
+ }
33
+ logger.trace(`Fetching with headers\n${JSON.stringify(requestHeaders)}`);
34
+ return await fetch(res.frameUri, {
35
+ method: "GET",
36
+ headers: requestHeaders,
37
+ }).then(async (res) => {
38
+ if (!res.ok) {
39
+ logger.error(`Failed to fetch image: ${await res.text()}`);
40
+ logger.error(res);
41
+ return null;
42
+ }
43
+ const arrayBuffer = await res.arrayBuffer();
44
+ const buffer = Buffer.from(arrayBuffer);
45
+ const base64 = buffer.toString("base64");
46
+ logger.debug(`Received image base64:\n ${base64}`);
47
+ return base64;
48
+ });
49
+ });
50
+ if (!base64Image) {
51
+ return {
52
+ success: false,
53
+ status: "failed to fetch image",
54
+ };
55
+ }
56
+ return {
57
+ success: true,
58
+ status: "successfully fetched image",
59
+ imageType: "base64",
60
+ imageData: base64Image,
61
+ };
62
+ }
63
+ export async function getCameraSettings(cameraUuid, requestModifiers, sessionId) {
64
+ const res = await postApi({
65
+ route: "/camera/getFacetedConfig",
66
+ body: {
67
+ deviceUuid: cameraUuid,
68
+ },
69
+ modifiers: requestModifiers,
70
+ sessionId,
71
+ });
72
+ if (res.error) {
73
+ return {
74
+ success: false,
75
+ status: "failed to fetch camera settings",
76
+ };
77
+ }
78
+ return {
79
+ success: true,
80
+ config: res.config,
81
+ status: "fetched camera settings",
82
+ };
83
+ }
84
+ export async function updateCameraSettings(cameraUuid, update, requestModifiers, sessionId) {
85
+ // remove any "null" values
86
+ const res = await postApi({
87
+ route: "/camera/updateFacetedConfig",
88
+ body: {
89
+ configUpdate: {
90
+ deviceUuid: cameraUuid,
91
+ ...removeNullFields(update),
92
+ },
93
+ },
94
+ modifiers: requestModifiers,
95
+ sessionId,
96
+ });
97
+ if (res.error) {
98
+ return {
99
+ success: false,
100
+ status: "failed to update camera settings",
101
+ };
102
+ }
103
+ return {
104
+ success: true,
105
+ config: res.config,
106
+ status: "updated camera settings",
107
+ };
108
+ }
@@ -0,0 +1,36 @@
1
+ import { postApi } from "../network.js";
2
+ export async function getSavedClips(args, requestModifiers, sessionId) {
3
+ return await postApi({
4
+ route: "/event/getClipsWithProgress",
5
+ body: args,
6
+ modifiers: requestModifiers,
7
+ sessionId,
8
+ }).then(response => {
9
+ if (response.error)
10
+ return response;
11
+ else
12
+ return (response.savedClips?.map((clip) => ({
13
+ ...clip,
14
+ createdAtTimestamp: clip.createdAtMs ? new Date(clip.createdAtMs).toISOString() : null,
15
+ })) ?? []);
16
+ });
17
+ }
18
+ export async function getExpiringClips(args, requestModifiers, sessionId) {
19
+ return await postApi({
20
+ route: "/event/getExpiringClipsForOrg",
21
+ body: args,
22
+ modifiers: requestModifiers,
23
+ sessionId,
24
+ }).then(response => {
25
+ if (response.error)
26
+ return response;
27
+ else
28
+ return {
29
+ ...response,
30
+ savedClips: (response.savedClips || []).map((clip) => ({
31
+ ...clip,
32
+ createdAtTimestamp: clip.createdAtMs ? new Date(clip.createdAtMs).toISOString() : null,
33
+ })),
34
+ };
35
+ });
36
+ }
@@ -0,0 +1,9 @@
1
+ import { postApi } from "../network.js";
2
+ export async function createCameraPolicy(args, requestModifiers, sessionId) {
3
+ return await postApi({
4
+ route: "/policy/createCameraPolicy",
5
+ body: args,
6
+ modifiers: requestModifiers,
7
+ sessionId,
8
+ });
9
+ }
@@ -0,0 +1,53 @@
1
+ import { logger } from "../logger.js";
2
+ import { postApi } from "../network.js";
3
+ export async function createVideoWall(options, requestModifiers, sessionId) {
4
+ const body = {
5
+ videoWall: {
6
+ displayName: options?.displayName,
7
+ deviceList: options?.deviceList,
8
+ othersCanEdit: true,
9
+ orgUuid: options?.orgUuid,
10
+ shared: true,
11
+ settings: {
12
+ gridSize: { width: options?.settings.columnCount, height: options?.settings.columnCount },
13
+ gridLayout: "1 2\n3 4",
14
+ intervalSeconds: options?.settings.intervalSeconds || 5,
15
+ },
16
+ },
17
+ };
18
+ const response = await postApi({
19
+ route: "/camera/createVideoWall",
20
+ body,
21
+ modifiers: requestModifiers,
22
+ sessionId,
23
+ });
24
+ return response;
25
+ }
26
+ export async function handleCreateVideoWallRequest(videoWallCreateOptions, requestModifiers, sessionId) {
27
+ let text = "Unable to create video wall!";
28
+ logger.info("🔨 Creating video wall");
29
+ if (!videoWallCreateOptions?.displayName) {
30
+ text = JSON.stringify({
31
+ needUserInput: true,
32
+ commandForUser: "What should the name of the video wall be?",
33
+ });
34
+ }
35
+ else if ((videoWallCreateOptions?.deviceList || []).length === 0) {
36
+ text = JSON.stringify({
37
+ needUserInput: true,
38
+ commandForUser: "Which cameras would you like on this video wall?",
39
+ });
40
+ }
41
+ else {
42
+ logger.info("Creating video wall with options: ", JSON.stringify(videoWallCreateOptions));
43
+ text = JSON.stringify(await createVideoWall(videoWallCreateOptions, requestModifiers, sessionId));
44
+ }
45
+ return Promise.resolve({
46
+ content: [
47
+ {
48
+ type: "text",
49
+ text,
50
+ },
51
+ ],
52
+ });
53
+ }
@@ -0,0 +1,37 @@
1
+ import { getAccessControlledDoors, getAudioGateways, getBadgeReaders, getButtons, getCameraList, getDoorbellCameras, getDoorSensors, getEnvironmentalGateways, getEnvironmentalSensors, getKeypads, getMotionSensors, } from "./get-entity-tool-api.js";
2
+ export async function getAllEntities(deviceUuids, timeZone, tempUnit, requestModifiers, sessionId) {
3
+ const promises = [
4
+ getCameraList(requestModifiers, sessionId),
5
+ getDoorbellCameras(requestModifiers, sessionId),
6
+ getBadgeReaders(requestModifiers, sessionId),
7
+ getAccessControlledDoors(requestModifiers, sessionId),
8
+ getAudioGateways(timeZone, requestModifiers, sessionId),
9
+ getDoorSensors(requestModifiers, sessionId),
10
+ getEnvironmentalSensors(timeZone, tempUnit, requestModifiers, sessionId),
11
+ getMotionSensors(requestModifiers, sessionId),
12
+ getButtons(timeZone, requestModifiers, sessionId),
13
+ getKeypads(requestModifiers, sessionId),
14
+ getEnvironmentalGateways(timeZone, requestModifiers, sessionId),
15
+ ];
16
+ const responses = await Promise.all(promises);
17
+ // Filter each response to only include devices with matching UUIDs
18
+ for (let i = 0; i < responses.length; i++) {
19
+ const response = responses[i];
20
+ // Look through keys and find any with an array
21
+ for (const key of Object.keys(response)) {
22
+ const value = response[key];
23
+ if (Array.isArray(value)) {
24
+ // Filter to only include devices with matching UUIDs
25
+ response[key] = value.filter((item) => {
26
+ return deviceUuids.includes(item.uuid);
27
+ });
28
+ response[`${key}Count`] = response[key].length;
29
+ }
30
+ }
31
+ }
32
+ const result = {};
33
+ for (const response of responses) {
34
+ Object.assign(result, response);
35
+ }
36
+ return result;
37
+ }
@@ -0,0 +1,320 @@
1
+ import z from "zod";
2
+ import { FIVE_SECONDS_MS, THREE_HOURS_MS } from "../constants.js";
3
+ import { postApi } from "../network.js";
4
+ import { formatTimestamp } from "../util.js";
5
+ import { tempFunc, TempUnit } from "../utils/temp.js";
6
+ // Type definitions
7
+ export const HumanEvent = z.object({
8
+ timestamp: z.number(),
9
+ id: z.number(),
10
+ });
11
+ export async function getFaceEvents(_locationUuid, timeZone, requestModifiers, sessionId) {
12
+ const nowMs = Date.now();
13
+ const rangeStartMs = nowMs - THREE_HOURS_MS;
14
+ const rangeEndMs = nowMs - FIVE_SECONDS_MS;
15
+ const body = {
16
+ pageRequest: {
17
+ lastEvaluatedKey: undefined,
18
+ maxPageSize: 75,
19
+ },
20
+ searchFilter: {
21
+ deviceUuids: [],
22
+ faceNames: [],
23
+ labels: [],
24
+ locationUuids: [],
25
+ personUuids: [],
26
+ timestampFilter: {
27
+ rangeStart: rangeStartMs,
28
+ rangeEnd: rangeEndMs,
29
+ },
30
+ },
31
+ };
32
+ const response = await postApi({
33
+ route: "/faceRecognition/faceEvent/findFaceEventsByOrg",
34
+ body,
35
+ modifiers: requestModifiers,
36
+ sessionId,
37
+ }).then(response => {
38
+ return {
39
+ faceEvents: (response.faceEvents || []).map(event => ({
40
+ ...event,
41
+ eventTimestamp: event.eventTimestamp
42
+ ? formatTimestamp(event.eventTimestamp, timeZone)
43
+ : undefined,
44
+ })),
45
+ };
46
+ });
47
+ return response;
48
+ }
49
+ export async function getAccessControlEvents(doorUuids, startTime, endTime, timeZone, requestModifiers, sessionId) {
50
+ const bodies = doorUuids.map(doorUuid => ({
51
+ accessControlledDoorUuid: doorUuid,
52
+ ...(startTime ? { createdAfterMs: startTime } : {}),
53
+ ...(endTime ? { createdBeforeMs: endTime } : {}),
54
+ typeFilter: ["CredentialReceivedEvent"],
55
+ }));
56
+ const responses = await Promise.all(bodies.map(body => postApi({
57
+ route: "/component/findComponentEventsByAccessControlledDoor",
58
+ body,
59
+ modifiers: requestModifiers,
60
+ sessionId,
61
+ }).then(response => {
62
+ return (response.componentEvents || [])
63
+ .map((credEvent) => {
64
+ return {
65
+ authenticationResult: credEvent?.authenticationResult,
66
+ authorizationResult: credEvent?.authorizationResult,
67
+ doorUuid: credEvent?.componentCompositeUuid,
68
+ locationUuid: credEvent?.locationUuid,
69
+ user: credEvent?.originator?.username,
70
+ credSource: credEvent?.credSource,
71
+ datetime: credEvent?.timestampMs
72
+ ? formatTimestamp(credEvent.timestampMs, timeZone)
73
+ : undefined,
74
+ };
75
+ })
76
+ .filter(Boolean);
77
+ })));
78
+ // Flatten all componentEvents into a single array
79
+ const accessControlEvents = responses.flatMap(events => events);
80
+ console.error(`componentEvents: ${JSON.stringify(accessControlEvents)}`);
81
+ return accessControlEvents;
82
+ }
83
+ export async function getHumanMotionEvents(cameraUuid, duration, startTime, requestModifiers, sessionId) {
84
+ const body = {
85
+ cameraUuid,
86
+ duration,
87
+ startTime: Math.round(startTime / 1000),
88
+ };
89
+ const response = await postApi({
90
+ route: "/camera/getFootageSeekpointsV2",
91
+ body,
92
+ modifiers: requestModifiers,
93
+ sessionId,
94
+ }).then(response => {
95
+ const seekPoints = response.footageSeekPoints || [];
96
+ const uniqueHumanEvents = seekPoints
97
+ .reduceRight((acc, point) => {
98
+ if (point.a === "MOTION_HUMAN" &&
99
+ typeof point.ts === "number" &&
100
+ typeof point.id === "number" &&
101
+ !acc.some(existing => existing.id === point.id)) {
102
+ acc.unshift({ timestamp: point.ts, id: point.id });
103
+ }
104
+ return acc;
105
+ }, [])
106
+ .map(event => ({
107
+ timestamp: event.timestamp,
108
+ id: event.id,
109
+ }));
110
+ return { cameraUuid, uniqueHumanEvents };
111
+ });
112
+ return response;
113
+ }
114
+ const EVENT_COUNT_MAX_PER_RESPONSE = 2000;
115
+ export async function getEventsForEnvironmentalGateway(deviceUuid, startTime, endTime, timeZone, tempUnit, requestModifiers, sessionId) {
116
+ let allEvents = [];
117
+ let hasMore = true;
118
+ let lastEvaluatedKey = undefined;
119
+ while (hasMore) {
120
+ const body = {
121
+ deviceUuid,
122
+ ...(startTime ? { createdAfterMs: startTime } : {}),
123
+ ...(endTime ? { createdBeforeMs: endTime } : {}),
124
+ maxPageSize: EVENT_COUNT_MAX_PER_RESPONSE,
125
+ ...(lastEvaluatedKey ? { lastEvaluatedKey } : {}),
126
+ };
127
+ const response = await postApi({
128
+ route: "/climate/getEventsForEnvironmentalGateway",
129
+ body,
130
+ modifiers: requestModifiers,
131
+ sessionId,
132
+ }).then(response => {
133
+ const tempFunc = tempUnit === TempUnit.FAHRENHEIT
134
+ ? (temp) => (temp * 9) / 5 + 32
135
+ : (temp) => temp;
136
+ return {
137
+ events: (response.events || []).map(event => ({
138
+ timestampString: event.timestampMs
139
+ ? formatTimestamp(event.timestampMs, timeZone)
140
+ : undefined,
141
+ temp: tempFunc(event.co2Sense?.tempC ?? 0),
142
+ probeTemp: tempFunc(event.tempProbe?.tempC ?? 0),
143
+ humidity: event.co2Sense?.relHumid,
144
+ pm25: event.pmSense?.pm2p5,
145
+ co2: event.co2Sense?.co2Ppm,
146
+ vapeDetected: event.derivedValues?.vapeDetected,
147
+ })),
148
+ lastEvaluatedKey: response.lastEvaluatedKey,
149
+ };
150
+ });
151
+ // Accumulate the events
152
+ if (response.events) {
153
+ allEvents.push(...response.events);
154
+ }
155
+ // Check if we should continue pagination
156
+ if (response.events &&
157
+ response.events.length === EVENT_COUNT_MAX_PER_RESPONSE &&
158
+ response.lastEvaluatedKey &&
159
+ response.lastEvaluatedKey !== null) {
160
+ // Update the lastEvaluatedKey for the next iteration
161
+ lastEvaluatedKey = response.lastEvaluatedKey;
162
+ }
163
+ else {
164
+ hasMore = false;
165
+ }
166
+ }
167
+ return {
168
+ events: allEvents,
169
+ lastEvaluatedKey: undefined, // Don't return lastEvaluatedKey since we've fetched all pages
170
+ };
171
+ }
172
+ export async function getClimateEventsForSensor(sensorUuid, startTime, endTime, limit, timeZone, tempUnit, requestModifiers, sessionId) {
173
+ let allClimateEvents = [];
174
+ let hasMore = true;
175
+ let remainingLimit = limit || 1000; // Default to 1000 if no limit specified
176
+ while (hasMore && allClimateEvents.length < remainingLimit) {
177
+ const currentBatchSize = Math.min(100, remainingLimit - allClimateEvents.length); // Max 100 per request
178
+ const body = {
179
+ sensorUuid,
180
+ ...(startTime ? { createdAfterMs: startTime } : {}),
181
+ ...(endTime ? { createdBeforeMs: endTime } : {}),
182
+ limit: currentBatchSize,
183
+ };
184
+ const response = await postApi({
185
+ route: "/climate/getClimateEventsForSensor",
186
+ body,
187
+ modifiers: requestModifiers,
188
+ sessionId,
189
+ });
190
+ if (response?.climateEvents) {
191
+ // Map the climate events to include formatted timestamp and relevant fields
192
+ const mappedEvents = response.climateEvents.map(event => ({
193
+ timestampString: event.timestampMs
194
+ ? formatTimestamp(event.timestampMs, timeZone)
195
+ : undefined,
196
+ timestampMs: event.timestampMs,
197
+ temp: event.temp,
198
+ probeTemp: tempFunc(event.probeTempC ?? 0, tempUnit),
199
+ // probeTempC: event.probeTempC,
200
+ // probeTempF: event.probeTempC ? (event.probeTempC * 9) / 5 + 32 : undefined,
201
+ humidity: event.humidity,
202
+ pm25: event.pm25,
203
+ co2: event.co2,
204
+ tvoc: event.tvoc,
205
+ iaq: event.iaq,
206
+ ethanol: event.ethanol,
207
+ heatIndexDegF: event.heatIndexDegF,
208
+ heatIndexRangeWarning: event.heatIndexRangeWarning,
209
+ vapeSmokeDetected: event.vapeSmokeDetected,
210
+ vapeSmokePercent: event.vapeSmokePercent,
211
+ thcDetected: event.thcDetected,
212
+ thcPercent: event.thcPercent,
213
+ tampered: event.tampered,
214
+ batteryPercentage: event.batteryPercentage,
215
+ locationUuid: event.locationUuid,
216
+ orgUuid: event.orgUuid,
217
+ }));
218
+ allClimateEvents.push(...mappedEvents);
219
+ // Check if we got a full batch and haven't reached the limit
220
+ if (response.climateEvents.length === currentBatchSize &&
221
+ allClimateEvents.length < remainingLimit) {
222
+ // Prepare for the next batch by updating the endTime to the oldest event's timestamp
223
+ if (mappedEvents.length > 0) {
224
+ const oldestEventTimestamp = mappedEvents[mappedEvents.length - 1].timestampMs;
225
+ if (oldestEventTimestamp) {
226
+ endTime = oldestEventTimestamp - 1; // Subtract 1ms to avoid duplicates
227
+ }
228
+ else {
229
+ hasMore = false;
230
+ }
231
+ }
232
+ else {
233
+ hasMore = false;
234
+ }
235
+ }
236
+ else {
237
+ hasMore = false;
238
+ }
239
+ }
240
+ else {
241
+ hasMore = false;
242
+ }
243
+ }
244
+ // Trim to the requested limit if we got more than needed
245
+ if (limit && allClimateEvents.length > limit) {
246
+ allClimateEvents = allClimateEvents.slice(0, limit);
247
+ }
248
+ return allClimateEvents;
249
+ }
250
+ export async function getComponentEventsByLocation(locationUuid, eventTypes, // Still accept string[] for flexibility
251
+ startTime, endTime, timeZone, requestModifiers, sessionId) {
252
+ const MAX_LIMIT = 1000; // API limit for component events
253
+ const body = {
254
+ locationUuid,
255
+ typeFilter: eventTypes.length > 0 ? eventTypes : undefined, // API accepts string array
256
+ ...(startTime ? { createdAfterMs: startTime } : {}),
257
+ ...(endTime ? { createdBeforeMs: endTime } : {}),
258
+ limit: MAX_LIMIT,
259
+ };
260
+ const response = await postApi({
261
+ route: "/component/findComponentEventsByLocation",
262
+ body,
263
+ modifiers: requestModifiers,
264
+ sessionId,
265
+ });
266
+ const events = (response.componentEvents || []).map(event => {
267
+ // Map different event types to a common structure
268
+ const baseEvent = {
269
+ eventType: event.type,
270
+ componentUuid: event.componentUuid,
271
+ locationUuid: event.locationUuid,
272
+ orgUuid: event.orgUuid,
273
+ correlationId: event.correlationId,
274
+ ownerDeviceUuid: event.ownerDeviceUuid,
275
+ datetime: event.timestampMs ? formatTimestamp(event.timestampMs, timeZone) : undefined,
276
+ timestampMs: event.timestampMs,
277
+ uuid: event.uuid,
278
+ };
279
+ // Add event-specific fields based on type
280
+ if (event.type === "CredentialReceivedEvent") {
281
+ const credEvent = event;
282
+ return {
283
+ ...baseEvent,
284
+ authenticationResult: credEvent?.authenticationResult,
285
+ authorizationResult: credEvent?.authorizationResult,
286
+ user: credEvent?.originator?.username,
287
+ credSource: credEvent?.credSource,
288
+ doorUuid: credEvent?.componentCompositeUuid,
289
+ };
290
+ }
291
+ else if (event.type === "DoorbellEvent") {
292
+ return {
293
+ ...baseEvent,
294
+ doorbellCameraUuid: event.componentUuid,
295
+ };
296
+ }
297
+ else if (event.type === "DoorStateChangeEvent") {
298
+ const doorEvent = event;
299
+ return {
300
+ ...baseEvent,
301
+ previousState: doorEvent?.previousState,
302
+ newState: doorEvent?.newState,
303
+ reason: doorEvent?.reason,
304
+ };
305
+ }
306
+ else if (event.type === "ButtonEvent" || event.type === "PanicButtonEvent") {
307
+ const buttonEvent = event;
308
+ return {
309
+ ...baseEvent,
310
+ buttonState: buttonEvent?.buttonState,
311
+ };
312
+ }
313
+ // For other event types, return the base event
314
+ return baseEvent;
315
+ });
316
+ // Sort events by timestamp (newest first)
317
+ events.sort((a, b) => (b.timestampMs || 0) - (a.timestampMs || 0));
318
+ console.error(`componentEvents: ${JSON.stringify(events)}`);
319
+ return events;
320
+ }