rhombus-node-mcp 0.1.45 → 0.1.46
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.
|
@@ -1,70 +1,65 @@
|
|
|
1
1
|
import { getLogger } from "../logger.js";
|
|
2
|
-
import {
|
|
2
|
+
import { postApi } from "../network/network.js";
|
|
3
3
|
import { removeNullFields } from "../util.js";
|
|
4
4
|
const logger = getLogger("camera-tool");
|
|
5
5
|
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
6
|
-
|
|
6
|
+
/** Convert a 0-100 percentage to permyriad (0-10000), clamped to the valid range. */
|
|
7
|
+
function pctToPermyriad(pct) {
|
|
8
|
+
return Math.round(Math.max(0, Math.min(100, pct)) * 100);
|
|
9
|
+
}
|
|
10
|
+
export async function getImageForCameraAtTime(cameraUuid, timestampMs, requestModifiers, sessionId, options) {
|
|
11
|
+
// biome-ignore lint/suspicious/noExplicitAny: request body is a loose JSON shape
|
|
7
12
|
const body = {
|
|
8
|
-
cameraUuid
|
|
9
|
-
|
|
13
|
+
cameraUuid,
|
|
14
|
+
timestampMs,
|
|
15
|
+
downscaleFactor: options?.downscaleFactor ?? 10,
|
|
10
16
|
jpgQuality: 70,
|
|
11
|
-
timestampMs: timestampMs,
|
|
12
17
|
};
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
const crop = options?.crop;
|
|
19
|
+
const hasCrop = crop != null &&
|
|
20
|
+
(crop.x != null || crop.y != null || crop.width != null || crop.height != null);
|
|
21
|
+
const resolvedCrop = hasCrop
|
|
22
|
+
? {
|
|
23
|
+
x: crop.x ?? 0,
|
|
24
|
+
y: crop.y ?? 0,
|
|
25
|
+
width: crop.width ?? 100,
|
|
26
|
+
height: crop.height ?? 100,
|
|
27
|
+
}
|
|
28
|
+
: null;
|
|
29
|
+
if (resolvedCrop) {
|
|
30
|
+
body.permyriadCropX = pctToPermyriad(resolvedCrop.x);
|
|
31
|
+
body.permyriadCropY = pctToPermyriad(resolvedCrop.y);
|
|
32
|
+
body.permyriadCropWidth = pctToPermyriad(resolvedCrop.width);
|
|
33
|
+
body.permyriadCropHeight = pctToPermyriad(resolvedCrop.height);
|
|
34
|
+
}
|
|
35
|
+
logger.debug(`Getting exact frame data for UUID: ${cameraUuid} at timestampMs: ${timestampMs}` +
|
|
36
|
+
(hasCrop ? ` (cropped)` : ``));
|
|
37
|
+
const res = await postApi({
|
|
38
|
+
route: "/video/getExactFrameData",
|
|
17
39
|
body,
|
|
18
40
|
modifiers: requestModifiers,
|
|
19
41
|
sessionId,
|
|
20
|
-
}).then(async (res) => {
|
|
21
|
-
logger.debug(`Received frameUri ${res.frameUri}`);
|
|
22
|
-
if (!res.frameUri) {
|
|
23
|
-
throw new Error("No frameUri given. Maybe camera does not support it.");
|
|
24
|
-
}
|
|
25
|
-
// construct request headers
|
|
26
|
-
const { url: _frameUri, requestHeaders } = constructRequestHeaders(res.frameUri, requestModifiers, sessionId);
|
|
27
|
-
frameUri = _frameUri;
|
|
28
|
-
// remove content type
|
|
29
|
-
delete requestHeaders["Content-Type"];
|
|
30
|
-
delete requestHeaders["accept"];
|
|
31
|
-
logger.debug(`Fetching with headers\n${JSON.stringify(requestHeaders, null, 2)}`);
|
|
32
|
-
return await fetch(frameUri, {
|
|
33
|
-
method: "GET",
|
|
34
|
-
headers: requestHeaders,
|
|
35
|
-
}).then(async (res) => {
|
|
36
|
-
if (!res.ok) {
|
|
37
|
-
logger.error(`Failed to fetch image (HTTP ${res.status}): ${await res.text()}`);
|
|
38
|
-
logger.error(res);
|
|
39
|
-
return res.status === 404 ? 404 : null;
|
|
40
|
-
}
|
|
41
|
-
const arrayBuffer = await res.arrayBuffer();
|
|
42
|
-
const buffer = Buffer.from(arrayBuffer);
|
|
43
|
-
const base64 = buffer.toString("base64");
|
|
44
|
-
logger.debug(`Received image base64:\n ${base64}`);
|
|
45
|
-
return base64;
|
|
46
|
-
});
|
|
47
42
|
});
|
|
48
|
-
if (!
|
|
49
|
-
|
|
50
|
-
success: false,
|
|
51
|
-
status: "failed to fetch image",
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
if (base64Image === 404) {
|
|
43
|
+
if (res.error || !res.frameData) {
|
|
44
|
+
logger.error(`getExactFrameData failed: ${JSON.stringify({ error: res.error, errorMsg: res.errorMsg, status: res.status, hasFrameData: !!res.frameData })}`);
|
|
55
45
|
return {
|
|
56
46
|
success: false,
|
|
57
47
|
status: "failed to fetch image",
|
|
58
|
-
|
|
59
|
-
|
|
48
|
+
// Prefer a structured business error from the endpoint, then any transport-level
|
|
49
|
+
// status set by postApi (e.g. permission/HTTP errors), and only fall back to the
|
|
50
|
+
// no-VOD explanation when no error detail is available.
|
|
51
|
+
message: res.errorMsg ??
|
|
52
|
+
res.status ??
|
|
53
|
+
"Camera snapshot unavailable: this camera may have no recorded video (VOD) at the requested time. " +
|
|
54
|
+
"It may not have any footage stored, or the requested timestamp may be outside its retention window.",
|
|
60
55
|
};
|
|
61
56
|
}
|
|
62
57
|
return {
|
|
63
58
|
success: true,
|
|
64
59
|
status: "successfully fetched image",
|
|
65
|
-
frameUri: frameUri ?? "",
|
|
66
60
|
imageType: "base64",
|
|
67
|
-
imageData:
|
|
61
|
+
imageData: res.frameData,
|
|
62
|
+
crop: resolvedCrop,
|
|
68
63
|
};
|
|
69
64
|
}
|
|
70
65
|
async function getCameraStorageData(cameraUuid, requestModifiers, sessionId) {
|
|
@@ -21,8 +21,8 @@ This tool captures and returns a real-time snapshot from a designated security c
|
|
|
21
21
|
The image reflects the current scene in the camera's field of view and serves as a contextual
|
|
22
22
|
input source for downstream tasks such as object recognition, anomaly detection, incident investigation,
|
|
23
23
|
or situational assessment. When invoked, the tool provides the following:
|
|
24
|
-
- Visual Scene Capture: A high-resolution image of what the camera is actively observing, including people, vehicles, license plates, and any detectable objects.
|
|
25
|
-
-
|
|
24
|
+
- Visual Scene Capture: A high-resolution image of what the camera is actively observing, including people, vehicles, license plates, and any detectable objects.
|
|
25
|
+
- Optional zoom: pass cropX, cropY, cropWidth, cropHeight (each a percentage 0-100, origin at the top-left) to return only a sub-region of the frame so you can inspect a detail (e.g. a license plate or a doorway) more closely. Omit them for the full frame. When zooming into a small crop, pass a smaller downscaleFactor (e.g. 1-3) to preserve detail.
|
|
26
26
|
|
|
27
27
|
What follows is a description of the behavior of this tool given the requestType "get-settings"
|
|
28
28
|
|
|
@@ -56,7 +56,7 @@ Examples that REQUIRE the automatic snapshot flow:
|
|
|
56
56
|
const logger = getLogger("camera-tool");
|
|
57
57
|
const TOOL_ARGS = BASE_TOOL_ARGS;
|
|
58
58
|
const TOOL_HANDLER = async (args, extra) => {
|
|
59
|
-
const { cameraUuid, timestampISO, requestType } = args;
|
|
59
|
+
const { cameraUuid, timestampISO, requestType, cropX, cropY, cropWidth, cropHeight, downscaleFactor } = args;
|
|
60
60
|
if (!cameraUuid) {
|
|
61
61
|
return {
|
|
62
62
|
content: [
|
|
@@ -76,13 +76,21 @@ const TOOL_HANDLER = async (args, extra) => {
|
|
|
76
76
|
const { requestModifiers, sessionId } = extractFromToolExtra(extra);
|
|
77
77
|
switch (requestType) {
|
|
78
78
|
case "image":
|
|
79
|
-
response = await getImageForCameraAtTime(cameraUuid, timestampMs, requestModifiers, sessionId
|
|
79
|
+
response = await getImageForCameraAtTime(cameraUuid, timestampMs, requestModifiers, sessionId, {
|
|
80
|
+
crop: {
|
|
81
|
+
x: cropX ?? null,
|
|
82
|
+
y: cropY ?? null,
|
|
83
|
+
width: cropWidth ?? null,
|
|
84
|
+
height: cropHeight ?? null,
|
|
85
|
+
},
|
|
86
|
+
downscaleFactor: downscaleFactor ?? null,
|
|
87
|
+
});
|
|
80
88
|
if (!response.success || !response.imageData) {
|
|
81
89
|
return {
|
|
82
90
|
content: [{ type: "text", text: JSON.stringify(response) }],
|
|
83
91
|
};
|
|
84
92
|
}
|
|
85
|
-
logger.debug(`Received image response
|
|
93
|
+
logger.debug(`Received image response (base64 length ${response.imageData.length})`);
|
|
86
94
|
return {
|
|
87
95
|
content: [
|
|
88
96
|
{
|
|
@@ -97,7 +105,7 @@ const TOOL_HANDLER = async (args, extra) => {
|
|
|
97
105
|
status: "image-attached",
|
|
98
106
|
cameraUuid,
|
|
99
107
|
timestampMs,
|
|
100
|
-
|
|
108
|
+
cropApplied: response.crop ?? null,
|
|
101
109
|
}),
|
|
102
110
|
},
|
|
103
111
|
],
|
|
@@ -221,5 +221,39 @@ export const BASE_TOOL_ARGS = {
|
|
|
221
221
|
the timestamp for the image. This will default to 5 minutes before the current time. You can also call time-tool to parse the user's time description.
|
|
222
222
|
` + ISOTimestampFormatDescription),
|
|
223
223
|
requestType: z.enum(["image", "get-settings", "get-media-uris", "get-ai-thresholds"]),
|
|
224
|
+
cropX: z
|
|
225
|
+
.number()
|
|
226
|
+
.min(0)
|
|
227
|
+
.max(100)
|
|
228
|
+
.nullable()
|
|
229
|
+
.optional()
|
|
230
|
+
.describe("For requestType 'image' only. Left edge of the crop region as a percentage (0-100) of image width, measured from the left. 0 = left edge. Omit for full frame."),
|
|
231
|
+
cropY: z
|
|
232
|
+
.number()
|
|
233
|
+
.min(0)
|
|
234
|
+
.max(100)
|
|
235
|
+
.nullable()
|
|
236
|
+
.optional()
|
|
237
|
+
.describe("For requestType 'image' only. Top edge of the crop region as a percentage (0-100) of image height, measured from the top. 0 = top edge. Omit for full frame."),
|
|
238
|
+
cropWidth: z
|
|
239
|
+
.number()
|
|
240
|
+
.min(0)
|
|
241
|
+
.max(100)
|
|
242
|
+
.nullable()
|
|
243
|
+
.optional()
|
|
244
|
+
.describe("For requestType 'image' only. Width of the crop region as a percentage (0-100) of image width. Omit for full frame (100)."),
|
|
245
|
+
cropHeight: z
|
|
246
|
+
.number()
|
|
247
|
+
.min(0)
|
|
248
|
+
.max(100)
|
|
249
|
+
.nullable()
|
|
250
|
+
.optional()
|
|
251
|
+
.describe("For requestType 'image' only. Height of the crop region as a percentage (0-100) of image height. Omit for full frame (100)."),
|
|
252
|
+
downscaleFactor: z
|
|
253
|
+
.number()
|
|
254
|
+
.min(1)
|
|
255
|
+
.nullable()
|
|
256
|
+
.optional()
|
|
257
|
+
.describe("For requestType 'image' only. Ratio to shrink the image pixels by (default 10). Use a smaller value (e.g. 1-3) when zooming into a crop to keep detail."),
|
|
224
258
|
};
|
|
225
259
|
const BASE_TOOL_ARGS_SCHEMA = z.object(BASE_TOOL_ARGS);
|