@blueharford/scrypted-spatial-awareness 0.1.0
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/.vscode/settings.json +3 -0
- package/CLAUDE.md +168 -0
- package/README.md +152 -0
- package/dist/main.nodejs.js +3 -0
- package/dist/main.nodejs.js.LICENSE.txt +1 -0
- package/dist/main.nodejs.js.map +1 -0
- package/dist/plugin.zip +0 -0
- package/out/main.nodejs.js +37376 -0
- package/out/main.nodejs.js.map +1 -0
- package/out/plugin.zip +0 -0
- package/package.json +59 -0
- package/src/alerts/alert-manager.ts +347 -0
- package/src/core/object-correlator.ts +376 -0
- package/src/core/tracking-engine.ts +367 -0
- package/src/devices/global-tracker-sensor.ts +191 -0
- package/src/devices/tracking-zone.ts +245 -0
- package/src/integrations/mqtt-publisher.ts +320 -0
- package/src/main.ts +690 -0
- package/src/models/alert.ts +229 -0
- package/src/models/topology.ts +168 -0
- package/src/models/tracked-object.ts +226 -0
- package/src/state/tracking-state.ts +285 -0
- package/src/ui/editor.html +1051 -0
- package/src/utils/id-generator.ts +36 -0
- package/tsconfig.json +21 -0
package/out/plugin.zip
ADDED
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@blueharford/scrypted-spatial-awareness",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cross-camera object tracking for Scrypted NVR with spatial awareness",
|
|
5
|
+
"author": "Joshua Seidel <blueharford>",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"main": "src/main.ts",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/blueharford/spatial-awareness-scrypted-plugin"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/blueharford/spatial-awareness-scrypted-plugin/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/blueharford/spatial-awareness-scrypted-plugin#readme",
|
|
16
|
+
"scripts": {
|
|
17
|
+
"scrypted-setup-project": "node node_modules/@scrypted/sdk/bin/scrypted-setup-project.js",
|
|
18
|
+
"prescrypted-setup-project": "node node_modules/@scrypted/sdk/bin/scrypted-package-json.js",
|
|
19
|
+
"build": "node node_modules/@scrypted/sdk/bin/scrypted-webpack.js",
|
|
20
|
+
"prepublishOnly": "NODE_ENV=production node node_modules/@scrypted/sdk/bin/scrypted-webpack.js",
|
|
21
|
+
"prescrypted-vscode-launch": "node node_modules/@scrypted/sdk/bin/scrypted-webpack.js",
|
|
22
|
+
"scrypted-vscode-launch": "node node_modules/@scrypted/sdk/bin/scrypted-deploy-debug.js",
|
|
23
|
+
"scrypted-deploy-debug": "node node_modules/@scrypted/sdk/bin/scrypted-deploy-debug.js",
|
|
24
|
+
"scrypted-debug": "node node_modules/@scrypted/sdk/bin/scrypted-debug.js",
|
|
25
|
+
"scrypted-deploy": "node node_modules/@scrypted/sdk/bin/scrypted-deploy.js",
|
|
26
|
+
"scrypted-readme": "node node_modules/@scrypted/sdk/bin/scrypted-readme.js",
|
|
27
|
+
"scrypted-package-json": "node node_modules/@scrypted/sdk/bin/scrypted-package-json.js"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"scrypted",
|
|
31
|
+
"plugin",
|
|
32
|
+
"tracking",
|
|
33
|
+
"cross-camera",
|
|
34
|
+
"nvr",
|
|
35
|
+
"object-detection",
|
|
36
|
+
"spatial-awareness"
|
|
37
|
+
],
|
|
38
|
+
"scrypted": {
|
|
39
|
+
"name": "Spatial Awareness",
|
|
40
|
+
"type": "API",
|
|
41
|
+
"interfaces": [
|
|
42
|
+
"DeviceProvider",
|
|
43
|
+
"DeviceCreator",
|
|
44
|
+
"Settings",
|
|
45
|
+
"HttpRequestHandler",
|
|
46
|
+
"Readme"
|
|
47
|
+
],
|
|
48
|
+
"realfs": true
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@scrypted/sdk": "^0.5.29",
|
|
52
|
+
"mqtt": "^5.0.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@scrypted/common": "^1.0.0",
|
|
56
|
+
"@types/node": "^20.11.0",
|
|
57
|
+
"typescript": "^5.3.0"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alert Manager
|
|
3
|
+
* Generates and dispatches alerts based on tracking events
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import sdk, { Notifier } from '@scrypted/sdk';
|
|
7
|
+
import {
|
|
8
|
+
Alert,
|
|
9
|
+
AlertRule,
|
|
10
|
+
AlertType,
|
|
11
|
+
AlertDetails,
|
|
12
|
+
AlertCondition,
|
|
13
|
+
createAlert,
|
|
14
|
+
createDefaultRules,
|
|
15
|
+
} from '../models/alert';
|
|
16
|
+
import { TrackedObject, GlobalTrackingId } from '../models/tracked-object';
|
|
17
|
+
|
|
18
|
+
const { systemManager } = sdk;
|
|
19
|
+
|
|
20
|
+
export class AlertManager {
|
|
21
|
+
private rules: AlertRule[] = [];
|
|
22
|
+
private recentAlerts: Alert[] = [];
|
|
23
|
+
private cooldowns: Map<string, number> = new Map();
|
|
24
|
+
private console: Console;
|
|
25
|
+
private storage: Storage;
|
|
26
|
+
private maxAlerts: number = 100;
|
|
27
|
+
|
|
28
|
+
constructor(console: Console, storage: Storage) {
|
|
29
|
+
this.console = console;
|
|
30
|
+
this.storage = storage;
|
|
31
|
+
this.loadRules();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check if an alert should be generated and send it
|
|
36
|
+
*/
|
|
37
|
+
async checkAndAlert(
|
|
38
|
+
type: AlertType,
|
|
39
|
+
tracked: TrackedObject,
|
|
40
|
+
details: Partial<AlertDetails>
|
|
41
|
+
): Promise<Alert | null> {
|
|
42
|
+
// Find matching rule
|
|
43
|
+
const rule = this.rules.find(r => r.type === type && r.enabled);
|
|
44
|
+
if (!rule) return null;
|
|
45
|
+
|
|
46
|
+
// Check if rule applies to this object class
|
|
47
|
+
if (rule.objectClasses && rule.objectClasses.length > 0) {
|
|
48
|
+
if (!rule.objectClasses.includes(tracked.className)) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check if rule applies to this camera
|
|
54
|
+
if (rule.cameraIds && rule.cameraIds.length > 0 && details.cameraId) {
|
|
55
|
+
if (!rule.cameraIds.includes(details.cameraId)) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Check cooldown
|
|
61
|
+
const cooldownKey = `${rule.id}:${tracked.globalId}`;
|
|
62
|
+
const lastAlert = this.cooldowns.get(cooldownKey) || 0;
|
|
63
|
+
if (rule.cooldown > 0 && Date.now() - lastAlert < rule.cooldown) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Check conditions
|
|
68
|
+
if (!this.evaluateConditions(rule.conditions, tracked)) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Create alert
|
|
73
|
+
const fullDetails: AlertDetails = {
|
|
74
|
+
...details,
|
|
75
|
+
objectClass: tracked.className,
|
|
76
|
+
objectLabel: tracked.label,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const alert = createAlert(
|
|
80
|
+
type,
|
|
81
|
+
tracked.globalId,
|
|
82
|
+
fullDetails,
|
|
83
|
+
rule.severity,
|
|
84
|
+
rule.id
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Store alert
|
|
88
|
+
this.recentAlerts.unshift(alert);
|
|
89
|
+
if (this.recentAlerts.length > this.maxAlerts) {
|
|
90
|
+
this.recentAlerts.pop();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Update cooldown
|
|
94
|
+
this.cooldowns.set(cooldownKey, Date.now());
|
|
95
|
+
|
|
96
|
+
// Send notifications
|
|
97
|
+
await this.sendNotifications(alert, rule);
|
|
98
|
+
|
|
99
|
+
this.console.log(`Alert generated: [${alert.severity}] ${alert.message}`);
|
|
100
|
+
|
|
101
|
+
return alert;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Send notifications for an alert
|
|
106
|
+
*/
|
|
107
|
+
private async sendNotifications(alert: Alert, rule: AlertRule): Promise<void> {
|
|
108
|
+
const notifierIds = rule.notifiers.length > 0
|
|
109
|
+
? rule.notifiers
|
|
110
|
+
: this.getDefaultNotifiers();
|
|
111
|
+
|
|
112
|
+
for (const notifierId of notifierIds) {
|
|
113
|
+
try {
|
|
114
|
+
const notifier = systemManager.getDeviceById<Notifier>(notifierId);
|
|
115
|
+
if (!notifier) {
|
|
116
|
+
this.console.warn(`Notifier not found: ${notifierId}`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await notifier.sendNotification(
|
|
121
|
+
this.getNotificationTitle(alert),
|
|
122
|
+
{
|
|
123
|
+
body: alert.message,
|
|
124
|
+
data: {
|
|
125
|
+
type: alert.type,
|
|
126
|
+
severity: alert.severity,
|
|
127
|
+
trackedObjectId: alert.trackedObjectId,
|
|
128
|
+
timestamp: alert.timestamp,
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
this.console.log(`Notification sent to ${notifierId}`);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
this.console.error(`Failed to send notification to ${notifierId}:`, e);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get notification title based on alert type
|
|
142
|
+
*/
|
|
143
|
+
private getNotificationTitle(alert: Alert): string {
|
|
144
|
+
const prefix = alert.severity === 'critical' ? '🚨 ' :
|
|
145
|
+
alert.severity === 'warning' ? '⚠️ ' : 'ℹ️ ';
|
|
146
|
+
|
|
147
|
+
switch (alert.type) {
|
|
148
|
+
case 'property_entry':
|
|
149
|
+
return `${prefix}Entry Detected`;
|
|
150
|
+
case 'property_exit':
|
|
151
|
+
return `${prefix}Exit Detected`;
|
|
152
|
+
case 'unusual_path':
|
|
153
|
+
return `${prefix}Unusual Path`;
|
|
154
|
+
case 'dwell_time':
|
|
155
|
+
return `${prefix}Extended Presence`;
|
|
156
|
+
case 'restricted_zone':
|
|
157
|
+
return `${prefix}Restricted Zone Alert`;
|
|
158
|
+
case 'lost_tracking':
|
|
159
|
+
return `${prefix}Lost Tracking`;
|
|
160
|
+
case 'reappearance':
|
|
161
|
+
return `${prefix}Object Reappeared`;
|
|
162
|
+
default:
|
|
163
|
+
return `${prefix}Spatial Awareness Alert`;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Get default notifier IDs from storage
|
|
169
|
+
*/
|
|
170
|
+
private getDefaultNotifiers(): string[] {
|
|
171
|
+
try {
|
|
172
|
+
const defaultNotifier = this.storage.getItem('defaultNotifier');
|
|
173
|
+
return defaultNotifier ? [defaultNotifier] : [];
|
|
174
|
+
} catch {
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Evaluate alert conditions against a tracked object
|
|
181
|
+
*/
|
|
182
|
+
private evaluateConditions(
|
|
183
|
+
conditions: AlertCondition[],
|
|
184
|
+
tracked: TrackedObject
|
|
185
|
+
): boolean {
|
|
186
|
+
for (const condition of conditions) {
|
|
187
|
+
const value = this.getFieldValue(condition.field, tracked);
|
|
188
|
+
|
|
189
|
+
switch (condition.operator) {
|
|
190
|
+
case 'equals':
|
|
191
|
+
if (value !== condition.value) return false;
|
|
192
|
+
break;
|
|
193
|
+
case 'not_equals':
|
|
194
|
+
if (value === condition.value) return false;
|
|
195
|
+
break;
|
|
196
|
+
case 'contains':
|
|
197
|
+
if (!String(value).includes(String(condition.value))) return false;
|
|
198
|
+
break;
|
|
199
|
+
case 'greater_than':
|
|
200
|
+
if (Number(value) <= Number(condition.value)) return false;
|
|
201
|
+
break;
|
|
202
|
+
case 'less_than':
|
|
203
|
+
if (Number(value) >= Number(condition.value)) return false;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Get a field value from tracked object by path
|
|
213
|
+
*/
|
|
214
|
+
private getFieldValue(field: string, tracked: TrackedObject): any {
|
|
215
|
+
const parts = field.split('.');
|
|
216
|
+
let value: any = tracked;
|
|
217
|
+
|
|
218
|
+
for (const part of parts) {
|
|
219
|
+
if (value === null || value === undefined) return undefined;
|
|
220
|
+
value = value[part];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return value;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Get recent alerts
|
|
228
|
+
*/
|
|
229
|
+
getRecentAlerts(limit: number = 50): Alert[] {
|
|
230
|
+
return this.recentAlerts.slice(0, limit);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Acknowledge an alert
|
|
235
|
+
*/
|
|
236
|
+
acknowledgeAlert(alertId: string): boolean {
|
|
237
|
+
const alert = this.recentAlerts.find(a => a.id === alertId);
|
|
238
|
+
if (alert) {
|
|
239
|
+
alert.acknowledged = true;
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Get alerts for a specific tracked object
|
|
247
|
+
*/
|
|
248
|
+
getAlertsForObject(globalId: GlobalTrackingId): Alert[] {
|
|
249
|
+
return this.recentAlerts.filter(a => a.trackedObjectId === globalId);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Set alert rules
|
|
254
|
+
*/
|
|
255
|
+
setRules(rules: AlertRule[]): void {
|
|
256
|
+
this.rules = rules;
|
|
257
|
+
this.saveRules();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Get current rules
|
|
262
|
+
*/
|
|
263
|
+
getRules(): AlertRule[] {
|
|
264
|
+
return this.rules;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Add or update a rule
|
|
269
|
+
*/
|
|
270
|
+
upsertRule(rule: AlertRule): void {
|
|
271
|
+
const index = this.rules.findIndex(r => r.id === rule.id);
|
|
272
|
+
if (index >= 0) {
|
|
273
|
+
this.rules[index] = rule;
|
|
274
|
+
} else {
|
|
275
|
+
this.rules.push(rule);
|
|
276
|
+
}
|
|
277
|
+
this.saveRules();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Delete a rule
|
|
282
|
+
*/
|
|
283
|
+
deleteRule(ruleId: string): boolean {
|
|
284
|
+
const index = this.rules.findIndex(r => r.id === ruleId);
|
|
285
|
+
if (index >= 0) {
|
|
286
|
+
this.rules.splice(index, 1);
|
|
287
|
+
this.saveRules();
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Enable or disable a rule
|
|
295
|
+
*/
|
|
296
|
+
setRuleEnabled(ruleId: string, enabled: boolean): boolean {
|
|
297
|
+
const rule = this.rules.find(r => r.id === ruleId);
|
|
298
|
+
if (rule) {
|
|
299
|
+
rule.enabled = enabled;
|
|
300
|
+
this.saveRules();
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Load rules from storage
|
|
308
|
+
*/
|
|
309
|
+
private loadRules(): void {
|
|
310
|
+
try {
|
|
311
|
+
const json = this.storage.getItem('alertRules');
|
|
312
|
+
if (json) {
|
|
313
|
+
this.rules = JSON.parse(json);
|
|
314
|
+
} else {
|
|
315
|
+
this.rules = createDefaultRules();
|
|
316
|
+
}
|
|
317
|
+
} catch (e) {
|
|
318
|
+
this.console.error('Failed to load alert rules:', e);
|
|
319
|
+
this.rules = createDefaultRules();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Save rules to storage
|
|
325
|
+
*/
|
|
326
|
+
private saveRules(): void {
|
|
327
|
+
try {
|
|
328
|
+
this.storage.setItem('alertRules', JSON.stringify(this.rules));
|
|
329
|
+
} catch (e) {
|
|
330
|
+
this.console.error('Failed to save alert rules:', e);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Clear all cooldowns
|
|
336
|
+
*/
|
|
337
|
+
clearCooldowns(): void {
|
|
338
|
+
this.cooldowns.clear();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Clear alert history
|
|
343
|
+
*/
|
|
344
|
+
clearHistory(): void {
|
|
345
|
+
this.recentAlerts = [];
|
|
346
|
+
}
|
|
347
|
+
}
|