@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/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
+ }