@blueharford/scrypted-spatial-awareness 0.6.3 → 0.6.5
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.
- package/dist/main.nodejs.js +1 -1
- package/dist/main.nodejs.js.map +1 -1
- package/dist/plugin.zip +0 -0
- package/out/main.nodejs.js +169 -49
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/core/topology-discovery.ts +74 -18
- package/src/main.ts +122 -39
package/dist/plugin.zip
CHANGED
|
Binary file
|
package/out/main.nodejs.js
CHANGED
|
@@ -36081,13 +36081,15 @@ Identify:
|
|
|
36081
36081
|
2. Camera connections - How someone could move between camera views and estimated walking time
|
|
36082
36082
|
3. Overall layout - Describe the property layout based on what you see
|
|
36083
36083
|
|
|
36084
|
+
IMPORTANT: For camera references, use the EXACT device ID shown in parentheses (e.g., "device_123"), NOT the camera name.
|
|
36085
|
+
|
|
36084
36086
|
Respond with ONLY valid JSON:
|
|
36085
36087
|
{
|
|
36086
36088
|
"sharedLandmarks": [
|
|
36087
|
-
{"name": "Driveway", "type": "access", "seenByCameras": ["
|
|
36089
|
+
{"name": "Driveway", "type": "access", "seenByCameras": ["device_123", "device_456"], "confidence": 0.8, "description": "Concrete driveway"}
|
|
36088
36090
|
],
|
|
36089
36091
|
"connections": [
|
|
36090
|
-
{"from": "
|
|
36092
|
+
{"from": "device_123", "to": "device_456", "transitSeconds": 10, "via": "driveway", "confidence": 0.7, "bidirectional": true}
|
|
36091
36093
|
],
|
|
36092
36094
|
"layoutDescription": "Single-story house with front yard facing street, driveway on the left side, backyard accessible through side gate"
|
|
36093
36095
|
}`;
|
|
@@ -36410,6 +36412,36 @@ class TopologyDiscoveryEngine {
|
|
|
36410
36412
|
return 'west';
|
|
36411
36413
|
return 'unknown';
|
|
36412
36414
|
}
|
|
36415
|
+
/** Resolve a camera reference (name or deviceId) to its deviceId */
|
|
36416
|
+
resolveCameraRef(ref) {
|
|
36417
|
+
if (!this.topology?.cameras || !ref)
|
|
36418
|
+
return null;
|
|
36419
|
+
// Try exact deviceId match first
|
|
36420
|
+
const byId = this.topology.cameras.find(c => c.deviceId === ref);
|
|
36421
|
+
if (byId)
|
|
36422
|
+
return byId.deviceId;
|
|
36423
|
+
// Try exact name match
|
|
36424
|
+
const byName = this.topology.cameras.find(c => c.name === ref);
|
|
36425
|
+
if (byName)
|
|
36426
|
+
return byName.deviceId;
|
|
36427
|
+
// Try case-insensitive name match
|
|
36428
|
+
const refLower = ref.toLowerCase();
|
|
36429
|
+
const byNameLower = this.topology.cameras.find(c => c.name.toLowerCase() === refLower);
|
|
36430
|
+
if (byNameLower)
|
|
36431
|
+
return byNameLower.deviceId;
|
|
36432
|
+
// Try partial name match (LLM might truncate or abbreviate)
|
|
36433
|
+
const byPartial = this.topology.cameras.find(c => c.name.toLowerCase().includes(refLower) || refLower.includes(c.name.toLowerCase()));
|
|
36434
|
+
if (byPartial)
|
|
36435
|
+
return byPartial.deviceId;
|
|
36436
|
+
this.console.warn(`[Discovery] Could not resolve camera reference: "${ref}"`);
|
|
36437
|
+
return null;
|
|
36438
|
+
}
|
|
36439
|
+
/** Normalize camera references in an array to deviceIds */
|
|
36440
|
+
normalizeCameraRefs(refs) {
|
|
36441
|
+
return refs
|
|
36442
|
+
.map(ref => this.resolveCameraRef(ref))
|
|
36443
|
+
.filter((id) => id !== null);
|
|
36444
|
+
}
|
|
36413
36445
|
/** Analyze all cameras and correlate findings */
|
|
36414
36446
|
async runFullDiscovery() {
|
|
36415
36447
|
if (!this.topology?.cameras?.length) {
|
|
@@ -36502,23 +36534,41 @@ class TopologyDiscoveryEngine {
|
|
|
36502
36534
|
timestamp: Date.now(),
|
|
36503
36535
|
};
|
|
36504
36536
|
if (Array.isArray(parsed.sharedLandmarks)) {
|
|
36505
|
-
correlation.sharedLandmarks = parsed.sharedLandmarks.map((l) =>
|
|
36506
|
-
|
|
36507
|
-
|
|
36508
|
-
|
|
36509
|
-
|
|
36510
|
-
|
|
36511
|
-
|
|
36537
|
+
correlation.sharedLandmarks = parsed.sharedLandmarks.map((l) => {
|
|
36538
|
+
// Normalize camera references to deviceIds
|
|
36539
|
+
const rawRefs = Array.isArray(l.seenByCameras) ? l.seenByCameras : [];
|
|
36540
|
+
const normalizedRefs = this.normalizeCameraRefs(rawRefs);
|
|
36541
|
+
if (rawRefs.length > 0 && normalizedRefs.length === 0) {
|
|
36542
|
+
this.console.warn(`[Discovery] Landmark "${l.name}" has no resolvable camera refs: ${JSON.stringify(rawRefs)}`);
|
|
36543
|
+
}
|
|
36544
|
+
return {
|
|
36545
|
+
name: l.name || 'Unknown',
|
|
36546
|
+
type: this.mapLandmarkType(l.type),
|
|
36547
|
+
seenByCameras: normalizedRefs,
|
|
36548
|
+
confidence: typeof l.confidence === 'number' ? l.confidence : 0.7,
|
|
36549
|
+
description: l.description,
|
|
36550
|
+
};
|
|
36551
|
+
});
|
|
36512
36552
|
}
|
|
36513
36553
|
if (Array.isArray(parsed.connections)) {
|
|
36514
|
-
correlation.suggestedConnections = parsed.connections.map((c) =>
|
|
36515
|
-
|
|
36516
|
-
|
|
36517
|
-
|
|
36518
|
-
|
|
36519
|
-
|
|
36520
|
-
|
|
36521
|
-
|
|
36554
|
+
correlation.suggestedConnections = parsed.connections.map((c) => {
|
|
36555
|
+
// Normalize camera references to deviceIds
|
|
36556
|
+
const fromRef = c.from || c.fromCameraId || '';
|
|
36557
|
+
const toRef = c.to || c.toCameraId || '';
|
|
36558
|
+
const fromId = this.resolveCameraRef(fromRef);
|
|
36559
|
+
const toId = this.resolveCameraRef(toRef);
|
|
36560
|
+
if (!fromId || !toId) {
|
|
36561
|
+
this.console.warn(`[Discovery] Connection has unresolvable camera refs: from="${fromRef}" to="${toRef}"`);
|
|
36562
|
+
}
|
|
36563
|
+
return {
|
|
36564
|
+
fromCameraId: fromId || fromRef,
|
|
36565
|
+
toCameraId: toId || toRef,
|
|
36566
|
+
transitSeconds: typeof c.transitSeconds === 'number' ? c.transitSeconds : 15,
|
|
36567
|
+
via: c.via || '',
|
|
36568
|
+
confidence: typeof c.confidence === 'number' ? c.confidence : 0.6,
|
|
36569
|
+
bidirectional: c.bidirectional !== false,
|
|
36570
|
+
};
|
|
36571
|
+
});
|
|
36522
36572
|
}
|
|
36523
36573
|
this.console.log(`[Discovery] Correlation found ${correlation.sharedLandmarks.length} shared landmarks, ${correlation.suggestedConnections.length} connections`);
|
|
36524
36574
|
return correlation;
|
|
@@ -36552,7 +36602,9 @@ class TopologyDiscoveryEngine {
|
|
|
36552
36602
|
type: landmark.type,
|
|
36553
36603
|
description: landmark.description,
|
|
36554
36604
|
visibleFromCameras: [analysis.cameraId],
|
|
36555
|
-
|
|
36605
|
+
// Include bounding box for positioning (will be used by applyDiscoverySuggestion)
|
|
36606
|
+
boundingBox: landmark.boundingBox,
|
|
36607
|
+
}, // boundingBox is extra metadata not in Landmark interface
|
|
36556
36608
|
};
|
|
36557
36609
|
this.suggestions.set(suggestion.id, suggestion);
|
|
36558
36610
|
}
|
|
@@ -40229,18 +40281,25 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
40229
40281
|
if (!position || (position.x === 0 && position.y === 0)) {
|
|
40230
40282
|
// Debug logging
|
|
40231
40283
|
this.console.log(`[Discovery] Processing landmark "${suggestion.landmark.name}"`);
|
|
40284
|
+
this.console.log(`[Discovery] sourceCameras: ${JSON.stringify(suggestion.sourceCameras)}`);
|
|
40232
40285
|
this.console.log(`[Discovery] visibleFromCameras: ${JSON.stringify(suggestion.landmark.visibleFromCameras)}`);
|
|
40233
40286
|
this.console.log(`[Discovery] Available cameras: ${topology.cameras.map(c => `${c.name}(${c.deviceId})`).join(', ')}`);
|
|
40234
|
-
// Find a camera that can see this landmark
|
|
40287
|
+
// Find a camera that can see this landmark
|
|
40288
|
+
// PREFER sourceCameras (set from analysis.cameraId) over visibleFromCameras (from LLM parsing)
|
|
40289
|
+
const sourceCameraRef = suggestion.sourceCameras?.[0];
|
|
40235
40290
|
const visibleCameraRef = suggestion.landmark.visibleFromCameras?.[0];
|
|
40236
|
-
const
|
|
40237
|
-
|
|
40238
|
-
|
|
40291
|
+
const cameraRef = sourceCameraRef || visibleCameraRef;
|
|
40292
|
+
// Use flexible matching (deviceId, name, or case-insensitive)
|
|
40293
|
+
const camera = cameraRef ? topology.cameras.find(c => c.deviceId === cameraRef ||
|
|
40294
|
+
c.name === cameraRef ||
|
|
40295
|
+
c.name.toLowerCase() === cameraRef.toLowerCase()) : null;
|
|
40239
40296
|
if (camera) {
|
|
40240
|
-
this.console.log(`[Discovery] Matched camera: ${camera.name}
|
|
40297
|
+
this.console.log(`[Discovery] Matched camera: ${camera.name} (${camera.deviceId})`);
|
|
40298
|
+
this.console.log(`[Discovery] Camera position: ${JSON.stringify(camera.floorPlanPosition)}`);
|
|
40299
|
+
this.console.log(`[Discovery] Camera FOV: ${JSON.stringify(camera.fov)}`);
|
|
40241
40300
|
}
|
|
40242
40301
|
else {
|
|
40243
|
-
this.console.warn(`[Discovery] No camera matched for "${visibleCameraRef}"`);
|
|
40302
|
+
this.console.warn(`[Discovery] No camera matched for ref="${cameraRef}" (source="${sourceCameraRef}", visible="${visibleCameraRef}")`);
|
|
40244
40303
|
}
|
|
40245
40304
|
if (camera?.floorPlanPosition) {
|
|
40246
40305
|
// Get camera's FOV direction and range (cast to any for flexible access)
|
|
@@ -40248,24 +40307,46 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
40248
40307
|
const direction = fov.direction || 0;
|
|
40249
40308
|
const range = fov.range || 80;
|
|
40250
40309
|
const fovAngle = fov.angle || 90;
|
|
40251
|
-
// Count existing landmarks from this camera to spread them out
|
|
40252
|
-
const cameraDeviceId = camera.deviceId;
|
|
40253
|
-
const existingFromCamera = (topology.landmarks || []).filter(l => l.visibleFromCameras?.includes(cameraDeviceId) ||
|
|
40254
|
-
l.visibleFromCameras?.includes(camera.name)).length;
|
|
40255
40310
|
// Calculate position in front of camera within its FOV
|
|
40256
40311
|
// Convert direction to radians (0 = up/north, 90 = right/east)
|
|
40257
40312
|
const dirRad = (direction - 90) * Math.PI / 180;
|
|
40258
40313
|
const halfFov = (fovAngle / 2) * Math.PI / 180;
|
|
40259
|
-
//
|
|
40260
|
-
|
|
40261
|
-
const
|
|
40314
|
+
// Use bounding box if available to position landmark accurately within FOV
|
|
40315
|
+
// boundingBox format: [x, y, width, height] normalized 0-1
|
|
40316
|
+
const bbox = suggestion.landmark.boundingBox;
|
|
40317
|
+
let angleOffset;
|
|
40318
|
+
let distanceMultiplier;
|
|
40319
|
+
if (bbox && bbox.length >= 2) {
|
|
40320
|
+
// Use bounding box center to determine position in FOV
|
|
40321
|
+
const bboxCenterX = bbox[0] + (bbox[2] || 0) / 2; // 0 = left edge, 1 = right edge
|
|
40322
|
+
const bboxCenterY = bbox[1] + (bbox[3] || 0) / 2; // 0 = top (far), 1 = bottom (close)
|
|
40323
|
+
// Map X position to angle within FOV
|
|
40324
|
+
// bboxCenterX 0 → left side of FOV (-halfFov)
|
|
40325
|
+
// bboxCenterX 0.5 → center of FOV (0)
|
|
40326
|
+
// bboxCenterX 1 → right side of FOV (+halfFov)
|
|
40327
|
+
angleOffset = (bboxCenterX - 0.5) * 2 * halfFov;
|
|
40328
|
+
// Map Y position to distance from camera
|
|
40329
|
+
// bboxCenterY 0 (top of frame) → far from camera (90% of range)
|
|
40330
|
+
// bboxCenterY 1 (bottom of frame) → close to camera (30% of range)
|
|
40331
|
+
distanceMultiplier = 0.9 - (bboxCenterY * 0.6);
|
|
40332
|
+
this.console.log(`[Discovery] Using bounding box [${bbox.join(',')}] → center (${bboxCenterX.toFixed(2)}, ${bboxCenterY.toFixed(2)})`);
|
|
40333
|
+
}
|
|
40334
|
+
else {
|
|
40335
|
+
// Fallback: spread landmarks across FOV if no bounding box
|
|
40336
|
+
const cameraDeviceId = camera.deviceId;
|
|
40337
|
+
const existingFromCamera = (topology.landmarks || []).filter(l => l.visibleFromCameras?.includes(cameraDeviceId) ||
|
|
40338
|
+
l.visibleFromCameras?.includes(camera.name)).length;
|
|
40339
|
+
angleOffset = (existingFromCamera % 3 - 1) * halfFov * 0.6;
|
|
40340
|
+
distanceMultiplier = 0.5 + (existingFromCamera % 2) * 0.3;
|
|
40341
|
+
this.console.log(`[Discovery] No bounding box, using fallback spread (existing: ${existingFromCamera})`);
|
|
40342
|
+
}
|
|
40262
40343
|
const finalAngle = dirRad + angleOffset;
|
|
40263
40344
|
const distance = range * distanceMultiplier;
|
|
40264
40345
|
position = {
|
|
40265
40346
|
x: camera.floorPlanPosition.x + Math.cos(finalAngle) * distance,
|
|
40266
40347
|
y: camera.floorPlanPosition.y + Math.sin(finalAngle) * distance,
|
|
40267
40348
|
};
|
|
40268
|
-
this.console.log(`[Discovery] Placing landmark "${suggestion.landmark.name}" in ${camera.name}'s FOV: dir=${direction}°, dist=${distance.toFixed(0)}px`);
|
|
40349
|
+
this.console.log(`[Discovery] Placing landmark "${suggestion.landmark.name}" in ${camera.name}'s FOV: dir=${direction}°, angle=${(angleOffset * 180 / Math.PI).toFixed(1)}°, dist=${distance.toFixed(0)}px`);
|
|
40269
40350
|
}
|
|
40270
40351
|
else {
|
|
40271
40352
|
// Position in a grid pattern starting from center
|
|
@@ -40301,9 +40382,19 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
40301
40382
|
const zone = suggestion.zone;
|
|
40302
40383
|
// Find cameras that see this zone
|
|
40303
40384
|
const sourceCameras = suggestion.sourceCameras || [];
|
|
40304
|
-
const
|
|
40305
|
-
|
|
40385
|
+
const cameraRef = sourceCameras[0];
|
|
40386
|
+
const camera = cameraRef
|
|
40387
|
+
? topology.cameras.find(c => c.deviceId === cameraRef ||
|
|
40388
|
+
c.name === cameraRef ||
|
|
40389
|
+
c.name.toLowerCase() === cameraRef.toLowerCase())
|
|
40306
40390
|
: null;
|
|
40391
|
+
this.console.log(`[Discovery] Processing zone "${zone.name}" from camera ref="${cameraRef}"`);
|
|
40392
|
+
if (camera) {
|
|
40393
|
+
this.console.log(`[Discovery] Matched camera: ${camera.name} (${camera.deviceId})`);
|
|
40394
|
+
}
|
|
40395
|
+
else if (cameraRef) {
|
|
40396
|
+
this.console.warn(`[Discovery] No camera matched for zone source ref="${cameraRef}"`);
|
|
40397
|
+
}
|
|
40307
40398
|
// Create zone polygon WITHIN the camera's field of view
|
|
40308
40399
|
let polygon = [];
|
|
40309
40400
|
const timestamp = Date.now();
|
|
@@ -40316,29 +40407,54 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
40316
40407
|
// Convert direction to radians (0 = up/north, 90 = right/east)
|
|
40317
40408
|
const dirRad = (direction - 90) * Math.PI / 180;
|
|
40318
40409
|
const halfFov = (fovAngle / 2) * Math.PI / 180;
|
|
40319
|
-
// Count existing zones from this camera to offset new ones
|
|
40320
|
-
const existingFromCamera = (topology.drawnZones || []).filter((z) => z.linkedCameras?.includes(sourceCameras[0])).length;
|
|
40321
|
-
// Create a wedge-shaped zone within the camera's FOV
|
|
40322
|
-
// Offset based on existing zones to avoid overlap
|
|
40323
|
-
const innerRadius = range * 0.3 + existingFromCamera * 20;
|
|
40324
|
-
const outerRadius = range * 0.8 + existingFromCamera * 20;
|
|
40325
|
-
// Use a portion of the FOV for each zone
|
|
40326
|
-
const zoneSpread = halfFov * 0.7; // 70% of half FOV
|
|
40327
40410
|
const camX = camera.floorPlanPosition.x;
|
|
40328
40411
|
const camY = camera.floorPlanPosition.y;
|
|
40329
|
-
//
|
|
40412
|
+
// Use bounding box if available to position zone accurately within FOV
|
|
40413
|
+
const bbox = zone.boundingBox; // [x, y, width, height] normalized 0-1
|
|
40414
|
+
let innerRadius;
|
|
40415
|
+
let outerRadius;
|
|
40416
|
+
let angleStart;
|
|
40417
|
+
let angleEnd;
|
|
40418
|
+
if (bbox && bbox.length >= 4) {
|
|
40419
|
+
// Map bounding box to position within FOV
|
|
40420
|
+
const bboxLeft = bbox[0];
|
|
40421
|
+
const bboxRight = bbox[0] + bbox[2];
|
|
40422
|
+
const bboxTop = bbox[1];
|
|
40423
|
+
const bboxBottom = bbox[1] + bbox[3];
|
|
40424
|
+
// Map X to angle within FOV (0 = left edge, 1 = right edge)
|
|
40425
|
+
angleStart = dirRad + (bboxLeft - 0.5) * 2 * halfFov;
|
|
40426
|
+
angleEnd = dirRad + (bboxRight - 0.5) * 2 * halfFov;
|
|
40427
|
+
// Map Y to distance (0 = far, 1 = close)
|
|
40428
|
+
innerRadius = range * (0.9 - bboxBottom * 0.6);
|
|
40429
|
+
outerRadius = range * (0.9 - bboxTop * 0.6);
|
|
40430
|
+
// Ensure min size
|
|
40431
|
+
if (outerRadius - innerRadius < 20) {
|
|
40432
|
+
outerRadius = innerRadius + 20;
|
|
40433
|
+
}
|
|
40434
|
+
this.console.log(`[Discovery] Zone "${zone.name}" using bbox [${bbox.join(',')}] → angles ${(angleStart * 180 / Math.PI).toFixed(1)}° to ${(angleEnd * 180 / Math.PI).toFixed(1)}°`);
|
|
40435
|
+
}
|
|
40436
|
+
else {
|
|
40437
|
+
// Fallback: wedge-shaped zone offset by existing count
|
|
40438
|
+
const existingFromCamera = (topology.drawnZones || []).filter((z) => z.linkedCameras?.includes(sourceCameras[0])).length;
|
|
40439
|
+
innerRadius = range * 0.3 + existingFromCamera * 20;
|
|
40440
|
+
outerRadius = range * 0.8 + existingFromCamera * 20;
|
|
40441
|
+
angleStart = dirRad - halfFov * 0.7;
|
|
40442
|
+
angleEnd = dirRad + halfFov * 0.7;
|
|
40443
|
+
this.console.log(`[Discovery] Zone "${zone.name}" using fallback spread (existing: ${existingFromCamera})`);
|
|
40444
|
+
}
|
|
40445
|
+
// Create arc polygon
|
|
40330
40446
|
const steps = 8;
|
|
40331
|
-
// Inner arc (from
|
|
40447
|
+
// Inner arc (from start angle to end angle)
|
|
40332
40448
|
for (let i = 0; i <= steps; i++) {
|
|
40333
|
-
const angle =
|
|
40449
|
+
const angle = angleStart + (angleEnd - angleStart) * i / steps;
|
|
40334
40450
|
polygon.push({
|
|
40335
40451
|
x: camX + Math.cos(angle) * innerRadius,
|
|
40336
40452
|
y: camY + Math.sin(angle) * innerRadius,
|
|
40337
40453
|
});
|
|
40338
40454
|
}
|
|
40339
|
-
// Outer arc (from
|
|
40455
|
+
// Outer arc (from end angle to start angle)
|
|
40340
40456
|
for (let i = steps; i >= 0; i--) {
|
|
40341
|
-
const angle =
|
|
40457
|
+
const angle = angleStart + (angleEnd - angleStart) * i / steps;
|
|
40342
40458
|
polygon.push({
|
|
40343
40459
|
x: camX + Math.cos(angle) * outerRadius,
|
|
40344
40460
|
y: camY + Math.sin(angle) * outerRadius,
|
|
@@ -40375,9 +40491,13 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
40375
40491
|
const globalZoneType = this.mapZoneTypeToGlobalType(zone.type);
|
|
40376
40492
|
const cameraZones = sourceCameras
|
|
40377
40493
|
.map(camRef => {
|
|
40378
|
-
const cam = topology.cameras.find(c => c.deviceId === camRef ||
|
|
40379
|
-
|
|
40494
|
+
const cam = topology.cameras.find(c => c.deviceId === camRef ||
|
|
40495
|
+
c.name === camRef ||
|
|
40496
|
+
c.name.toLowerCase() === camRef.toLowerCase());
|
|
40497
|
+
if (!cam) {
|
|
40498
|
+
this.console.warn(`[Discovery] GlobalZone: No camera matched for ref="${camRef}"`);
|
|
40380
40499
|
return null;
|
|
40500
|
+
}
|
|
40381
40501
|
return {
|
|
40382
40502
|
cameraId: cam.deviceId,
|
|
40383
40503
|
zone: [[0, 0], [100, 0], [100, 100], [0, 100]], // Full frame default
|