@blueharford/scrypted-spatial-awareness 0.1.11 → 0.1.15
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 +1019 -92
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/alerts/alert-manager.ts +43 -8
- package/src/core/tracking-engine.ts +15 -1
- package/src/main.ts +129 -8
- package/src/models/alert.ts +31 -4
package/out/main.nodejs.js
CHANGED
|
@@ -34350,14 +34350,44 @@ function socketOnError() {
|
|
|
34350
34350
|
* Alert Manager
|
|
34351
34351
|
* Generates and dispatches alerts based on tracking events
|
|
34352
34352
|
*/
|
|
34353
|
-
var
|
|
34354
|
-
|
|
34355
|
-
|
|
34353
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
34354
|
+
if (k2 === undefined) k2 = k;
|
|
34355
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
34356
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
34357
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
34358
|
+
}
|
|
34359
|
+
Object.defineProperty(o, k2, desc);
|
|
34360
|
+
}) : (function(o, m, k, k2) {
|
|
34361
|
+
if (k2 === undefined) k2 = k;
|
|
34362
|
+
o[k2] = m[k];
|
|
34363
|
+
}));
|
|
34364
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
34365
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
34366
|
+
}) : function(o, v) {
|
|
34367
|
+
o["default"] = v;
|
|
34368
|
+
});
|
|
34369
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
34370
|
+
var ownKeys = function(o) {
|
|
34371
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
34372
|
+
var ar = [];
|
|
34373
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
34374
|
+
return ar;
|
|
34375
|
+
};
|
|
34376
|
+
return ownKeys(o);
|
|
34377
|
+
};
|
|
34378
|
+
return function (mod) {
|
|
34379
|
+
if (mod && mod.__esModule) return mod;
|
|
34380
|
+
var result = {};
|
|
34381
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
34382
|
+
__setModuleDefault(result, mod);
|
|
34383
|
+
return result;
|
|
34384
|
+
};
|
|
34385
|
+
})();
|
|
34356
34386
|
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
|
34357
34387
|
exports.AlertManager = void 0;
|
|
34358
|
-
const sdk_1 =
|
|
34388
|
+
const sdk_1 = __importStar(__webpack_require__(/*! @scrypted/sdk */ "./node_modules/@scrypted/sdk/dist/src/index.js"));
|
|
34359
34389
|
const alert_1 = __webpack_require__(/*! ../models/alert */ "./src/models/alert.ts");
|
|
34360
|
-
const { systemManager } = sdk_1.default;
|
|
34390
|
+
const { systemManager, mediaManager } = sdk_1.default;
|
|
34361
34391
|
class AlertManager {
|
|
34362
34392
|
rules = [];
|
|
34363
34393
|
recentAlerts = [];
|
|
@@ -34426,6 +34456,20 @@ class AlertManager {
|
|
|
34426
34456
|
const notifierIds = rule.notifiers.length > 0
|
|
34427
34457
|
? rule.notifiers
|
|
34428
34458
|
: this.getDefaultNotifiers();
|
|
34459
|
+
// Try to get a thumbnail from the camera
|
|
34460
|
+
let mediaObject;
|
|
34461
|
+
const cameraId = alert.details.toCameraId || alert.details.cameraId;
|
|
34462
|
+
if (cameraId) {
|
|
34463
|
+
try {
|
|
34464
|
+
const camera = systemManager.getDeviceById(cameraId);
|
|
34465
|
+
if (camera && camera.interfaces?.includes(sdk_1.ScryptedInterface.Camera)) {
|
|
34466
|
+
mediaObject = await camera.takePicture();
|
|
34467
|
+
}
|
|
34468
|
+
}
|
|
34469
|
+
catch (e) {
|
|
34470
|
+
this.console.warn(`Failed to get thumbnail from camera ${cameraId}:`, e);
|
|
34471
|
+
}
|
|
34472
|
+
}
|
|
34429
34473
|
for (const notifierId of notifierIds) {
|
|
34430
34474
|
try {
|
|
34431
34475
|
const notifier = systemManager.getDeviceById(notifierId);
|
|
@@ -34441,8 +34485,8 @@ class AlertManager {
|
|
|
34441
34485
|
trackedObjectId: alert.trackedObjectId,
|
|
34442
34486
|
timestamp: alert.timestamp,
|
|
34443
34487
|
},
|
|
34444
|
-
});
|
|
34445
|
-
this.console.log(`Notification sent to ${notifierId}`);
|
|
34488
|
+
}, mediaObject);
|
|
34489
|
+
this.console.log(`Notification sent to ${notifierId}${mediaObject ? ' with thumbnail' : ''}`);
|
|
34446
34490
|
}
|
|
34447
34491
|
catch (e) {
|
|
34448
34492
|
this.console.error(`Failed to send notification to ${notifierId}:`, e);
|
|
@@ -34454,16 +34498,18 @@ class AlertManager {
|
|
|
34454
34498
|
*/
|
|
34455
34499
|
getNotificationTitle(alert) {
|
|
34456
34500
|
const prefix = alert.severity === 'critical' ? '🚨 ' :
|
|
34457
|
-
alert.severity === 'warning' ? '⚠️ ' : '
|
|
34501
|
+
alert.severity === 'warning' ? '⚠️ ' : '';
|
|
34458
34502
|
switch (alert.type) {
|
|
34459
34503
|
case 'property_entry':
|
|
34460
|
-
return `${prefix}Entry Detected`;
|
|
34504
|
+
return `${prefix}🚶 Entry Detected`;
|
|
34461
34505
|
case 'property_exit':
|
|
34462
|
-
return `${prefix}Exit Detected`;
|
|
34506
|
+
return `${prefix}🚶 Exit Detected`;
|
|
34507
|
+
case 'movement':
|
|
34508
|
+
return `${prefix}🚶 Movement Detected`;
|
|
34463
34509
|
case 'unusual_path':
|
|
34464
34510
|
return `${prefix}Unusual Path`;
|
|
34465
34511
|
case 'dwell_time':
|
|
34466
|
-
return `${prefix}Extended Presence`;
|
|
34512
|
+
return `${prefix}⏱️ Extended Presence`;
|
|
34467
34513
|
case 'restricted_zone':
|
|
34468
34514
|
return `${prefix}Restricted Zone Alert`;
|
|
34469
34515
|
case 'lost_tracking':
|
|
@@ -34479,6 +34525,25 @@ class AlertManager {
|
|
|
34479
34525
|
*/
|
|
34480
34526
|
getDefaultNotifiers() {
|
|
34481
34527
|
try {
|
|
34528
|
+
// Try new multiple notifiers setting first
|
|
34529
|
+
const notifiers = this.storage.getItem('defaultNotifiers');
|
|
34530
|
+
if (notifiers) {
|
|
34531
|
+
// Could be JSON array or comma-separated string
|
|
34532
|
+
try {
|
|
34533
|
+
const parsed = JSON.parse(notifiers);
|
|
34534
|
+
if (Array.isArray(parsed)) {
|
|
34535
|
+
return parsed;
|
|
34536
|
+
}
|
|
34537
|
+
}
|
|
34538
|
+
catch {
|
|
34539
|
+
// Not JSON, might be comma-separated or single value
|
|
34540
|
+
if (notifiers.includes(',')) {
|
|
34541
|
+
return notifiers.split(',').map(s => s.trim()).filter(Boolean);
|
|
34542
|
+
}
|
|
34543
|
+
return [notifiers];
|
|
34544
|
+
}
|
|
34545
|
+
}
|
|
34546
|
+
// Fallback to old single notifier setting
|
|
34482
34547
|
const defaultNotifier = this.storage.getItem('defaultNotifier');
|
|
34483
34548
|
return defaultNotifier ? [defaultNotifier] : [];
|
|
34484
34549
|
}
|
|
@@ -35117,6 +35182,7 @@ class TrackingEngine {
|
|
|
35117
35182
|
// Check if this is a cross-camera transition
|
|
35118
35183
|
const lastSighting = (0, tracked_object_1.getLastSighting)(tracked);
|
|
35119
35184
|
if (lastSighting && lastSighting.cameraId !== sighting.cameraId) {
|
|
35185
|
+
const transitDuration = sighting.timestamp - lastSighting.timestamp;
|
|
35120
35186
|
// Add journey segment
|
|
35121
35187
|
this.state.addJourney(tracked.globalId, {
|
|
35122
35188
|
fromCameraId: lastSighting.cameraId,
|
|
@@ -35125,12 +35191,23 @@ class TrackingEngine {
|
|
|
35125
35191
|
toCameraName: sighting.cameraName,
|
|
35126
35192
|
exitTime: lastSighting.timestamp,
|
|
35127
35193
|
entryTime: sighting.timestamp,
|
|
35128
|
-
transitDuration
|
|
35194
|
+
transitDuration,
|
|
35129
35195
|
correlationConfidence: correlation.confidence,
|
|
35130
35196
|
});
|
|
35131
35197
|
this.console.log(`Object ${tracked.globalId.slice(0, 8)} transited: ` +
|
|
35132
35198
|
`${lastSighting.cameraName} → ${sighting.cameraName} ` +
|
|
35133
35199
|
`(confidence: ${(correlation.confidence * 100).toFixed(0)}%)`);
|
|
35200
|
+
// Generate movement alert for cross-camera transition
|
|
35201
|
+
await this.alertManager.checkAndAlert('movement', tracked, {
|
|
35202
|
+
fromCameraId: lastSighting.cameraId,
|
|
35203
|
+
fromCameraName: lastSighting.cameraName,
|
|
35204
|
+
toCameraId: sighting.cameraId,
|
|
35205
|
+
toCameraName: sighting.cameraName,
|
|
35206
|
+
transitTime: transitDuration,
|
|
35207
|
+
objectClass: sighting.detection.className,
|
|
35208
|
+
objectLabel: sighting.detection.label,
|
|
35209
|
+
detectionId: sighting.detectionId,
|
|
35210
|
+
});
|
|
35134
35211
|
}
|
|
35135
35212
|
// Add sighting to tracked object
|
|
35136
35213
|
this.state.addSighting(tracked.globalId, sighting);
|
|
@@ -35915,7 +35992,6 @@ exports.MqttPublisher = MqttPublisher;
|
|
|
35915
35992
|
(__unused_webpack_module, exports, __webpack_require__) {
|
|
35916
35993
|
|
|
35917
35994
|
"use strict";
|
|
35918
|
-
var __webpack_dirname__ = "src";
|
|
35919
35995
|
|
|
35920
35996
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
35921
35997
|
if (k2 === undefined) k2 = k;
|
|
@@ -35962,8 +36038,7 @@ const alert_manager_1 = __webpack_require__(/*! ./alerts/alert-manager */ "./src
|
|
|
35962
36038
|
const global_tracker_sensor_1 = __webpack_require__(/*! ./devices/global-tracker-sensor */ "./src/devices/global-tracker-sensor.ts");
|
|
35963
36039
|
const tracking_zone_1 = __webpack_require__(/*! ./devices/tracking-zone */ "./src/devices/tracking-zone.ts");
|
|
35964
36040
|
const mqtt_publisher_1 = __webpack_require__(/*! ./integrations/mqtt-publisher */ "./src/integrations/mqtt-publisher.ts");
|
|
35965
|
-
const
|
|
35966
|
-
const path = __importStar(__webpack_require__(/*! path */ "path"));
|
|
36041
|
+
const editor_html_1 = __webpack_require__(/*! ./ui/editor-html */ "./src/ui/editor-html.ts");
|
|
35967
36042
|
const { deviceManager, systemManager } = sdk_1.default;
|
|
35968
36043
|
const TRACKING_ZONE_PREFIX = 'tracking-zone:';
|
|
35969
36044
|
const GLOBAL_TRACKER_ID = 'global-tracker';
|
|
@@ -36052,10 +36127,12 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
36052
36127
|
defaultValue: true,
|
|
36053
36128
|
group: 'Alerts',
|
|
36054
36129
|
},
|
|
36055
|
-
|
|
36056
|
-
title: '
|
|
36130
|
+
defaultNotifiers: {
|
|
36131
|
+
title: 'Notifiers',
|
|
36057
36132
|
type: 'device',
|
|
36133
|
+
multiple: true,
|
|
36058
36134
|
deviceFilter: `interfaces.includes('${sdk_1.ScryptedInterface.Notifier}')`,
|
|
36135
|
+
description: 'Select one or more notifiers to receive alerts',
|
|
36059
36136
|
group: 'Alerts',
|
|
36060
36137
|
},
|
|
36061
36138
|
// Tracked Cameras
|
|
@@ -36227,24 +36304,82 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
36227
36304
|
// ==================== Settings Implementation ====================
|
|
36228
36305
|
async getSettings() {
|
|
36229
36306
|
const settings = await this.storageSettings.getSettings();
|
|
36230
|
-
//
|
|
36307
|
+
// Topology editor button that opens modal overlay (appended to body for proper z-index)
|
|
36308
|
+
const onclickCode = `(function(){var e=document.getElementById('sa-topology-modal');if(e)e.remove();var m=document.createElement('div');m.id='sa-topology-modal';m.style.cssText='position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.9);z-index:2147483647;display:flex;align-items:center;justify-content:center;';var c=document.createElement('div');c.style.cssText='width:95vw;height:92vh;max-width:1800px;background:#1a1a2e;border-radius:12px;overflow:hidden;position:relative;box-shadow:0 25px 50px rgba(0,0,0,0.5);';var b=document.createElement('button');b.innerHTML='×';b.style.cssText='position:absolute;top:15px;right:15px;z-index:2147483647;background:#e94560;color:white;border:none;width:40px;height:40px;border-radius:50%;font-size:24px;cursor:pointer;';b.onclick=function(){m.remove();};var f=document.createElement('iframe');f.src='/endpoint/@blueharford/scrypted-spatial-awareness/ui/editor';f.style.cssText='width:100%;height:100%;border:none;';c.appendChild(b);c.appendChild(f);m.appendChild(c);m.onclick=function(ev){if(ev.target===m)m.remove();};document.body.appendChild(m);})()`;
|
|
36231
36309
|
settings.push({
|
|
36232
|
-
key: '
|
|
36233
|
-
title: '
|
|
36234
|
-
type: '
|
|
36235
|
-
|
|
36236
|
-
|
|
36310
|
+
key: 'topologyEditor',
|
|
36311
|
+
title: 'Topology Editor',
|
|
36312
|
+
type: 'html',
|
|
36313
|
+
value: `
|
|
36314
|
+
<style>
|
|
36315
|
+
.sa-open-btn {
|
|
36316
|
+
background: linear-gradient(135deg, #e94560 0%, #0f3460 100%);
|
|
36317
|
+
color: white;
|
|
36318
|
+
border: none;
|
|
36319
|
+
padding: 14px 28px;
|
|
36320
|
+
border-radius: 8px;
|
|
36321
|
+
font-size: 15px;
|
|
36322
|
+
font-weight: 600;
|
|
36323
|
+
cursor: pointer;
|
|
36324
|
+
display: inline-flex;
|
|
36325
|
+
align-items: center;
|
|
36326
|
+
gap: 10px;
|
|
36327
|
+
transition: transform 0.2s, box-shadow 0.2s;
|
|
36328
|
+
}
|
|
36329
|
+
.sa-open-btn:hover {
|
|
36330
|
+
transform: translateY(-2px);
|
|
36331
|
+
box-shadow: 0 8px 20px rgba(233, 69, 96, 0.4);
|
|
36332
|
+
}
|
|
36333
|
+
.sa-btn-container {
|
|
36334
|
+
padding: 20px;
|
|
36335
|
+
background: #16213e;
|
|
36336
|
+
border-radius: 8px;
|
|
36337
|
+
text-align: center;
|
|
36338
|
+
}
|
|
36339
|
+
.sa-btn-desc {
|
|
36340
|
+
color: #888;
|
|
36341
|
+
margin-bottom: 15px;
|
|
36342
|
+
font-size: 14px;
|
|
36343
|
+
}
|
|
36344
|
+
</style>
|
|
36345
|
+
<div class="sa-btn-container">
|
|
36346
|
+
<p class="sa-btn-desc">Configure camera positions, connections, and transit times</p>
|
|
36347
|
+
<button class="sa-open-btn" onclick="${onclickCode}">
|
|
36348
|
+
<span>⚙</span> Open Topology Editor
|
|
36349
|
+
</button>
|
|
36350
|
+
</div>
|
|
36351
|
+
`,
|
|
36352
|
+
group: 'Topology',
|
|
36237
36353
|
});
|
|
36238
36354
|
// Add status display
|
|
36239
36355
|
const activeCount = this.trackingState.getActiveCount();
|
|
36356
|
+
const topologyJson = this.storage.getItem('topology');
|
|
36357
|
+
let statusText = 'Not configured - add cameras and configure topology';
|
|
36358
|
+
if (this.trackingEngine) {
|
|
36359
|
+
statusText = `Active: Tracking ${activeCount} object${activeCount !== 1 ? 's' : ''}`;
|
|
36360
|
+
}
|
|
36361
|
+
else if (topologyJson) {
|
|
36362
|
+
try {
|
|
36363
|
+
const topology = JSON.parse(topologyJson);
|
|
36364
|
+
if (topology.cameras && topology.cameras.length > 0) {
|
|
36365
|
+
// Topology exists but engine not running - try to start it
|
|
36366
|
+
statusText = `Configured (${topology.cameras.length} cameras) - Starting...`;
|
|
36367
|
+
// Restart the tracking engine asynchronously
|
|
36368
|
+
this.startTrackingEngine(topology).catch(e => {
|
|
36369
|
+
this.console.error('Failed to restart tracking engine:', e);
|
|
36370
|
+
});
|
|
36371
|
+
}
|
|
36372
|
+
}
|
|
36373
|
+
catch (e) {
|
|
36374
|
+
statusText = 'Error loading topology';
|
|
36375
|
+
}
|
|
36376
|
+
}
|
|
36240
36377
|
settings.push({
|
|
36241
36378
|
key: 'status',
|
|
36242
36379
|
title: 'Tracking Status',
|
|
36243
36380
|
type: 'string',
|
|
36244
36381
|
readonly: true,
|
|
36245
|
-
value:
|
|
36246
|
-
? `Active: Tracking ${activeCount} object${activeCount !== 1 ? 's' : ''}`
|
|
36247
|
-
: 'Not configured - add cameras and configure topology',
|
|
36382
|
+
value: statusText,
|
|
36248
36383
|
group: 'Status',
|
|
36249
36384
|
});
|
|
36250
36385
|
// Add recent alerts summary
|
|
@@ -36259,14 +36394,77 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
36259
36394
|
group: 'Status',
|
|
36260
36395
|
});
|
|
36261
36396
|
}
|
|
36397
|
+
// Add alert rules configuration UI
|
|
36398
|
+
const alertRules = this.alertManager.getRules();
|
|
36399
|
+
const rulesHtml = this.generateAlertRulesHtml(alertRules);
|
|
36400
|
+
settings.push({
|
|
36401
|
+
key: 'alertRulesEditor',
|
|
36402
|
+
title: 'Alert Rules',
|
|
36403
|
+
type: 'html',
|
|
36404
|
+
value: rulesHtml,
|
|
36405
|
+
group: 'Alerts',
|
|
36406
|
+
});
|
|
36262
36407
|
return settings;
|
|
36263
36408
|
}
|
|
36409
|
+
generateAlertRulesHtml(rules) {
|
|
36410
|
+
const ruleRows = rules.map(rule => `
|
|
36411
|
+
<tr data-rule-id="${rule.id}">
|
|
36412
|
+
<td style="padding:8px;border-bottom:1px solid #333;">
|
|
36413
|
+
<input type="checkbox" ${rule.enabled ? 'checked' : ''}
|
|
36414
|
+
onchange="(function(el){var rules=JSON.parse(localStorage.getItem('sa-temp-rules')||'[]');var r=rules.find(x=>x.id==='${rule.id}');if(r)r.enabled=el.checked;localStorage.setItem('sa-temp-rules',JSON.stringify(rules));})(this)" />
|
|
36415
|
+
</td>
|
|
36416
|
+
<td style="padding:8px;border-bottom:1px solid #333;color:#fff;">${rule.name}</td>
|
|
36417
|
+
<td style="padding:8px;border-bottom:1px solid #333;color:#888;">${rule.type}</td>
|
|
36418
|
+
<td style="padding:8px;border-bottom:1px solid #333;">
|
|
36419
|
+
<span style="padding:2px 8px;border-radius:4px;font-size:12px;background:${rule.severity === 'critical' ? '#e94560' :
|
|
36420
|
+
rule.severity === 'warning' ? '#f39c12' : '#3498db'};color:white;">${rule.severity}</span>
|
|
36421
|
+
</td>
|
|
36422
|
+
<td style="padding:8px;border-bottom:1px solid #333;color:#888;">${Math.round(rule.cooldown / 1000)}s</td>
|
|
36423
|
+
</tr>
|
|
36424
|
+
`).join('');
|
|
36425
|
+
const initCode = `localStorage.setItem('sa-temp-rules',JSON.stringify(${JSON.stringify(rules)}))`;
|
|
36426
|
+
const saveCode = `(function(){var rules=JSON.parse(localStorage.getItem('sa-temp-rules')||'[]');fetch('/endpoint/@blueharford/scrypted-spatial-awareness/api/alert-rules',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(rules)}).then(r=>r.json()).then(d=>{if(d.success)alert('Alert rules saved!');else alert('Error: '+d.error);}).catch(e=>alert('Error: '+e));})()`;
|
|
36427
|
+
return `
|
|
36428
|
+
<style>
|
|
36429
|
+
.sa-rules-table { width:100%; border-collapse:collapse; margin-top:10px; }
|
|
36430
|
+
.sa-rules-table th { text-align:left; padding:10px 8px; border-bottom:2px solid #e94560; color:#e94560; font-size:13px; }
|
|
36431
|
+
.sa-save-rules-btn {
|
|
36432
|
+
background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%);
|
|
36433
|
+
color: white;
|
|
36434
|
+
border: none;
|
|
36435
|
+
padding: 10px 20px;
|
|
36436
|
+
border-radius: 6px;
|
|
36437
|
+
font-size: 14px;
|
|
36438
|
+
font-weight: 600;
|
|
36439
|
+
cursor: pointer;
|
|
36440
|
+
margin-top: 15px;
|
|
36441
|
+
}
|
|
36442
|
+
.sa-save-rules-btn:hover { opacity: 0.9; }
|
|
36443
|
+
.sa-rules-container { background:#16213e; border-radius:8px; padding:15px; }
|
|
36444
|
+
.sa-rules-desc { color:#888; font-size:13px; margin-bottom:10px; }
|
|
36445
|
+
</style>
|
|
36446
|
+
<div class="sa-rules-container">
|
|
36447
|
+
<p class="sa-rules-desc">Enable or disable alert types. Movement alerts notify you when someone moves between cameras.</p>
|
|
36448
|
+
<table class="sa-rules-table">
|
|
36449
|
+
<thead>
|
|
36450
|
+
<tr>
|
|
36451
|
+
<th style="width:40px;">On</th>
|
|
36452
|
+
<th>Alert Type</th>
|
|
36453
|
+
<th>Event</th>
|
|
36454
|
+
<th>Severity</th>
|
|
36455
|
+
<th>Cooldown</th>
|
|
36456
|
+
</tr>
|
|
36457
|
+
</thead>
|
|
36458
|
+
<tbody>
|
|
36459
|
+
${ruleRows}
|
|
36460
|
+
</tbody>
|
|
36461
|
+
</table>
|
|
36462
|
+
<button class="sa-save-rules-btn" onclick="${saveCode}">Save Alert Rules</button>
|
|
36463
|
+
<script>(function(){${initCode}})();</script>
|
|
36464
|
+
</div>
|
|
36465
|
+
`;
|
|
36466
|
+
}
|
|
36264
36467
|
async putSetting(key, value) {
|
|
36265
|
-
if (key === 'openTopologyEditor') {
|
|
36266
|
-
// The UI will handle opening the editor via HTTP
|
|
36267
|
-
this.console.log('Topology editor requested - access via plugin HTTP endpoint');
|
|
36268
|
-
return;
|
|
36269
|
-
}
|
|
36270
36468
|
await this.storageSettings.putSetting(key, value);
|
|
36271
36469
|
// Handle setting changes that require engine restart
|
|
36272
36470
|
if (key === 'trackedCameras' ||
|
|
@@ -36311,11 +36509,17 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
36311
36509
|
return this.handleJourneyRequest(globalId, response);
|
|
36312
36510
|
}
|
|
36313
36511
|
if (path.endsWith('/api/topology')) {
|
|
36314
|
-
return this.handleTopologyRequest(request, response);
|
|
36512
|
+
return await this.handleTopologyRequest(request, response);
|
|
36315
36513
|
}
|
|
36316
36514
|
if (path.endsWith('/api/alerts')) {
|
|
36317
36515
|
return this.handleAlertsRequest(request, response);
|
|
36318
36516
|
}
|
|
36517
|
+
if (path.endsWith('/api/alert-rules')) {
|
|
36518
|
+
return this.handleAlertRulesRequest(request, response);
|
|
36519
|
+
}
|
|
36520
|
+
if (path.endsWith('/api/cameras')) {
|
|
36521
|
+
return this.handleCamerasRequest(response);
|
|
36522
|
+
}
|
|
36319
36523
|
if (path.endsWith('/api/floor-plan')) {
|
|
36320
36524
|
return this.handleFloorPlanRequest(request, response);
|
|
36321
36525
|
}
|
|
@@ -36387,7 +36591,7 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
36387
36591
|
});
|
|
36388
36592
|
}
|
|
36389
36593
|
}
|
|
36390
|
-
handleTopologyRequest(request, response) {
|
|
36594
|
+
async handleTopologyRequest(request, response) {
|
|
36391
36595
|
if (request.method === 'GET') {
|
|
36392
36596
|
const topologyJson = this.storage.getItem('topology');
|
|
36393
36597
|
const topology = topologyJson ? JSON.parse(topologyJson) : (0, topology_1.createEmptyTopology)();
|
|
@@ -36399,7 +36603,7 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
36399
36603
|
try {
|
|
36400
36604
|
const topology = JSON.parse(request.body);
|
|
36401
36605
|
this.storage.setItem('topology', JSON.stringify(topology));
|
|
36402
|
-
this.startTrackingEngine(topology);
|
|
36606
|
+
await this.startTrackingEngine(topology);
|
|
36403
36607
|
response.send(JSON.stringify({ success: true }), {
|
|
36404
36608
|
headers: { 'Content-Type': 'application/json' },
|
|
36405
36609
|
});
|
|
@@ -36418,6 +36622,58 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
36418
36622
|
headers: { 'Content-Type': 'application/json' },
|
|
36419
36623
|
});
|
|
36420
36624
|
}
|
|
36625
|
+
handleAlertRulesRequest(request, response) {
|
|
36626
|
+
if (request.method === 'GET') {
|
|
36627
|
+
const rules = this.alertManager.getRules();
|
|
36628
|
+
response.send(JSON.stringify(rules), {
|
|
36629
|
+
headers: { 'Content-Type': 'application/json' },
|
|
36630
|
+
});
|
|
36631
|
+
}
|
|
36632
|
+
else if (request.method === 'PUT' || request.method === 'POST') {
|
|
36633
|
+
try {
|
|
36634
|
+
const rules = JSON.parse(request.body);
|
|
36635
|
+
this.alertManager.setRules(rules);
|
|
36636
|
+
response.send(JSON.stringify({ success: true }), {
|
|
36637
|
+
headers: { 'Content-Type': 'application/json' },
|
|
36638
|
+
});
|
|
36639
|
+
}
|
|
36640
|
+
catch (e) {
|
|
36641
|
+
response.send(JSON.stringify({ error: 'Invalid rules JSON' }), {
|
|
36642
|
+
code: 400,
|
|
36643
|
+
headers: { 'Content-Type': 'application/json' },
|
|
36644
|
+
});
|
|
36645
|
+
}
|
|
36646
|
+
}
|
|
36647
|
+
}
|
|
36648
|
+
handleCamerasRequest(response) {
|
|
36649
|
+
try {
|
|
36650
|
+
// Get all devices with ObjectDetector interface
|
|
36651
|
+
const cameras = [];
|
|
36652
|
+
for (const id of Object.keys(systemManager.getSystemState())) {
|
|
36653
|
+
try {
|
|
36654
|
+
const device = systemManager.getDeviceById(id);
|
|
36655
|
+
if (device && device.interfaces?.includes(sdk_1.ScryptedInterface.ObjectDetector)) {
|
|
36656
|
+
cameras.push({
|
|
36657
|
+
id: id,
|
|
36658
|
+
name: device.name || `Camera ${id}`,
|
|
36659
|
+
});
|
|
36660
|
+
}
|
|
36661
|
+
}
|
|
36662
|
+
catch (e) {
|
|
36663
|
+
// Skip devices that can't be accessed
|
|
36664
|
+
}
|
|
36665
|
+
}
|
|
36666
|
+
response.send(JSON.stringify(cameras), {
|
|
36667
|
+
headers: { 'Content-Type': 'application/json' },
|
|
36668
|
+
});
|
|
36669
|
+
}
|
|
36670
|
+
catch (e) {
|
|
36671
|
+
this.console.error('Error getting cameras:', e);
|
|
36672
|
+
response.send(JSON.stringify([]), {
|
|
36673
|
+
headers: { 'Content-Type': 'application/json' },
|
|
36674
|
+
});
|
|
36675
|
+
}
|
|
36676
|
+
}
|
|
36421
36677
|
handleFloorPlanRequest(request, response) {
|
|
36422
36678
|
if (request.method === 'GET') {
|
|
36423
36679
|
const imageData = this.storage.getItem('floorPlanImage');
|
|
@@ -36449,49 +36705,9 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
36449
36705
|
}
|
|
36450
36706
|
}
|
|
36451
36707
|
serveEditorUI(response) {
|
|
36452
|
-
|
|
36453
|
-
|
|
36454
|
-
|
|
36455
|
-
const html = fs.readFileSync(editorPath, 'utf-8');
|
|
36456
|
-
response.send(html, {
|
|
36457
|
-
headers: { 'Content-Type': 'text/html' },
|
|
36458
|
-
});
|
|
36459
|
-
}
|
|
36460
|
-
catch (e) {
|
|
36461
|
-
this.console.error('Failed to load editor UI:', e);
|
|
36462
|
-
// Fallback to basic UI
|
|
36463
|
-
const html = `
|
|
36464
|
-
<!DOCTYPE html>
|
|
36465
|
-
<html>
|
|
36466
|
-
<head>
|
|
36467
|
-
<title>Spatial Awareness - Topology Editor</title>
|
|
36468
|
-
<meta charset="utf-8">
|
|
36469
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
36470
|
-
<style>
|
|
36471
|
-
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 20px; background: #1a1a1a; color: #fff; }
|
|
36472
|
-
h1 { margin-top: 0; }
|
|
36473
|
-
.container { max-width: 1200px; margin: 0 auto; }
|
|
36474
|
-
.btn { background: #0066cc; color: #fff; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; font-size: 14px; }
|
|
36475
|
-
</style>
|
|
36476
|
-
</head>
|
|
36477
|
-
<body>
|
|
36478
|
-
<div class="container">
|
|
36479
|
-
<h1>Spatial Awareness - Topology Editor</h1>
|
|
36480
|
-
<p>Error loading visual editor. Configure topology via REST API: <code>PUT /api/topology</code></p>
|
|
36481
|
-
<h3>Current Topology</h3>
|
|
36482
|
-
<pre id="topology-json" style="background: #2a2a2a; padding: 15px; border-radius: 4px; overflow: auto;"></pre>
|
|
36483
|
-
</div>
|
|
36484
|
-
<script>
|
|
36485
|
-
fetch('api/topology').then(r => r.json()).then(data => {
|
|
36486
|
-
document.getElementById('topology-json').textContent = JSON.stringify(data, null, 2);
|
|
36487
|
-
});
|
|
36488
|
-
</script>
|
|
36489
|
-
</body>
|
|
36490
|
-
</html>`;
|
|
36491
|
-
response.send(html, {
|
|
36492
|
-
headers: { 'Content-Type': 'text/html' },
|
|
36493
|
-
});
|
|
36494
|
-
}
|
|
36708
|
+
response.send(editor_html_1.EDITOR_HTML, {
|
|
36709
|
+
headers: { 'Content-Type': 'text/html' },
|
|
36710
|
+
});
|
|
36495
36711
|
}
|
|
36496
36712
|
serveStaticFile(path, response) {
|
|
36497
36713
|
// Serve static files for the UI
|
|
@@ -36587,7 +36803,7 @@ function createDefaultRules() {
|
|
|
36587
36803
|
conditions: [],
|
|
36588
36804
|
severity: 'info',
|
|
36589
36805
|
notifiers: [],
|
|
36590
|
-
cooldown:
|
|
36806
|
+
cooldown: 60000, // 1 minute
|
|
36591
36807
|
},
|
|
36592
36808
|
{
|
|
36593
36809
|
id: 'property-exit',
|
|
@@ -36597,7 +36813,17 @@ function createDefaultRules() {
|
|
|
36597
36813
|
conditions: [],
|
|
36598
36814
|
severity: 'info',
|
|
36599
36815
|
notifiers: [],
|
|
36600
|
-
cooldown:
|
|
36816
|
+
cooldown: 60000,
|
|
36817
|
+
},
|
|
36818
|
+
{
|
|
36819
|
+
id: 'movement',
|
|
36820
|
+
name: 'Movement Between Cameras',
|
|
36821
|
+
enabled: true,
|
|
36822
|
+
type: 'movement',
|
|
36823
|
+
conditions: [],
|
|
36824
|
+
severity: 'info',
|
|
36825
|
+
notifiers: [],
|
|
36826
|
+
cooldown: 10000, // 10 seconds - we want frequent movement updates
|
|
36601
36827
|
},
|
|
36602
36828
|
{
|
|
36603
36829
|
id: 'unusual-path',
|
|
@@ -36645,14 +36871,20 @@ function createDefaultRules() {
|
|
|
36645
36871
|
}
|
|
36646
36872
|
/** Generates a human-readable message for an alert */
|
|
36647
36873
|
function generateAlertMessage(type, details) {
|
|
36874
|
+
// Capitalize the object class for display (person -> Person, car -> Car, dog -> Dog)
|
|
36875
|
+
const capitalize = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1) : 'Object';
|
|
36648
36876
|
const objectDesc = details.objectLabel
|
|
36649
|
-
? `${details.objectClass} (${details.objectLabel})`
|
|
36650
|
-
: details.objectClass || '
|
|
36877
|
+
? `${capitalize(details.objectClass || '')} (${details.objectLabel})`
|
|
36878
|
+
: capitalize(details.objectClass || '');
|
|
36651
36879
|
switch (type) {
|
|
36652
36880
|
case 'property_entry':
|
|
36653
36881
|
return `${objectDesc} entered property via ${details.cameraName || 'unknown camera'}`;
|
|
36654
36882
|
case 'property_exit':
|
|
36655
36883
|
return `${objectDesc} exited property via ${details.cameraName || 'unknown camera'}`;
|
|
36884
|
+
case 'movement':
|
|
36885
|
+
const transitSecs = details.transitTime ? Math.round(details.transitTime / 1000) : 0;
|
|
36886
|
+
const transitStr = transitSecs > 0 ? ` (${transitSecs}s transit)` : '';
|
|
36887
|
+
return `${objectDesc} moving from ${details.fromCameraName || 'unknown'} towards ${details.toCameraName || 'unknown'}${transitStr}`;
|
|
36656
36888
|
case 'unusual_path':
|
|
36657
36889
|
return `${objectDesc} took unusual path: ${details.actualPath || 'unknown'}`;
|
|
36658
36890
|
case 'dwell_time':
|
|
@@ -37098,6 +37330,712 @@ class TrackingState {
|
|
|
37098
37330
|
exports.TrackingState = TrackingState;
|
|
37099
37331
|
|
|
37100
37332
|
|
|
37333
|
+
/***/ },
|
|
37334
|
+
|
|
37335
|
+
/***/ "./src/ui/editor-html.ts"
|
|
37336
|
+
/*!*******************************!*\
|
|
37337
|
+
!*** ./src/ui/editor-html.ts ***!
|
|
37338
|
+
\*******************************/
|
|
37339
|
+
(__unused_webpack_module, exports) {
|
|
37340
|
+
|
|
37341
|
+
"use strict";
|
|
37342
|
+
|
|
37343
|
+
/**
|
|
37344
|
+
* Editor HTML embedded as a string for bundling
|
|
37345
|
+
*/
|
|
37346
|
+
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
|
37347
|
+
exports.EDITOR_HTML = void 0;
|
|
37348
|
+
exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
37349
|
+
<html lang="en">
|
|
37350
|
+
<head>
|
|
37351
|
+
<meta charset="UTF-8">
|
|
37352
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
37353
|
+
<title>Spatial Awareness - Topology Editor</title>
|
|
37354
|
+
<style>
|
|
37355
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
37356
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif; background: #1a1a2e; color: #eee; min-height: 100vh; }
|
|
37357
|
+
.container { display: flex; height: 100vh; }
|
|
37358
|
+
.sidebar { width: 300px; background: #16213e; border-right: 1px solid #0f3460; display: flex; flex-direction: column; overflow: hidden; }
|
|
37359
|
+
.sidebar-header { padding: 20px; border-bottom: 1px solid #0f3460; }
|
|
37360
|
+
.sidebar-header h1 { font-size: 18px; font-weight: 600; margin-bottom: 5px; }
|
|
37361
|
+
.sidebar-header p { font-size: 12px; color: #888; }
|
|
37362
|
+
.sidebar-content { flex: 1; overflow-y: auto; padding: 15px; }
|
|
37363
|
+
.section { margin-bottom: 20px; }
|
|
37364
|
+
.section-title { font-size: 12px; font-weight: 600; text-transform: uppercase; color: #888; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center; }
|
|
37365
|
+
.btn { background: #0f3460; color: #fff; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 13px; transition: background 0.2s; }
|
|
37366
|
+
.btn:hover { background: #1a4a7a; }
|
|
37367
|
+
.btn-primary { background: #e94560; }
|
|
37368
|
+
.btn-primary:hover { background: #ff6b6b; }
|
|
37369
|
+
.btn-small { padding: 4px 8px; font-size: 11px; }
|
|
37370
|
+
.camera-item, .connection-item { background: #0f3460; border-radius: 6px; padding: 12px; margin-bottom: 8px; cursor: pointer; transition: background 0.2s; }
|
|
37371
|
+
.camera-item:hover, .connection-item:hover { background: #1a4a7a; }
|
|
37372
|
+
.camera-item.selected, .connection-item.selected { outline: 2px solid #e94560; }
|
|
37373
|
+
.camera-name { font-weight: 500; margin-bottom: 4px; }
|
|
37374
|
+
.camera-info { font-size: 11px; color: #888; }
|
|
37375
|
+
.editor { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
|
37376
|
+
.toolbar { background: #16213e; border-bottom: 1px solid #0f3460; padding: 10px 20px; display: flex; gap: 10px; align-items: center; }
|
|
37377
|
+
.toolbar-group { display: flex; gap: 5px; padding-right: 15px; border-right: 1px solid #0f3460; margin-right: 5px; }
|
|
37378
|
+
.toolbar-group:last-child { border-right: none; }
|
|
37379
|
+
.canvas-container { flex: 1; position: relative; overflow: hidden; background: #0f0f1a; }
|
|
37380
|
+
#floor-plan-canvas { position: absolute; top: 0; left: 0; }
|
|
37381
|
+
.canvas-placeholder { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; color: #666; }
|
|
37382
|
+
.canvas-placeholder h2 { margin-bottom: 15px; }
|
|
37383
|
+
.properties-panel { width: 280px; background: #16213e; border-left: 1px solid #0f3460; overflow-y: auto; padding: 15px; }
|
|
37384
|
+
.properties-panel h3 { font-size: 14px; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #0f3460; }
|
|
37385
|
+
.form-group { margin-bottom: 15px; }
|
|
37386
|
+
.form-group label { display: block; font-size: 12px; color: #888; margin-bottom: 5px; }
|
|
37387
|
+
.form-group input, .form-group select { width: 100%; padding: 8px 10px; background: #0f3460; border: 1px solid #1a4a7a; border-radius: 4px; color: #fff; font-size: 13px; }
|
|
37388
|
+
.form-group input:focus, .form-group select:focus { outline: none; border-color: #e94560; }
|
|
37389
|
+
.checkbox-group { display: flex; align-items: center; gap: 8px; }
|
|
37390
|
+
.checkbox-group input[type="checkbox"] { width: auto; }
|
|
37391
|
+
.transit-time-inputs { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
|
|
37392
|
+
.transit-time-inputs input { text-align: center; }
|
|
37393
|
+
.transit-time-labels { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; font-size: 10px; color: #666; text-align: center; }
|
|
37394
|
+
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.7); z-index: 1000; align-items: center; justify-content: center; }
|
|
37395
|
+
.modal-overlay.active { display: flex; }
|
|
37396
|
+
.modal { background: #16213e; border-radius: 8px; padding: 25px; max-width: 500px; width: 90%; max-height: 80vh; overflow-y: auto; }
|
|
37397
|
+
.modal h2 { margin-bottom: 20px; }
|
|
37398
|
+
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
|
|
37399
|
+
.upload-zone { border: 2px dashed #0f3460; border-radius: 8px; padding: 40px; text-align: center; cursor: pointer; transition: border-color 0.2s, background 0.2s; }
|
|
37400
|
+
.upload-zone:hover { border-color: #e94560; background: rgba(233, 69, 96, 0.1); }
|
|
37401
|
+
.upload-zone input { display: none; }
|
|
37402
|
+
.status-bar { background: #0f3460; padding: 8px 20px; font-size: 12px; color: #888; display: flex; justify-content: space-between; }
|
|
37403
|
+
.status-indicator { display: flex; align-items: center; gap: 6px; }
|
|
37404
|
+
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #4caf50; }
|
|
37405
|
+
.status-dot.warning { background: #ff9800; }
|
|
37406
|
+
.status-dot.error { background: #f44336; }
|
|
37407
|
+
</style>
|
|
37408
|
+
</head>
|
|
37409
|
+
<body>
|
|
37410
|
+
<div class="container">
|
|
37411
|
+
<div class="sidebar">
|
|
37412
|
+
<div class="sidebar-header">
|
|
37413
|
+
<h1>Spatial Awareness</h1>
|
|
37414
|
+
<p>Topology Editor</p>
|
|
37415
|
+
</div>
|
|
37416
|
+
<div class="sidebar-content">
|
|
37417
|
+
<div class="section">
|
|
37418
|
+
<div class="section-title">
|
|
37419
|
+
<span>Cameras</span>
|
|
37420
|
+
<button class="btn btn-small" onclick="openAddCameraModal()">+ Add</button>
|
|
37421
|
+
</div>
|
|
37422
|
+
<div id="camera-list">
|
|
37423
|
+
<div class="camera-item" style="color: #666; text-align: center; cursor: default;">No cameras configured</div>
|
|
37424
|
+
</div>
|
|
37425
|
+
</div>
|
|
37426
|
+
<div class="section">
|
|
37427
|
+
<div class="section-title">
|
|
37428
|
+
<span>Connections</span>
|
|
37429
|
+
<button class="btn btn-small" onclick="openAddConnectionModal()">+ Add</button>
|
|
37430
|
+
</div>
|
|
37431
|
+
<div id="connection-list">
|
|
37432
|
+
<div class="connection-item" style="color: #666; text-align: center; cursor: default;">No connections configured</div>
|
|
37433
|
+
</div>
|
|
37434
|
+
</div>
|
|
37435
|
+
</div>
|
|
37436
|
+
</div>
|
|
37437
|
+
<div class="editor">
|
|
37438
|
+
<div class="toolbar">
|
|
37439
|
+
<div class="toolbar-group">
|
|
37440
|
+
<button class="btn" onclick="uploadFloorPlan()">Upload Image</button>
|
|
37441
|
+
<button class="btn" onclick="useBlankCanvas()">Blank Canvas</button>
|
|
37442
|
+
</div>
|
|
37443
|
+
<div class="toolbar-group">
|
|
37444
|
+
<button class="btn" id="tool-select" onclick="setTool('select')">Select</button>
|
|
37445
|
+
<button class="btn" id="tool-wall" onclick="setTool('wall')">Draw Wall</button>
|
|
37446
|
+
<button class="btn" id="tool-room" onclick="setTool('room')">Draw Room</button>
|
|
37447
|
+
<button class="btn" id="tool-camera" onclick="setTool('camera')">Place Camera</button>
|
|
37448
|
+
<button class="btn" id="tool-connect" onclick="setTool('connect')">Connect</button>
|
|
37449
|
+
</div>
|
|
37450
|
+
<div class="toolbar-group">
|
|
37451
|
+
<button class="btn" onclick="clearDrawings()">Clear Drawings</button>
|
|
37452
|
+
</div>
|
|
37453
|
+
<div class="toolbar-group">
|
|
37454
|
+
<button class="btn btn-primary" onclick="saveTopology()">Save</button>
|
|
37455
|
+
</div>
|
|
37456
|
+
</div>
|
|
37457
|
+
<div class="canvas-container">
|
|
37458
|
+
<canvas id="floor-plan-canvas"></canvas>
|
|
37459
|
+
<div class="canvas-placeholder" id="canvas-placeholder">
|
|
37460
|
+
<h2>Floor Plan Editor</h2>
|
|
37461
|
+
<p>Upload an image or use a blank canvas to draw your floor plan</p>
|
|
37462
|
+
<br>
|
|
37463
|
+
<div style="display: flex; gap: 15px; justify-content: center;">
|
|
37464
|
+
<button class="btn btn-primary" onclick="uploadFloorPlan()">Upload Image</button>
|
|
37465
|
+
<button class="btn" onclick="useBlankCanvas()">Use Blank Canvas</button>
|
|
37466
|
+
</div>
|
|
37467
|
+
</div>
|
|
37468
|
+
</div>
|
|
37469
|
+
<div class="status-bar">
|
|
37470
|
+
<div class="status-indicator">
|
|
37471
|
+
<div class="status-dot" id="status-dot"></div>
|
|
37472
|
+
<span id="status-text">Ready</span>
|
|
37473
|
+
</div>
|
|
37474
|
+
<div>
|
|
37475
|
+
<span id="camera-count">0</span> cameras | <span id="connection-count">0</span> connections
|
|
37476
|
+
</div>
|
|
37477
|
+
</div>
|
|
37478
|
+
</div>
|
|
37479
|
+
<div class="properties-panel" id="properties-panel">
|
|
37480
|
+
<h3>Properties</h3>
|
|
37481
|
+
<p style="color: #666; font-size: 13px;">Select a camera or connection to edit its properties.</p>
|
|
37482
|
+
</div>
|
|
37483
|
+
</div>
|
|
37484
|
+
|
|
37485
|
+
<div class="modal-overlay" id="add-camera-modal">
|
|
37486
|
+
<div class="modal">
|
|
37487
|
+
<h2>Add Camera</h2>
|
|
37488
|
+
<div class="form-group">
|
|
37489
|
+
<label>Camera Device</label>
|
|
37490
|
+
<select id="camera-device-select"><option value="">Loading cameras...</option></select>
|
|
37491
|
+
</div>
|
|
37492
|
+
<div class="form-group">
|
|
37493
|
+
<label>Display Name</label>
|
|
37494
|
+
<input type="text" id="camera-name-input" placeholder="e.g., Front Door Camera">
|
|
37495
|
+
</div>
|
|
37496
|
+
<div class="form-group">
|
|
37497
|
+
<label class="checkbox-group">
|
|
37498
|
+
<input type="checkbox" id="camera-entry-checkbox">
|
|
37499
|
+
Entry Point (objects can enter property here)
|
|
37500
|
+
</label>
|
|
37501
|
+
</div>
|
|
37502
|
+
<div class="form-group">
|
|
37503
|
+
<label class="checkbox-group">
|
|
37504
|
+
<input type="checkbox" id="camera-exit-checkbox">
|
|
37505
|
+
Exit Point (objects can exit property here)
|
|
37506
|
+
</label>
|
|
37507
|
+
</div>
|
|
37508
|
+
<div class="modal-actions">
|
|
37509
|
+
<button class="btn" onclick="closeModal('add-camera-modal')">Cancel</button>
|
|
37510
|
+
<button class="btn btn-primary" onclick="addCamera()">Add Camera</button>
|
|
37511
|
+
</div>
|
|
37512
|
+
</div>
|
|
37513
|
+
</div>
|
|
37514
|
+
|
|
37515
|
+
<div class="modal-overlay" id="add-connection-modal">
|
|
37516
|
+
<div class="modal">
|
|
37517
|
+
<h2>Add Connection</h2>
|
|
37518
|
+
<div class="form-group">
|
|
37519
|
+
<label>Connection Name</label>
|
|
37520
|
+
<input type="text" id="connection-name-input" placeholder="e.g., Driveway to Front Door">
|
|
37521
|
+
</div>
|
|
37522
|
+
<div class="form-group">
|
|
37523
|
+
<label>From Camera</label>
|
|
37524
|
+
<select id="connection-from-select"></select>
|
|
37525
|
+
</div>
|
|
37526
|
+
<div class="form-group">
|
|
37527
|
+
<label>To Camera</label>
|
|
37528
|
+
<select id="connection-to-select"></select>
|
|
37529
|
+
</div>
|
|
37530
|
+
<div class="form-group">
|
|
37531
|
+
<label>Transit Time (seconds)</label>
|
|
37532
|
+
<div class="transit-time-inputs">
|
|
37533
|
+
<input type="number" id="transit-min" placeholder="Min" value="3">
|
|
37534
|
+
<input type="number" id="transit-typical" placeholder="Typical" value="10">
|
|
37535
|
+
<input type="number" id="transit-max" placeholder="Max" value="30">
|
|
37536
|
+
</div>
|
|
37537
|
+
<div class="transit-time-labels">
|
|
37538
|
+
<span>Minimum</span>
|
|
37539
|
+
<span>Typical</span>
|
|
37540
|
+
<span>Maximum</span>
|
|
37541
|
+
</div>
|
|
37542
|
+
</div>
|
|
37543
|
+
<div class="form-group">
|
|
37544
|
+
<label class="checkbox-group">
|
|
37545
|
+
<input type="checkbox" id="connection-bidirectional" checked>
|
|
37546
|
+
Bidirectional (works both ways)
|
|
37547
|
+
</label>
|
|
37548
|
+
</div>
|
|
37549
|
+
<div class="modal-actions">
|
|
37550
|
+
<button class="btn" onclick="closeModal('add-connection-modal')">Cancel</button>
|
|
37551
|
+
<button class="btn btn-primary" onclick="addConnection()">Add Connection</button>
|
|
37552
|
+
</div>
|
|
37553
|
+
</div>
|
|
37554
|
+
</div>
|
|
37555
|
+
|
|
37556
|
+
<div class="modal-overlay" id="upload-modal">
|
|
37557
|
+
<div class="modal">
|
|
37558
|
+
<h2>Upload Floor Plan</h2>
|
|
37559
|
+
<div class="upload-zone" onclick="document.getElementById('floor-plan-input').click()">
|
|
37560
|
+
<p>Click to select an image<br><small>PNG, JPG, or SVG</small></p>
|
|
37561
|
+
<input type="file" id="floor-plan-input" accept="image/*" onchange="handleFloorPlanUpload(event)">
|
|
37562
|
+
</div>
|
|
37563
|
+
<div class="modal-actions">
|
|
37564
|
+
<button class="btn" onclick="closeModal('upload-modal')">Cancel</button>
|
|
37565
|
+
</div>
|
|
37566
|
+
</div>
|
|
37567
|
+
</div>
|
|
37568
|
+
|
|
37569
|
+
<script>
|
|
37570
|
+
let topology = { version: '1.0', cameras: [], connections: [], globalZones: [], floorPlan: null, drawings: [] };
|
|
37571
|
+
let selectedItem = null;
|
|
37572
|
+
let currentTool = 'select';
|
|
37573
|
+
let floorPlanImage = null;
|
|
37574
|
+
let availableCameras = [];
|
|
37575
|
+
let isDrawing = false;
|
|
37576
|
+
let drawStart = null;
|
|
37577
|
+
let currentDrawing = null;
|
|
37578
|
+
let blankCanvasMode = false;
|
|
37579
|
+
const canvas = document.getElementById('floor-plan-canvas');
|
|
37580
|
+
const ctx = canvas.getContext('2d');
|
|
37581
|
+
|
|
37582
|
+
async function init() {
|
|
37583
|
+
await loadTopology();
|
|
37584
|
+
await loadAvailableCameras();
|
|
37585
|
+
resizeCanvas();
|
|
37586
|
+
render();
|
|
37587
|
+
updateUI();
|
|
37588
|
+
}
|
|
37589
|
+
|
|
37590
|
+
async function loadTopology() {
|
|
37591
|
+
try {
|
|
37592
|
+
const response = await fetch('../api/topology');
|
|
37593
|
+
if (response.ok) {
|
|
37594
|
+
topology = await response.json();
|
|
37595
|
+
if (!topology.drawings) topology.drawings = [];
|
|
37596
|
+
if (topology.floorPlan?.imageData) {
|
|
37597
|
+
await loadFloorPlanImage(topology.floorPlan.imageData);
|
|
37598
|
+
} else if (topology.floorPlan?.type === 'blank') {
|
|
37599
|
+
blankCanvasMode = true;
|
|
37600
|
+
}
|
|
37601
|
+
}
|
|
37602
|
+
} catch (e) { console.error('Failed to load topology:', e); }
|
|
37603
|
+
}
|
|
37604
|
+
|
|
37605
|
+
async function loadAvailableCameras() {
|
|
37606
|
+
try {
|
|
37607
|
+
const response = await fetch('../api/cameras');
|
|
37608
|
+
if (response.ok) {
|
|
37609
|
+
availableCameras = await response.json();
|
|
37610
|
+
} else {
|
|
37611
|
+
availableCameras = [];
|
|
37612
|
+
}
|
|
37613
|
+
} catch (e) {
|
|
37614
|
+
console.error('Failed to load cameras:', e);
|
|
37615
|
+
availableCameras = [];
|
|
37616
|
+
}
|
|
37617
|
+
updateCameraSelects();
|
|
37618
|
+
}
|
|
37619
|
+
|
|
37620
|
+
async function saveTopology() {
|
|
37621
|
+
try {
|
|
37622
|
+
setStatus('Saving...', 'warning');
|
|
37623
|
+
const response = await fetch('../api/topology', {
|
|
37624
|
+
method: 'PUT',
|
|
37625
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37626
|
+
body: JSON.stringify(topology)
|
|
37627
|
+
});
|
|
37628
|
+
if (response.ok) { setStatus('Saved successfully', 'success'); }
|
|
37629
|
+
else { setStatus('Failed to save', 'error'); }
|
|
37630
|
+
} catch (e) { console.error('Failed to save topology:', e); setStatus('Failed to save', 'error'); }
|
|
37631
|
+
}
|
|
37632
|
+
|
|
37633
|
+
function resizeCanvas() {
|
|
37634
|
+
const container = canvas.parentElement;
|
|
37635
|
+
canvas.width = container.clientWidth;
|
|
37636
|
+
canvas.height = container.clientHeight;
|
|
37637
|
+
}
|
|
37638
|
+
|
|
37639
|
+
function render() {
|
|
37640
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
37641
|
+
|
|
37642
|
+
// Draw grid for blank canvas
|
|
37643
|
+
if (blankCanvasMode && !floorPlanImage) {
|
|
37644
|
+
document.getElementById('canvas-placeholder').style.display = 'none';
|
|
37645
|
+
ctx.fillStyle = '#1a1a2e';
|
|
37646
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
37647
|
+
ctx.strokeStyle = '#2a2a4e';
|
|
37648
|
+
ctx.lineWidth = 1;
|
|
37649
|
+
const gridSize = 40;
|
|
37650
|
+
for (let x = 0; x < canvas.width; x += gridSize) {
|
|
37651
|
+
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke();
|
|
37652
|
+
}
|
|
37653
|
+
for (let y = 0; y < canvas.height; y += gridSize) {
|
|
37654
|
+
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke();
|
|
37655
|
+
}
|
|
37656
|
+
} else if (floorPlanImage) {
|
|
37657
|
+
document.getElementById('canvas-placeholder').style.display = 'none';
|
|
37658
|
+
const scale = Math.min(canvas.width / floorPlanImage.width, canvas.height / floorPlanImage.height) * 0.9;
|
|
37659
|
+
const x = (canvas.width - floorPlanImage.width * scale) / 2;
|
|
37660
|
+
const y = (canvas.height - floorPlanImage.height * scale) / 2;
|
|
37661
|
+
ctx.drawImage(floorPlanImage, x, y, floorPlanImage.width * scale, floorPlanImage.height * scale);
|
|
37662
|
+
} else {
|
|
37663
|
+
document.getElementById('canvas-placeholder').style.display = 'block';
|
|
37664
|
+
}
|
|
37665
|
+
|
|
37666
|
+
// Draw saved drawings (walls and rooms)
|
|
37667
|
+
if (topology.drawings) {
|
|
37668
|
+
for (const drawing of topology.drawings) {
|
|
37669
|
+
if (drawing.type === 'wall') {
|
|
37670
|
+
ctx.beginPath();
|
|
37671
|
+
ctx.moveTo(drawing.x1, drawing.y1);
|
|
37672
|
+
ctx.lineTo(drawing.x2, drawing.y2);
|
|
37673
|
+
ctx.strokeStyle = '#888';
|
|
37674
|
+
ctx.lineWidth = 4;
|
|
37675
|
+
ctx.stroke();
|
|
37676
|
+
} else if (drawing.type === 'room') {
|
|
37677
|
+
ctx.strokeStyle = '#666';
|
|
37678
|
+
ctx.lineWidth = 2;
|
|
37679
|
+
ctx.strokeRect(drawing.x, drawing.y, drawing.width, drawing.height);
|
|
37680
|
+
ctx.fillStyle = 'rgba(100, 100, 150, 0.1)';
|
|
37681
|
+
ctx.fillRect(drawing.x, drawing.y, drawing.width, drawing.height);
|
|
37682
|
+
if (drawing.label) {
|
|
37683
|
+
ctx.fillStyle = '#888';
|
|
37684
|
+
ctx.font = '12px sans-serif';
|
|
37685
|
+
ctx.textAlign = 'center';
|
|
37686
|
+
ctx.fillText(drawing.label, drawing.x + drawing.width/2, drawing.y + drawing.height/2);
|
|
37687
|
+
}
|
|
37688
|
+
}
|
|
37689
|
+
}
|
|
37690
|
+
}
|
|
37691
|
+
|
|
37692
|
+
// Draw current drawing in progress
|
|
37693
|
+
if (currentDrawing) {
|
|
37694
|
+
if (currentDrawing.type === 'wall') {
|
|
37695
|
+
ctx.beginPath();
|
|
37696
|
+
ctx.moveTo(currentDrawing.x1, currentDrawing.y1);
|
|
37697
|
+
ctx.lineTo(currentDrawing.x2, currentDrawing.y2);
|
|
37698
|
+
ctx.strokeStyle = '#e94560';
|
|
37699
|
+
ctx.lineWidth = 4;
|
|
37700
|
+
ctx.stroke();
|
|
37701
|
+
} else if (currentDrawing.type === 'room') {
|
|
37702
|
+
ctx.strokeStyle = '#e94560';
|
|
37703
|
+
ctx.lineWidth = 2;
|
|
37704
|
+
ctx.strokeRect(currentDrawing.x, currentDrawing.y, currentDrawing.width, currentDrawing.height);
|
|
37705
|
+
}
|
|
37706
|
+
}
|
|
37707
|
+
for (const conn of topology.connections) {
|
|
37708
|
+
const fromCam = topology.cameras.find(c => c.deviceId === conn.fromCameraId);
|
|
37709
|
+
const toCam = topology.cameras.find(c => c.deviceId === conn.toCameraId);
|
|
37710
|
+
if (fromCam?.floorPlanPosition && toCam?.floorPlanPosition) {
|
|
37711
|
+
drawConnection(fromCam.floorPlanPosition, toCam.floorPlanPosition, conn);
|
|
37712
|
+
}
|
|
37713
|
+
}
|
|
37714
|
+
for (const camera of topology.cameras) {
|
|
37715
|
+
if (camera.floorPlanPosition) { drawCamera(camera); }
|
|
37716
|
+
}
|
|
37717
|
+
}
|
|
37718
|
+
|
|
37719
|
+
function drawCamera(camera) {
|
|
37720
|
+
const pos = camera.floorPlanPosition;
|
|
37721
|
+
const isSelected = selectedItem?.type === 'camera' && selectedItem?.id === camera.deviceId;
|
|
37722
|
+
ctx.beginPath();
|
|
37723
|
+
ctx.arc(pos.x, pos.y, 20, 0, Math.PI * 2);
|
|
37724
|
+
ctx.fillStyle = isSelected ? '#e94560' : '#0f3460';
|
|
37725
|
+
ctx.fill();
|
|
37726
|
+
ctx.strokeStyle = '#fff';
|
|
37727
|
+
ctx.lineWidth = 2;
|
|
37728
|
+
ctx.stroke();
|
|
37729
|
+
ctx.fillStyle = '#fff';
|
|
37730
|
+
ctx.font = '12px sans-serif';
|
|
37731
|
+
ctx.textAlign = 'center';
|
|
37732
|
+
ctx.textBaseline = 'middle';
|
|
37733
|
+
ctx.fillText('CAM', pos.x, pos.y);
|
|
37734
|
+
ctx.fillText(camera.name, pos.x, pos.y + 35);
|
|
37735
|
+
}
|
|
37736
|
+
|
|
37737
|
+
function drawConnection(from, to, conn) {
|
|
37738
|
+
const isSelected = selectedItem?.type === 'connection' && selectedItem?.id === conn.id;
|
|
37739
|
+
ctx.beginPath();
|
|
37740
|
+
ctx.moveTo(from.x, from.y);
|
|
37741
|
+
ctx.lineTo(to.x, to.y);
|
|
37742
|
+
ctx.strokeStyle = isSelected ? '#e94560' : '#4caf50';
|
|
37743
|
+
ctx.lineWidth = isSelected ? 4 : 2;
|
|
37744
|
+
ctx.stroke();
|
|
37745
|
+
if (!conn.bidirectional) {
|
|
37746
|
+
const angle = Math.atan2(to.y - from.y, to.x - from.x);
|
|
37747
|
+
const midX = (from.x + to.x) / 2;
|
|
37748
|
+
const midY = (from.y + to.y) / 2;
|
|
37749
|
+
ctx.beginPath();
|
|
37750
|
+
ctx.moveTo(midX, midY);
|
|
37751
|
+
ctx.lineTo(midX - 10 * Math.cos(angle - 0.5), midY - 10 * Math.sin(angle - 0.5));
|
|
37752
|
+
ctx.lineTo(midX - 10 * Math.cos(angle + 0.5), midY - 10 * Math.sin(angle + 0.5));
|
|
37753
|
+
ctx.closePath();
|
|
37754
|
+
ctx.fillStyle = isSelected ? '#e94560' : '#4caf50';
|
|
37755
|
+
ctx.fill();
|
|
37756
|
+
}
|
|
37757
|
+
}
|
|
37758
|
+
|
|
37759
|
+
function uploadFloorPlan() { document.getElementById('upload-modal').classList.add('active'); }
|
|
37760
|
+
|
|
37761
|
+
async function handleFloorPlanUpload(event) {
|
|
37762
|
+
const file = event.target.files[0];
|
|
37763
|
+
if (!file) return;
|
|
37764
|
+
const reader = new FileReader();
|
|
37765
|
+
reader.onload = async (e) => {
|
|
37766
|
+
const imageData = e.target.result;
|
|
37767
|
+
await loadFloorPlanImage(imageData);
|
|
37768
|
+
topology.floorPlan = { imageData, width: floorPlanImage.width, height: floorPlanImage.height };
|
|
37769
|
+
closeModal('upload-modal');
|
|
37770
|
+
render();
|
|
37771
|
+
};
|
|
37772
|
+
reader.readAsDataURL(file);
|
|
37773
|
+
}
|
|
37774
|
+
|
|
37775
|
+
function loadFloorPlanImage(imageData) {
|
|
37776
|
+
return new Promise((resolve) => {
|
|
37777
|
+
floorPlanImage = new Image();
|
|
37778
|
+
floorPlanImage.onload = resolve;
|
|
37779
|
+
floorPlanImage.src = imageData;
|
|
37780
|
+
});
|
|
37781
|
+
}
|
|
37782
|
+
|
|
37783
|
+
function openAddCameraModal() { document.getElementById('add-camera-modal').classList.add('active'); }
|
|
37784
|
+
|
|
37785
|
+
function addCamera() {
|
|
37786
|
+
const deviceId = document.getElementById('camera-device-select').value;
|
|
37787
|
+
if (!deviceId) {
|
|
37788
|
+
alert('Please select a camera');
|
|
37789
|
+
return;
|
|
37790
|
+
}
|
|
37791
|
+
const selectedCam = availableCameras.find(c => c.id === deviceId);
|
|
37792
|
+
const customName = document.getElementById('camera-name-input').value;
|
|
37793
|
+
const name = customName || (selectedCam ? selectedCam.name : 'New Camera');
|
|
37794
|
+
const isEntry = document.getElementById('camera-entry-checkbox').checked;
|
|
37795
|
+
const isExit = document.getElementById('camera-exit-checkbox').checked;
|
|
37796
|
+
// Use pending position from click, or default to center
|
|
37797
|
+
const pos = topology._pendingCameraPos || { x: canvas.width / 2 + Math.random() * 100 - 50, y: canvas.height / 2 + Math.random() * 100 - 50 };
|
|
37798
|
+
delete topology._pendingCameraPos;
|
|
37799
|
+
const camera = {
|
|
37800
|
+
deviceId: deviceId,
|
|
37801
|
+
nativeId: 'cam-' + Date.now(),
|
|
37802
|
+
name,
|
|
37803
|
+
isEntryPoint: isEntry,
|
|
37804
|
+
isExitPoint: isExit,
|
|
37805
|
+
trackClasses: ['person', 'car', 'animal'],
|
|
37806
|
+
floorPlanPosition: pos
|
|
37807
|
+
};
|
|
37808
|
+
topology.cameras.push(camera);
|
|
37809
|
+
closeModal('add-camera-modal');
|
|
37810
|
+
// Clear form
|
|
37811
|
+
document.getElementById('camera-name-input').value = '';
|
|
37812
|
+
document.getElementById('camera-entry-checkbox').checked = false;
|
|
37813
|
+
document.getElementById('camera-exit-checkbox').checked = false;
|
|
37814
|
+
updateCameraSelects();
|
|
37815
|
+
updateUI();
|
|
37816
|
+
render();
|
|
37817
|
+
}
|
|
37818
|
+
|
|
37819
|
+
function openAddConnectionModal() {
|
|
37820
|
+
if (topology.cameras.length < 2) { alert('Add at least 2 cameras before creating connections'); return; }
|
|
37821
|
+
updateCameraSelects();
|
|
37822
|
+
document.getElementById('add-connection-modal').classList.add('active');
|
|
37823
|
+
}
|
|
37824
|
+
|
|
37825
|
+
function updateCameraSelects() {
|
|
37826
|
+
// Update camera device select (for adding new cameras)
|
|
37827
|
+
const cameraDeviceSelect = document.getElementById('camera-device-select');
|
|
37828
|
+
if (availableCameras.length > 0) {
|
|
37829
|
+
const existingIds = topology.cameras.map(c => c.deviceId);
|
|
37830
|
+
const available = availableCameras.filter(c => !existingIds.includes(c.id));
|
|
37831
|
+
if (available.length > 0) {
|
|
37832
|
+
cameraDeviceSelect.innerHTML = '<option value="">Select a camera...</option>' +
|
|
37833
|
+
available.map(c => '<option value="' + c.id + '">' + c.name + '</option>').join('');
|
|
37834
|
+
} else {
|
|
37835
|
+
cameraDeviceSelect.innerHTML = '<option value="">All cameras already added</option>';
|
|
37836
|
+
}
|
|
37837
|
+
} else {
|
|
37838
|
+
cameraDeviceSelect.innerHTML = '<option value="">No cameras with object detection found</option>';
|
|
37839
|
+
}
|
|
37840
|
+
|
|
37841
|
+
// Update connection selects (for existing topology cameras)
|
|
37842
|
+
const options = topology.cameras.map(c => '<option value="' + c.deviceId + '">' + c.name + '</option>').join('');
|
|
37843
|
+
document.getElementById('connection-from-select').innerHTML = options;
|
|
37844
|
+
document.getElementById('connection-to-select').innerHTML = options;
|
|
37845
|
+
}
|
|
37846
|
+
|
|
37847
|
+
function addConnection() {
|
|
37848
|
+
const name = document.getElementById('connection-name-input').value;
|
|
37849
|
+
const fromId = document.getElementById('connection-from-select').value;
|
|
37850
|
+
const toId = document.getElementById('connection-to-select').value;
|
|
37851
|
+
const minTransit = parseInt(document.getElementById('transit-min').value) * 1000;
|
|
37852
|
+
const typicalTransit = parseInt(document.getElementById('transit-typical').value) * 1000;
|
|
37853
|
+
const maxTransit = parseInt(document.getElementById('transit-max').value) * 1000;
|
|
37854
|
+
const bidirectional = document.getElementById('connection-bidirectional').checked;
|
|
37855
|
+
if (fromId === toId) { alert('Please select different cameras'); return; }
|
|
37856
|
+
const connection = {
|
|
37857
|
+
id: 'conn-' + Date.now(),
|
|
37858
|
+
fromCameraId: fromId,
|
|
37859
|
+
toCameraId: toId,
|
|
37860
|
+
name: name || fromId + ' to ' + toId,
|
|
37861
|
+
exitZone: [],
|
|
37862
|
+
entryZone: [],
|
|
37863
|
+
transitTime: { min: minTransit, typical: typicalTransit, max: maxTransit },
|
|
37864
|
+
bidirectional
|
|
37865
|
+
};
|
|
37866
|
+
topology.connections.push(connection);
|
|
37867
|
+
closeModal('add-connection-modal');
|
|
37868
|
+
updateUI();
|
|
37869
|
+
render();
|
|
37870
|
+
}
|
|
37871
|
+
|
|
37872
|
+
function updateUI() {
|
|
37873
|
+
const cameraList = document.getElementById('camera-list');
|
|
37874
|
+
if (topology.cameras.length === 0) {
|
|
37875
|
+
cameraList.innerHTML = '<div class="camera-item" style="color: #666; text-align: center; cursor: default;">No cameras configured</div>';
|
|
37876
|
+
} else {
|
|
37877
|
+
cameraList.innerHTML = topology.cameras.map(c => '<div class="camera-item ' + (selectedItem?.type === 'camera' && selectedItem?.id === c.deviceId ? 'selected' : '') + '" onclick="selectCamera(\\'' + c.deviceId + '\\')"><div class="camera-name">CAM ' + c.name + '</div><div class="camera-info">' + (c.isEntryPoint ? 'Entry ' : '') + (c.isExitPoint ? 'Exit' : '') + '</div></div>').join('');
|
|
37878
|
+
}
|
|
37879
|
+
const connectionList = document.getElementById('connection-list');
|
|
37880
|
+
if (topology.connections.length === 0) {
|
|
37881
|
+
connectionList.innerHTML = '<div class="connection-item" style="color: #666; text-align: center; cursor: default;">No connections configured</div>';
|
|
37882
|
+
} else {
|
|
37883
|
+
connectionList.innerHTML = topology.connections.map(c => '<div class="connection-item ' + (selectedItem?.type === 'connection' && selectedItem?.id === c.id ? 'selected' : '') + '" onclick="selectConnection(\\'' + c.id + '\\')"><div class="camera-name">' + c.name + '</div><div class="camera-info">' + (c.transitTime.typical / 1000) + 's typical ' + (c.bidirectional ? '<->' : '->') + '</div></div>').join('');
|
|
37884
|
+
}
|
|
37885
|
+
document.getElementById('camera-count').textContent = topology.cameras.length;
|
|
37886
|
+
document.getElementById('connection-count').textContent = topology.connections.length;
|
|
37887
|
+
}
|
|
37888
|
+
|
|
37889
|
+
function selectCamera(deviceId) {
|
|
37890
|
+
selectedItem = { type: 'camera', id: deviceId };
|
|
37891
|
+
const camera = topology.cameras.find(c => c.deviceId === deviceId);
|
|
37892
|
+
showCameraProperties(camera);
|
|
37893
|
+
updateUI();
|
|
37894
|
+
render();
|
|
37895
|
+
}
|
|
37896
|
+
|
|
37897
|
+
function selectConnection(connId) {
|
|
37898
|
+
selectedItem = { type: 'connection', id: connId };
|
|
37899
|
+
const connection = topology.connections.find(c => c.id === connId);
|
|
37900
|
+
showConnectionProperties(connection);
|
|
37901
|
+
updateUI();
|
|
37902
|
+
render();
|
|
37903
|
+
}
|
|
37904
|
+
|
|
37905
|
+
function showCameraProperties(camera) {
|
|
37906
|
+
const panel = document.getElementById('properties-panel');
|
|
37907
|
+
panel.innerHTML = '<h3>Camera Properties</h3><div class="form-group"><label>Name</label><input type="text" value="' + camera.name + '" onchange="updateCameraName(\\'' + camera.deviceId + '\\', this.value)"></div><div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (camera.isEntryPoint ? 'checked' : '') + ' onchange="updateCameraEntry(\\'' + camera.deviceId + '\\', this.checked)">Entry Point</label></div><div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (camera.isExitPoint ? 'checked' : '') + ' onchange="updateCameraExit(\\'' + camera.deviceId + '\\', this.checked)">Exit Point</label></div><div class="form-group"><button class="btn" style="width: 100%; background: #f44336;" onclick="deleteCamera(\\'' + camera.deviceId + '\\')">Delete Camera</button></div>';
|
|
37908
|
+
}
|
|
37909
|
+
|
|
37910
|
+
function showConnectionProperties(connection) {
|
|
37911
|
+
const panel = document.getElementById('properties-panel');
|
|
37912
|
+
panel.innerHTML = '<h3>Connection Properties</h3><div class="form-group"><label>Name</label><input type="text" value="' + connection.name + '" onchange="updateConnectionName(\\'' + connection.id + '\\', this.value)"></div><div class="form-group"><label>Transit Time (seconds)</label><div class="transit-time-inputs"><input type="number" value="' + (connection.transitTime.min / 1000) + '" onchange="updateTransitTime(\\'' + connection.id + '\\', \\'min\\', this.value)"><input type="number" value="' + (connection.transitTime.typical / 1000) + '" onchange="updateTransitTime(\\'' + connection.id + '\\', \\'typical\\', this.value)"><input type="number" value="' + (connection.transitTime.max / 1000) + '" onchange="updateTransitTime(\\'' + connection.id + '\\', \\'max\\', this.value)"></div><div class="transit-time-labels"><span>Min</span><span>Typical</span><span>Max</span></div></div><div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (connection.bidirectional ? 'checked' : '') + ' onchange="updateConnectionBidi(\\'' + connection.id + '\\', this.checked)">Bidirectional</label></div><div class="form-group"><button class="btn" style="width: 100%; background: #f44336;" onclick="deleteConnection(\\'' + connection.id + '\\')">Delete Connection</button></div>';
|
|
37913
|
+
}
|
|
37914
|
+
|
|
37915
|
+
function updateCameraName(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.name = value; updateUI(); }
|
|
37916
|
+
function updateCameraEntry(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.isEntryPoint = value; }
|
|
37917
|
+
function updateCameraExit(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.isExitPoint = value; }
|
|
37918
|
+
function updateConnectionName(id, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.name = value; updateUI(); }
|
|
37919
|
+
function updateTransitTime(id, field, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.transitTime[field] = parseInt(value) * 1000; }
|
|
37920
|
+
function updateConnectionBidi(id, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.bidirectional = value; render(); }
|
|
37921
|
+
function deleteCamera(id) { if (!confirm('Delete this camera?')) return; topology.cameras = topology.cameras.filter(c => c.deviceId !== id); topology.connections = topology.connections.filter(c => c.fromCameraId !== id && c.toCameraId !== id); selectedItem = null; document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select a camera or connection.</p>'; updateUI(); render(); }
|
|
37922
|
+
function deleteConnection(id) { if (!confirm('Delete this connection?')) return; topology.connections = topology.connections.filter(c => c.id !== id); selectedItem = null; document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select a camera or connection.</p>'; updateUI(); render(); }
|
|
37923
|
+
function setTool(tool) {
|
|
37924
|
+
currentTool = tool;
|
|
37925
|
+
setStatus('Tool: ' + tool, 'success');
|
|
37926
|
+
document.querySelectorAll('.toolbar .btn').forEach(b => b.style.background = '');
|
|
37927
|
+
const btn = document.getElementById('tool-' + tool);
|
|
37928
|
+
if (btn) btn.style.background = '#e94560';
|
|
37929
|
+
}
|
|
37930
|
+
|
|
37931
|
+
function useBlankCanvas() {
|
|
37932
|
+
blankCanvasMode = true;
|
|
37933
|
+
floorPlanImage = null;
|
|
37934
|
+
topology.floorPlan = { type: 'blank', width: canvas.width, height: canvas.height };
|
|
37935
|
+
render();
|
|
37936
|
+
setStatus('Blank canvas ready - use Draw Wall or Draw Room tools', 'success');
|
|
37937
|
+
}
|
|
37938
|
+
|
|
37939
|
+
function clearDrawings() {
|
|
37940
|
+
if (!confirm('Clear all drawings (walls and rooms)?')) return;
|
|
37941
|
+
topology.drawings = [];
|
|
37942
|
+
render();
|
|
37943
|
+
setStatus('Drawings cleared', 'success');
|
|
37944
|
+
}
|
|
37945
|
+
|
|
37946
|
+
function closeModal(id) { document.getElementById(id).classList.remove('active'); }
|
|
37947
|
+
function setStatus(text, type) { document.getElementById('status-text').textContent = text; const dot = document.getElementById('status-dot'); dot.className = 'status-dot'; if (type === 'warning') dot.classList.add('warning'); if (type === 'error') dot.classList.add('error'); }
|
|
37948
|
+
|
|
37949
|
+
let dragging = null;
|
|
37950
|
+
|
|
37951
|
+
canvas.addEventListener('mousedown', (e) => {
|
|
37952
|
+
const rect = canvas.getBoundingClientRect();
|
|
37953
|
+
const x = e.clientX - rect.left;
|
|
37954
|
+
const y = e.clientY - rect.top;
|
|
37955
|
+
|
|
37956
|
+
if (currentTool === 'select') {
|
|
37957
|
+
for (const camera of topology.cameras) {
|
|
37958
|
+
if (camera.floorPlanPosition) {
|
|
37959
|
+
const dist = Math.hypot(x - camera.floorPlanPosition.x, y - camera.floorPlanPosition.y);
|
|
37960
|
+
if (dist < 25) { selectCamera(camera.deviceId); dragging = camera; return; }
|
|
37961
|
+
}
|
|
37962
|
+
}
|
|
37963
|
+
} else if (currentTool === 'wall') {
|
|
37964
|
+
isDrawing = true;
|
|
37965
|
+
drawStart = { x, y };
|
|
37966
|
+
currentDrawing = { type: 'wall', x1: x, y1: y, x2: x, y2: y };
|
|
37967
|
+
} else if (currentTool === 'room') {
|
|
37968
|
+
isDrawing = true;
|
|
37969
|
+
drawStart = { x, y };
|
|
37970
|
+
currentDrawing = { type: 'room', x: x, y: y, width: 0, height: 0 };
|
|
37971
|
+
} else if (currentTool === 'camera') {
|
|
37972
|
+
openAddCameraModal();
|
|
37973
|
+
// Will position camera at click location after adding
|
|
37974
|
+
topology._pendingCameraPos = { x, y };
|
|
37975
|
+
}
|
|
37976
|
+
});
|
|
37977
|
+
|
|
37978
|
+
canvas.addEventListener('mousemove', (e) => {
|
|
37979
|
+
const rect = canvas.getBoundingClientRect();
|
|
37980
|
+
const x = e.clientX - rect.left;
|
|
37981
|
+
const y = e.clientY - rect.top;
|
|
37982
|
+
|
|
37983
|
+
if (dragging) {
|
|
37984
|
+
dragging.floorPlanPosition.x = x;
|
|
37985
|
+
dragging.floorPlanPosition.y = y;
|
|
37986
|
+
render();
|
|
37987
|
+
} else if (isDrawing && currentDrawing) {
|
|
37988
|
+
if (currentDrawing.type === 'wall') {
|
|
37989
|
+
currentDrawing.x2 = x;
|
|
37990
|
+
currentDrawing.y2 = y;
|
|
37991
|
+
} else if (currentDrawing.type === 'room') {
|
|
37992
|
+
currentDrawing.width = x - drawStart.x;
|
|
37993
|
+
currentDrawing.height = y - drawStart.y;
|
|
37994
|
+
}
|
|
37995
|
+
render();
|
|
37996
|
+
}
|
|
37997
|
+
});
|
|
37998
|
+
|
|
37999
|
+
canvas.addEventListener('mouseup', (e) => {
|
|
38000
|
+
if (isDrawing && currentDrawing) {
|
|
38001
|
+
if (!topology.drawings) topology.drawings = [];
|
|
38002
|
+
// Normalize room coordinates if drawn backwards
|
|
38003
|
+
if (currentDrawing.type === 'room') {
|
|
38004
|
+
if (currentDrawing.width < 0) {
|
|
38005
|
+
currentDrawing.x += currentDrawing.width;
|
|
38006
|
+
currentDrawing.width = Math.abs(currentDrawing.width);
|
|
38007
|
+
}
|
|
38008
|
+
if (currentDrawing.height < 0) {
|
|
38009
|
+
currentDrawing.y += currentDrawing.height;
|
|
38010
|
+
currentDrawing.height = Math.abs(currentDrawing.height);
|
|
38011
|
+
}
|
|
38012
|
+
// Only add if room is big enough
|
|
38013
|
+
if (currentDrawing.width > 20 && currentDrawing.height > 20) {
|
|
38014
|
+
const label = prompt('Room name (optional):');
|
|
38015
|
+
if (label) currentDrawing.label = label;
|
|
38016
|
+
topology.drawings.push(currentDrawing);
|
|
38017
|
+
}
|
|
38018
|
+
} else if (currentDrawing.type === 'wall') {
|
|
38019
|
+
// Only add if wall is long enough
|
|
38020
|
+
const len = Math.hypot(currentDrawing.x2 - currentDrawing.x1, currentDrawing.y2 - currentDrawing.y1);
|
|
38021
|
+
if (len > 20) {
|
|
38022
|
+
topology.drawings.push(currentDrawing);
|
|
38023
|
+
}
|
|
38024
|
+
}
|
|
38025
|
+
isDrawing = false;
|
|
38026
|
+
currentDrawing = null;
|
|
38027
|
+
render();
|
|
38028
|
+
}
|
|
38029
|
+
dragging = null;
|
|
38030
|
+
});
|
|
38031
|
+
|
|
38032
|
+
window.addEventListener('resize', () => { resizeCanvas(); render(); });
|
|
38033
|
+
init();
|
|
38034
|
+
</script>
|
|
38035
|
+
</body>
|
|
38036
|
+
</html>`;
|
|
38037
|
+
|
|
38038
|
+
|
|
37101
38039
|
/***/ },
|
|
37102
38040
|
|
|
37103
38041
|
/***/ "assert"
|
|
@@ -37221,17 +38159,6 @@ module.exports = require("os");
|
|
|
37221
38159
|
|
|
37222
38160
|
/***/ },
|
|
37223
38161
|
|
|
37224
|
-
/***/ "path"
|
|
37225
|
-
/*!***********************!*\
|
|
37226
|
-
!*** external "path" ***!
|
|
37227
|
-
\***********************/
|
|
37228
|
-
(module) {
|
|
37229
|
-
|
|
37230
|
-
"use strict";
|
|
37231
|
-
module.exports = require("path");
|
|
37232
|
-
|
|
37233
|
-
/***/ },
|
|
37234
|
-
|
|
37235
38162
|
/***/ "stream"
|
|
37236
38163
|
/*!*************************!*\
|
|
37237
38164
|
!*** external "stream" ***!
|