@blueharford/scrypted-spatial-awareness 0.1.10 â 0.1.14
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 +996 -90
- 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 +25 -8
- package/src/core/tracking-engine.ts +15 -1
- package/src/main.ts +128 -30
- package/src/models/alert.ts +31 -4
package/out/plugin.zip
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Generates and dispatches alerts based on tracking events
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import sdk, { Notifier } from '@scrypted/sdk';
|
|
6
|
+
import sdk, { Notifier, Camera, ScryptedInterface, MediaObject } from '@scrypted/sdk';
|
|
7
7
|
import {
|
|
8
8
|
Alert,
|
|
9
9
|
AlertRule,
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
} from '../models/alert';
|
|
16
16
|
import { TrackedObject, GlobalTrackingId } from '../models/tracked-object';
|
|
17
17
|
|
|
18
|
-
const { systemManager } = sdk;
|
|
18
|
+
const { systemManager, mediaManager } = sdk;
|
|
19
19
|
|
|
20
20
|
export class AlertManager {
|
|
21
21
|
private rules: AlertRule[] = [];
|
|
@@ -109,6 +109,20 @@ export class AlertManager {
|
|
|
109
109
|
? rule.notifiers
|
|
110
110
|
: this.getDefaultNotifiers();
|
|
111
111
|
|
|
112
|
+
// Try to get a thumbnail from the camera
|
|
113
|
+
let mediaObject: MediaObject | undefined;
|
|
114
|
+
const cameraId = alert.details.toCameraId || alert.details.cameraId;
|
|
115
|
+
if (cameraId) {
|
|
116
|
+
try {
|
|
117
|
+
const camera = systemManager.getDeviceById<Camera>(cameraId);
|
|
118
|
+
if (camera && camera.interfaces?.includes(ScryptedInterface.Camera)) {
|
|
119
|
+
mediaObject = await camera.takePicture();
|
|
120
|
+
}
|
|
121
|
+
} catch (e) {
|
|
122
|
+
this.console.warn(`Failed to get thumbnail from camera ${cameraId}:`, e);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
112
126
|
for (const notifierId of notifierIds) {
|
|
113
127
|
try {
|
|
114
128
|
const notifier = systemManager.getDeviceById<Notifier>(notifierId);
|
|
@@ -127,10 +141,11 @@ export class AlertManager {
|
|
|
127
141
|
trackedObjectId: alert.trackedObjectId,
|
|
128
142
|
timestamp: alert.timestamp,
|
|
129
143
|
},
|
|
130
|
-
}
|
|
144
|
+
},
|
|
145
|
+
mediaObject
|
|
131
146
|
);
|
|
132
147
|
|
|
133
|
-
this.console.log(`Notification sent to ${notifierId}`);
|
|
148
|
+
this.console.log(`Notification sent to ${notifierId}${mediaObject ? ' with thumbnail' : ''}`);
|
|
134
149
|
} catch (e) {
|
|
135
150
|
this.console.error(`Failed to send notification to ${notifierId}:`, e);
|
|
136
151
|
}
|
|
@@ -142,17 +157,19 @@ export class AlertManager {
|
|
|
142
157
|
*/
|
|
143
158
|
private getNotificationTitle(alert: Alert): string {
|
|
144
159
|
const prefix = alert.severity === 'critical' ? 'đ¨ ' :
|
|
145
|
-
alert.severity === 'warning' ? 'â ī¸ ' : '
|
|
160
|
+
alert.severity === 'warning' ? 'â ī¸ ' : '';
|
|
146
161
|
|
|
147
162
|
switch (alert.type) {
|
|
148
163
|
case 'property_entry':
|
|
149
|
-
return `${prefix}Entry Detected`;
|
|
164
|
+
return `${prefix}đļ Entry Detected`;
|
|
150
165
|
case 'property_exit':
|
|
151
|
-
return `${prefix}Exit Detected`;
|
|
166
|
+
return `${prefix}đļ Exit Detected`;
|
|
167
|
+
case 'movement':
|
|
168
|
+
return `${prefix}đļ Movement Detected`;
|
|
152
169
|
case 'unusual_path':
|
|
153
170
|
return `${prefix}Unusual Path`;
|
|
154
171
|
case 'dwell_time':
|
|
155
|
-
return `${prefix}Extended Presence`;
|
|
172
|
+
return `${prefix}âąī¸ Extended Presence`;
|
|
156
173
|
case 'restricted_zone':
|
|
157
174
|
return `${prefix}Restricted Zone Alert`;
|
|
158
175
|
case 'lost_tracking':
|
|
@@ -192,6 +192,8 @@ export class TrackingEngine {
|
|
|
192
192
|
// Check if this is a cross-camera transition
|
|
193
193
|
const lastSighting = getLastSighting(tracked);
|
|
194
194
|
if (lastSighting && lastSighting.cameraId !== sighting.cameraId) {
|
|
195
|
+
const transitDuration = sighting.timestamp - lastSighting.timestamp;
|
|
196
|
+
|
|
195
197
|
// Add journey segment
|
|
196
198
|
this.state.addJourney(tracked.globalId, {
|
|
197
199
|
fromCameraId: lastSighting.cameraId,
|
|
@@ -200,7 +202,7 @@ export class TrackingEngine {
|
|
|
200
202
|
toCameraName: sighting.cameraName,
|
|
201
203
|
exitTime: lastSighting.timestamp,
|
|
202
204
|
entryTime: sighting.timestamp,
|
|
203
|
-
transitDuration
|
|
205
|
+
transitDuration,
|
|
204
206
|
correlationConfidence: correlation.confidence,
|
|
205
207
|
});
|
|
206
208
|
|
|
@@ -209,6 +211,18 @@ export class TrackingEngine {
|
|
|
209
211
|
`${lastSighting.cameraName} â ${sighting.cameraName} ` +
|
|
210
212
|
`(confidence: ${(correlation.confidence * 100).toFixed(0)}%)`
|
|
211
213
|
);
|
|
214
|
+
|
|
215
|
+
// Generate movement alert for cross-camera transition
|
|
216
|
+
await this.alertManager.checkAndAlert('movement', tracked, {
|
|
217
|
+
fromCameraId: lastSighting.cameraId,
|
|
218
|
+
fromCameraName: lastSighting.cameraName,
|
|
219
|
+
toCameraId: sighting.cameraId,
|
|
220
|
+
toCameraName: sighting.cameraName,
|
|
221
|
+
transitTime: transitDuration,
|
|
222
|
+
objectClass: sighting.detection.className,
|
|
223
|
+
objectLabel: sighting.detection.label,
|
|
224
|
+
detectionId: sighting.detectionId,
|
|
225
|
+
});
|
|
212
226
|
}
|
|
213
227
|
|
|
214
228
|
// Add sighting to tracked object
|
package/src/main.ts
CHANGED
|
@@ -336,6 +336,8 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
336
336
|
const settings = await this.storageSettings.getSettings();
|
|
337
337
|
|
|
338
338
|
// Topology editor button that opens modal overlay (appended to body for proper z-index)
|
|
339
|
+
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);})()`;
|
|
340
|
+
|
|
339
341
|
settings.push({
|
|
340
342
|
key: 'topologyEditor',
|
|
341
343
|
title: 'Topology Editor',
|
|
@@ -374,47 +376,43 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
374
376
|
</style>
|
|
375
377
|
<div class="sa-btn-container">
|
|
376
378
|
<p class="sa-btn-desc">Configure camera positions, connections, and transit times</p>
|
|
377
|
-
<button class="sa-open-btn" onclick="
|
|
379
|
+
<button class="sa-open-btn" onclick="${onclickCode}">
|
|
378
380
|
<span>⚙</span> Open Topology Editor
|
|
379
381
|
</button>
|
|
380
382
|
</div>
|
|
381
|
-
<script>
|
|
382
|
-
window.openSATopologyEditor = function() {
|
|
383
|
-
var existing = document.getElementById('sa-topology-modal');
|
|
384
|
-
if (existing) existing.remove();
|
|
385
|
-
var modal = document.createElement('div');
|
|
386
|
-
modal.id = 'sa-topology-modal';
|
|
387
|
-
modal.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;';
|
|
388
|
-
var container = document.createElement('div');
|
|
389
|
-
container.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);';
|
|
390
|
-
var closeBtn = document.createElement('button');
|
|
391
|
-
closeBtn.innerHTML = '×';
|
|
392
|
-
closeBtn.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;';
|
|
393
|
-
closeBtn.onclick = function() { modal.remove(); };
|
|
394
|
-
var iframe = document.createElement('iframe');
|
|
395
|
-
iframe.src = '/endpoint/@blueharford/scrypted-spatial-awareness/ui/editor';
|
|
396
|
-
iframe.style.cssText = 'width:100%;height:100%;border:none;';
|
|
397
|
-
container.appendChild(closeBtn);
|
|
398
|
-
container.appendChild(iframe);
|
|
399
|
-
modal.appendChild(container);
|
|
400
|
-
modal.onclick = function(e) { if (e.target === modal) modal.remove(); };
|
|
401
|
-
document.body.appendChild(modal);
|
|
402
|
-
};
|
|
403
|
-
</script>
|
|
404
383
|
`,
|
|
405
384
|
group: 'Topology',
|
|
406
385
|
});
|
|
407
386
|
|
|
408
387
|
// Add status display
|
|
409
388
|
const activeCount = this.trackingState.getActiveCount();
|
|
389
|
+
const topologyJson = this.storage.getItem('topology');
|
|
390
|
+
let statusText = 'Not configured - add cameras and configure topology';
|
|
391
|
+
|
|
392
|
+
if (this.trackingEngine) {
|
|
393
|
+
statusText = `Active: Tracking ${activeCount} object${activeCount !== 1 ? 's' : ''}`;
|
|
394
|
+
} else if (topologyJson) {
|
|
395
|
+
try {
|
|
396
|
+
const topology = JSON.parse(topologyJson) as CameraTopology;
|
|
397
|
+
if (topology.cameras && topology.cameras.length > 0) {
|
|
398
|
+
// Topology exists but engine not running - try to start it
|
|
399
|
+
statusText = `Configured (${topology.cameras.length} cameras) - Starting...`;
|
|
400
|
+
// Restart the tracking engine asynchronously
|
|
401
|
+
this.startTrackingEngine(topology).catch(e => {
|
|
402
|
+
this.console.error('Failed to restart tracking engine:', e);
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
} catch (e) {
|
|
406
|
+
statusText = 'Error loading topology';
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
410
|
settings.push({
|
|
411
411
|
key: 'status',
|
|
412
412
|
title: 'Tracking Status',
|
|
413
413
|
type: 'string',
|
|
414
414
|
readonly: true,
|
|
415
|
-
value:
|
|
416
|
-
? `Active: Tracking ${activeCount} object${activeCount !== 1 ? 's' : ''}`
|
|
417
|
-
: 'Not configured - add cameras and configure topology',
|
|
415
|
+
value: statusText,
|
|
418
416
|
group: 'Status',
|
|
419
417
|
});
|
|
420
418
|
|
|
@@ -431,9 +429,83 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
431
429
|
});
|
|
432
430
|
}
|
|
433
431
|
|
|
432
|
+
// Add alert rules configuration UI
|
|
433
|
+
const alertRules = this.alertManager.getRules();
|
|
434
|
+
const rulesHtml = this.generateAlertRulesHtml(alertRules);
|
|
435
|
+
settings.push({
|
|
436
|
+
key: 'alertRulesEditor',
|
|
437
|
+
title: 'Alert Rules',
|
|
438
|
+
type: 'html' as any,
|
|
439
|
+
value: rulesHtml,
|
|
440
|
+
group: 'Alerts',
|
|
441
|
+
});
|
|
442
|
+
|
|
434
443
|
return settings;
|
|
435
444
|
}
|
|
436
445
|
|
|
446
|
+
private generateAlertRulesHtml(rules: any[]): string {
|
|
447
|
+
const ruleRows = rules.map(rule => `
|
|
448
|
+
<tr data-rule-id="${rule.id}">
|
|
449
|
+
<td style="padding:8px;border-bottom:1px solid #333;">
|
|
450
|
+
<input type="checkbox" ${rule.enabled ? 'checked' : ''}
|
|
451
|
+
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)" />
|
|
452
|
+
</td>
|
|
453
|
+
<td style="padding:8px;border-bottom:1px solid #333;color:#fff;">${rule.name}</td>
|
|
454
|
+
<td style="padding:8px;border-bottom:1px solid #333;color:#888;">${rule.type}</td>
|
|
455
|
+
<td style="padding:8px;border-bottom:1px solid #333;">
|
|
456
|
+
<span style="padding:2px 8px;border-radius:4px;font-size:12px;background:${
|
|
457
|
+
rule.severity === 'critical' ? '#e94560' :
|
|
458
|
+
rule.severity === 'warning' ? '#f39c12' : '#3498db'
|
|
459
|
+
};color:white;">${rule.severity}</span>
|
|
460
|
+
</td>
|
|
461
|
+
<td style="padding:8px;border-bottom:1px solid #333;color:#888;">${Math.round(rule.cooldown / 1000)}s</td>
|
|
462
|
+
</tr>
|
|
463
|
+
`).join('');
|
|
464
|
+
|
|
465
|
+
const initCode = `localStorage.setItem('sa-temp-rules',JSON.stringify(${JSON.stringify(rules)}))`;
|
|
466
|
+
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));})()`;
|
|
467
|
+
|
|
468
|
+
return `
|
|
469
|
+
<style>
|
|
470
|
+
.sa-rules-table { width:100%; border-collapse:collapse; margin-top:10px; }
|
|
471
|
+
.sa-rules-table th { text-align:left; padding:10px 8px; border-bottom:2px solid #e94560; color:#e94560; font-size:13px; }
|
|
472
|
+
.sa-save-rules-btn {
|
|
473
|
+
background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%);
|
|
474
|
+
color: white;
|
|
475
|
+
border: none;
|
|
476
|
+
padding: 10px 20px;
|
|
477
|
+
border-radius: 6px;
|
|
478
|
+
font-size: 14px;
|
|
479
|
+
font-weight: 600;
|
|
480
|
+
cursor: pointer;
|
|
481
|
+
margin-top: 15px;
|
|
482
|
+
}
|
|
483
|
+
.sa-save-rules-btn:hover { opacity: 0.9; }
|
|
484
|
+
.sa-rules-container { background:#16213e; border-radius:8px; padding:15px; }
|
|
485
|
+
.sa-rules-desc { color:#888; font-size:13px; margin-bottom:10px; }
|
|
486
|
+
</style>
|
|
487
|
+
<div class="sa-rules-container">
|
|
488
|
+
<p class="sa-rules-desc">Enable or disable alert types. Movement alerts notify you when someone moves between cameras.</p>
|
|
489
|
+
<table class="sa-rules-table">
|
|
490
|
+
<thead>
|
|
491
|
+
<tr>
|
|
492
|
+
<th style="width:40px;">On</th>
|
|
493
|
+
<th>Alert Type</th>
|
|
494
|
+
<th>Event</th>
|
|
495
|
+
<th>Severity</th>
|
|
496
|
+
<th>Cooldown</th>
|
|
497
|
+
</tr>
|
|
498
|
+
</thead>
|
|
499
|
+
<tbody>
|
|
500
|
+
${ruleRows}
|
|
501
|
+
</tbody>
|
|
502
|
+
</table>
|
|
503
|
+
<button class="sa-save-rules-btn" onclick="${saveCode}">Save Alert Rules</button>
|
|
504
|
+
<script>(function(){${initCode}})();</script>
|
|
505
|
+
</div>
|
|
506
|
+
`;
|
|
507
|
+
}
|
|
508
|
+
|
|
437
509
|
async putSetting(key: string, value: SettingValue): Promise<void> {
|
|
438
510
|
await this.storageSettings.putSetting(key, value);
|
|
439
511
|
|
|
@@ -487,13 +559,17 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
487
559
|
}
|
|
488
560
|
|
|
489
561
|
if (path.endsWith('/api/topology')) {
|
|
490
|
-
return this.handleTopologyRequest(request, response);
|
|
562
|
+
return await this.handleTopologyRequest(request, response);
|
|
491
563
|
}
|
|
492
564
|
|
|
493
565
|
if (path.endsWith('/api/alerts')) {
|
|
494
566
|
return this.handleAlertsRequest(request, response);
|
|
495
567
|
}
|
|
496
568
|
|
|
569
|
+
if (path.endsWith('/api/alert-rules')) {
|
|
570
|
+
return this.handleAlertRulesRequest(request, response);
|
|
571
|
+
}
|
|
572
|
+
|
|
497
573
|
if (path.endsWith('/api/cameras')) {
|
|
498
574
|
return this.handleCamerasRequest(response);
|
|
499
575
|
}
|
|
@@ -573,7 +649,7 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
573
649
|
}
|
|
574
650
|
}
|
|
575
651
|
|
|
576
|
-
private handleTopologyRequest(request: HttpRequest, response: HttpResponse): void {
|
|
652
|
+
private async handleTopologyRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
|
|
577
653
|
if (request.method === 'GET') {
|
|
578
654
|
const topologyJson = this.storage.getItem('topology');
|
|
579
655
|
const topology = topologyJson ? JSON.parse(topologyJson) : createEmptyTopology();
|
|
@@ -584,7 +660,7 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
584
660
|
try {
|
|
585
661
|
const topology = JSON.parse(request.body!) as CameraTopology;
|
|
586
662
|
this.storage.setItem('topology', JSON.stringify(topology));
|
|
587
|
-
this.startTrackingEngine(topology);
|
|
663
|
+
await this.startTrackingEngine(topology);
|
|
588
664
|
response.send(JSON.stringify({ success: true }), {
|
|
589
665
|
headers: { 'Content-Type': 'application/json' },
|
|
590
666
|
});
|
|
@@ -604,6 +680,28 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
604
680
|
});
|
|
605
681
|
}
|
|
606
682
|
|
|
683
|
+
private handleAlertRulesRequest(request: HttpRequest, response: HttpResponse): void {
|
|
684
|
+
if (request.method === 'GET') {
|
|
685
|
+
const rules = this.alertManager.getRules();
|
|
686
|
+
response.send(JSON.stringify(rules), {
|
|
687
|
+
headers: { 'Content-Type': 'application/json' },
|
|
688
|
+
});
|
|
689
|
+
} else if (request.method === 'PUT' || request.method === 'POST') {
|
|
690
|
+
try {
|
|
691
|
+
const rules = JSON.parse(request.body!);
|
|
692
|
+
this.alertManager.setRules(rules);
|
|
693
|
+
response.send(JSON.stringify({ success: true }), {
|
|
694
|
+
headers: { 'Content-Type': 'application/json' },
|
|
695
|
+
});
|
|
696
|
+
} catch (e) {
|
|
697
|
+
response.send(JSON.stringify({ error: 'Invalid rules JSON' }), {
|
|
698
|
+
code: 400,
|
|
699
|
+
headers: { 'Content-Type': 'application/json' },
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
607
705
|
private handleCamerasRequest(response: HttpResponse): void {
|
|
608
706
|
try {
|
|
609
707
|
// Get all devices with ObjectDetector interface
|
package/src/models/alert.ts
CHANGED
|
@@ -12,6 +12,7 @@ export type AlertSeverity = 'info' | 'warning' | 'critical';
|
|
|
12
12
|
export type AlertType =
|
|
13
13
|
| 'property_entry'
|
|
14
14
|
| 'property_exit'
|
|
15
|
+
| 'movement'
|
|
15
16
|
| 'unusual_path'
|
|
16
17
|
| 'dwell_time'
|
|
17
18
|
| 'restricted_zone'
|
|
@@ -51,10 +52,20 @@ export interface AlertDetails {
|
|
|
51
52
|
cameraId?: string;
|
|
52
53
|
/** Camera display name */
|
|
53
54
|
cameraName?: string;
|
|
55
|
+
/** Source camera for movement alerts */
|
|
56
|
+
fromCameraId?: string;
|
|
57
|
+
/** Source camera name for movement alerts */
|
|
58
|
+
fromCameraName?: string;
|
|
59
|
+
/** Destination camera for movement alerts */
|
|
60
|
+
toCameraId?: string;
|
|
61
|
+
/** Destination camera name for movement alerts */
|
|
62
|
+
toCameraName?: string;
|
|
54
63
|
/** Zone name (for zone-related alerts) */
|
|
55
64
|
zoneName?: string;
|
|
56
65
|
/** Dwell time in milliseconds (for dwell alerts) */
|
|
57
66
|
dwellTime?: number;
|
|
67
|
+
/** Transit time in milliseconds (for movement alerts) */
|
|
68
|
+
transitTime?: number;
|
|
58
69
|
/** Expected path (for unusual path alerts) */
|
|
59
70
|
expectedPath?: string;
|
|
60
71
|
/** Actual path taken */
|
|
@@ -116,7 +127,7 @@ export function createDefaultRules(): AlertRule[] {
|
|
|
116
127
|
conditions: [],
|
|
117
128
|
severity: 'info',
|
|
118
129
|
notifiers: [],
|
|
119
|
-
cooldown:
|
|
130
|
+
cooldown: 60000, // 1 minute
|
|
120
131
|
},
|
|
121
132
|
{
|
|
122
133
|
id: 'property-exit',
|
|
@@ -126,7 +137,17 @@ export function createDefaultRules(): AlertRule[] {
|
|
|
126
137
|
conditions: [],
|
|
127
138
|
severity: 'info',
|
|
128
139
|
notifiers: [],
|
|
129
|
-
cooldown:
|
|
140
|
+
cooldown: 60000,
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: 'movement',
|
|
144
|
+
name: 'Movement Between Cameras',
|
|
145
|
+
enabled: true,
|
|
146
|
+
type: 'movement',
|
|
147
|
+
conditions: [],
|
|
148
|
+
severity: 'info',
|
|
149
|
+
notifiers: [],
|
|
150
|
+
cooldown: 10000, // 10 seconds - we want frequent movement updates
|
|
130
151
|
},
|
|
131
152
|
{
|
|
132
153
|
id: 'unusual-path',
|
|
@@ -178,15 +199,21 @@ export function generateAlertMessage(
|
|
|
178
199
|
type: AlertType,
|
|
179
200
|
details: AlertDetails
|
|
180
201
|
): string {
|
|
202
|
+
// Capitalize the object class for display (person -> Person, car -> Car, dog -> Dog)
|
|
203
|
+
const capitalize = (s: string) => s ? s.charAt(0).toUpperCase() + s.slice(1) : 'Object';
|
|
181
204
|
const objectDesc = details.objectLabel
|
|
182
|
-
? `${details.objectClass} (${details.objectLabel})`
|
|
183
|
-
: details.objectClass || '
|
|
205
|
+
? `${capitalize(details.objectClass || '')} (${details.objectLabel})`
|
|
206
|
+
: capitalize(details.objectClass || '');
|
|
184
207
|
|
|
185
208
|
switch (type) {
|
|
186
209
|
case 'property_entry':
|
|
187
210
|
return `${objectDesc} entered property via ${details.cameraName || 'unknown camera'}`;
|
|
188
211
|
case 'property_exit':
|
|
189
212
|
return `${objectDesc} exited property via ${details.cameraName || 'unknown camera'}`;
|
|
213
|
+
case 'movement':
|
|
214
|
+
const transitSecs = details.transitTime ? Math.round(details.transitTime / 1000) : 0;
|
|
215
|
+
const transitStr = transitSecs > 0 ? ` (${transitSecs}s transit)` : '';
|
|
216
|
+
return `${objectDesc} moving from ${details.fromCameraName || 'unknown'} towards ${details.toCameraName || 'unknown'}${transitStr}`;
|
|
190
217
|
case 'unusual_path':
|
|
191
218
|
return `${objectDesc} took unusual path: ${details.actualPath || 'unknown'}`;
|
|
192
219
|
case 'dwell_time':
|