een-api-toolkit 0.3.20 → 0.3.22

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.
Files changed (33) hide show
  1. package/.claude/agents/docs-accuracy-reviewer.md +146 -0
  2. package/.claude/agents/een-auth-agent.md +168 -0
  3. package/.claude/agents/een-devices-agent.md +294 -0
  4. package/.claude/agents/een-events-agent.md +375 -0
  5. package/.claude/agents/een-media-agent.md +256 -0
  6. package/.claude/agents/een-setup-agent.md +126 -0
  7. package/.claude/agents/een-users-agent.md +239 -0
  8. package/.claude/agents/test-runner.md +144 -0
  9. package/CHANGELOG.md +138 -30
  10. package/docs/AI-CONTEXT.md +169 -1700
  11. package/docs/ai-reference/AI-AUTH.md +288 -0
  12. package/docs/ai-reference/AI-DEVICES.md +569 -0
  13. package/docs/ai-reference/AI-EVENTS.md +1745 -0
  14. package/docs/ai-reference/AI-MEDIA.md +974 -0
  15. package/docs/ai-reference/AI-SETUP.md +267 -0
  16. package/docs/ai-reference/AI-USERS.md +255 -0
  17. package/examples/vue-event-subscriptions/package-lock.json +8 -1
  18. package/examples/vue-event-subscriptions/package.json +1 -0
  19. package/examples/vue-event-subscriptions/src/App.vue +1 -41
  20. package/examples/vue-event-subscriptions/src/composables/useHlsPlayer.ts +272 -0
  21. package/examples/vue-event-subscriptions/src/main.ts +3 -3
  22. package/examples/vue-event-subscriptions/src/stores/connection.ts +101 -0
  23. package/examples/vue-event-subscriptions/src/stores/mediaSession.ts +79 -0
  24. package/examples/vue-event-subscriptions/src/views/LiveEvents.vue +349 -88
  25. package/examples/vue-event-subscriptions/src/views/Logout.vue +6 -0
  26. package/examples/vue-event-subscriptions/src/views/Subscriptions.vue +0 -13
  27. package/examples/vue-events/package-lock.json +8 -1
  28. package/examples/vue-events/package.json +1 -0
  29. package/examples/vue-events/src/components/EventsModal.vue +269 -47
  30. package/examples/vue-events/src/composables/useHlsPlayer.ts +272 -0
  31. package/examples/vue-events/src/stores/mediaSession.ts +79 -0
  32. package/package.json +10 -2
  33. package/scripts/setup-agents.ts +116 -0
@@ -0,0 +1,1745 @@
1
+ # Events, Alerts & Real-Time Streaming - EEN API Toolkit
2
+
3
+ > **Version:** 0.3.22
4
+ >
5
+ > Complete reference for events, alerts, metrics, and SSE subscriptions.
6
+ > Load this document when implementing event-driven features.
7
+
8
+ ---
9
+
10
+ ## Overview
11
+
12
+ | Concept | Description |
13
+ |---------|-------------|
14
+ | **Events** | Camera-generated occurrences (motion, analytics) |
15
+ | **Alerts** | Rule-triggered notifications |
16
+ | **Metrics** | Aggregated event counts over time |
17
+ | **Notifications** | User-facing alert messages |
18
+ | **Subscriptions** | Real-time SSE streams for events |
19
+
20
+ ---
21
+
22
+ ## Event Types
23
+
24
+ ```typescript
25
+ type ActorType =
26
+ | 'bridge' | 'camera' | 'speaker' | 'account' | 'user'
27
+ | 'layout' | 'job' | 'measurement' | 'sensor' | 'gateway'
28
+
29
+ interface Event {
30
+ id: string
31
+ startTimestamp: string // ISO 8601
32
+ endTimestamp?: string | null
33
+ span: boolean
34
+ accountId: string
35
+ actorId: string
36
+ actorAccountId: string
37
+ actorType: ActorType
38
+ creatorId: string
39
+ type: string // e.g., 'een.motionDetectionEvent.v1'
40
+ dataSchemas: string[]
41
+ data: EventData[]
42
+ }
43
+
44
+ interface ListEventsParams {
45
+ actor: string // Required: 'camera:{id}' format
46
+ type__in: string[] // Required: event types to fetch
47
+ startTimestamp__gte: string // Required: ISO 8601 timestamp
48
+ startTimestamp__lte?: string
49
+ endTimestamp__gte?: string
50
+ endTimestamp__lte?: string
51
+ pageSize?: number
52
+ pageToken?: string
53
+ sort?: '+startTimestamp' | '-startTimestamp'
54
+ include?: string[] // e.g., ['data.een.fullFrameImageUrl.v1']
55
+ }
56
+ ```
57
+
58
+ ---
59
+
60
+ ## Event Metrics Types
61
+
62
+ ```typescript
63
+ type MetricDataPoint = [number, number] // [timestamp_ms, count]
64
+
65
+ interface EventMetric {
66
+ eventType: string
67
+ actorId: string
68
+ actorType: string
69
+ dataPoints: MetricDataPoint[]
70
+ }
71
+
72
+ interface GetEventMetricsParams {
73
+ actor: string // Required: 'camera:{id}'
74
+ eventType: string // Required: e.g., 'een.motionDetectionEvent.v1'
75
+ timestamp__gte?: string // Defaults to 7 days ago
76
+ timestamp__lte?: string // Defaults to now
77
+ aggregateByMinutes?: number // Default 60, minimum 60
78
+ }
79
+ ```
80
+
81
+ ---
82
+
83
+ ## Alert Types
84
+
85
+ ```typescript
86
+ interface Alert {
87
+ id: string
88
+ timestamp: string
89
+ alertType: string
90
+ alertName?: string
91
+ actorId: string
92
+ actorType: string
93
+ actorName?: string
94
+ priority?: number // 0-20
95
+ actions?: Record<string, AlertAction>
96
+ }
97
+
98
+ interface ListAlertsParams {
99
+ timestamp__gte?: string
100
+ timestamp__lte?: string
101
+ alertType__in?: string[]
102
+ actorId__in?: string[]
103
+ priority__gte?: number
104
+ include?: ('data' | 'actions')[]
105
+ sort?: ('+timestamp' | '-timestamp')[]
106
+ }
107
+ ```
108
+
109
+ ---
110
+
111
+ ## Event Subscription Types
112
+
113
+ ```typescript
114
+ type EventSubscriptionLifecycle = 'temporary' | 'persistent'
115
+ type EventSubscriptionDeliveryType = 'serverSentEvents.v1' | 'webhook.v1'
116
+
117
+ interface EventSubscription {
118
+ id: string
119
+ subscriptionConfig?: {
120
+ lifeCycle: EventSubscriptionLifecycle
121
+ timeToLiveSeconds?: number
122
+ }
123
+ deliveryConfig: {
124
+ type: EventSubscriptionDeliveryType
125
+ sseUrl?: string // SSE endpoint URL (for SSE type)
126
+ secret?: string // Webhook signature secret (for webhook type)
127
+ }
128
+ }
129
+
130
+ interface EventTypeFilter {
131
+ id: string // e.g., 'een.motionDetectionEvent.v1'
132
+ }
133
+
134
+ interface FilterCreate {
135
+ actors: string[] // e.g., ['camera:{id}']
136
+ types: EventTypeFilter[] // Event types to subscribe to
137
+ }
138
+
139
+ interface CreateEventSubscriptionParams {
140
+ deliveryConfig: {
141
+ type: 'serverSentEvents.v1'
142
+ } | {
143
+ type: 'webhook.v1'
144
+ webhookUrl: string
145
+ technicalContactEmail: string
146
+ technicalContactName: string
147
+ }
148
+ filters: FilterCreate[]
149
+ }
150
+
151
+ type SSEConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
152
+
153
+ interface SSEConnection {
154
+ close: () => void
155
+ status: SSEConnectionStatus
156
+ }
157
+
158
+ interface SSEEvent {
159
+ id: string
160
+ type: string
161
+ actorId: string
162
+ actorType?: string
163
+ startTimestamp: string
164
+ endTimestamp?: string | null
165
+ span?: boolean
166
+ data?: Array<{ type: string; [key: string]: unknown }>
167
+ }
168
+ ```
169
+
170
+ ---
171
+
172
+ ## Event Functions
173
+
174
+ ### listEvents(params)
175
+
176
+ ```typescript
177
+ import { listEvents, formatTimestamp } from 'een-api-toolkit'
178
+
179
+ const { data, error } = await listEvents({
180
+ actor: `camera:${cameraId}`,
181
+ type__in: ['een.motionDetectionEvent.v1'],
182
+ startTimestamp__gte: formatTimestamp(startDate.toISOString()),
183
+ include: ['data.een.fullFrameImageUrl.v1']
184
+ })
185
+ ```
186
+
187
+ ### getEventMetrics(params)
188
+
189
+ ```typescript
190
+ import { getEventMetrics, formatTimestamp } from 'een-api-toolkit'
191
+
192
+ const { data, error } = await getEventMetrics({
193
+ actor: `camera:${cameraId}`,
194
+ eventType: 'een.motionDetectionEvent.v1',
195
+ timestamp__gte: formatTimestamp(sevenDaysAgo.toISOString()),
196
+ aggregateByMinutes: 60
197
+ })
198
+
199
+ // Convert to Chart.js format
200
+ const chartData = data.map(metric => ({
201
+ x: new Date(metric.dataPoints[0][0]),
202
+ y: metric.dataPoints[0][1]
203
+ }))
204
+ ```
205
+
206
+ ---
207
+
208
+ ## Alert Functions
209
+
210
+ ### listAlerts(params?)
211
+
212
+ ```typescript
213
+ import { listAlerts, formatTimestamp } from 'een-api-toolkit'
214
+
215
+ const { data, error } = await listAlerts({
216
+ timestamp__gte: formatTimestamp(startDate.toISOString()),
217
+ actorId__in: [cameraId],
218
+ include: ['actions'],
219
+ sort: ['-timestamp']
220
+ })
221
+ ```
222
+
223
+ ---
224
+
225
+ ## Notification Functions
226
+
227
+ ### listNotifications(params?)
228
+
229
+ ```typescript
230
+ import { listNotifications } from 'een-api-toolkit'
231
+
232
+ const { data, error } = await listNotifications({
233
+ read: false,
234
+ sort: ['-timestamp']
235
+ })
236
+ ```
237
+
238
+ ---
239
+
240
+ ## Event Subscription Functions
241
+
242
+ ### SSE Lifecycle
243
+
244
+ ```typescript
245
+ import {
246
+ createEventSubscription,
247
+ connectToEventSubscription,
248
+ deleteEventSubscription
249
+ } from 'een-api-toolkit'
250
+
251
+ // 1. Create subscription
252
+ const { data: subscription } = await createEventSubscription({
253
+ deliveryConfig: { type: 'serverSentEvents.v1' },
254
+ filters: [{
255
+ actors: [`camera:${cameraId}`],
256
+ types: [{ id: 'een.motionDetectionEvent.v1' }]
257
+ }]
258
+ })
259
+
260
+ // 2. Connect to SSE stream
261
+ const { data: connection } = connectToEventSubscription(
262
+ subscription.deliveryConfig.sseUrl,
263
+ {
264
+ onEvent: (event) => {
265
+ console.log('Event received:', event)
266
+ },
267
+ onError: (error) => {
268
+ console.error('SSE error:', error)
269
+ },
270
+ onStatusChange: (status) => {
271
+ console.log('Connection status:', status)
272
+ }
273
+ }
274
+ )
275
+
276
+ // 3. Cleanup when done
277
+ connection.close()
278
+ await deleteEventSubscription(subscription.id)
279
+ ```
280
+
281
+ ---
282
+
283
+ ## Vue Components
284
+
285
+ ### EventsModal.vue
286
+
287
+ ```vue
288
+ <script setup lang="ts">
289
+ import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
290
+ import {
291
+ listEvents,
292
+ listEventFieldValues,
293
+ listEventTypes,
294
+ getRecordedImage,
295
+ type Camera,
296
+ type Event,
297
+ type EventType,
298
+ type EenError
299
+ } from 'een-api-toolkit'
300
+ import { useHlsPlayer } from '../composables/useHlsPlayer'
301
+
302
+ // Initialize HLS player composable
303
+ const hlsPlayer = useHlsPlayer()
304
+ const { videoUrl, videoError, loadingVideo, loadVideo, resetVideo } = hlsPlayer
305
+
306
+ /**
307
+ * Bounding box from object detection data.
308
+ * Coordinates are normalized (0-1) relative to image dimensions.
309
+ */
310
+ interface BoundingBox {
311
+ x: number
312
+ y: number
313
+ width: number
314
+ height: number
315
+ label?: string
316
+ confidence?: number
317
+ }
318
+
319
+ /** Constant for converting normalized coordinates (0-1) to SVG viewBox percentage */
320
+ const NORMALIZED_TO_PERCENT = 100
321
+
322
+ /** Maximum number of images to cache to prevent memory issues */
323
+ const MAX_IMAGE_CACHE_SIZE = 50
324
+
325
+ /**
326
+ * Type guard to check if a value is a non-empty string.
327
+ */
328
+ function isNonEmptyString(value: unknown): value is string {
329
+ return typeof value === 'string' && value.length > 0
330
+ }
331
+
332
+ /**
333
+ * Type guard to validate a bounding box array.
334
+ * Must be an array of exactly 4 numbers.
335
+ */
336
+ function isValidBoundingBoxArray(value: unknown): value is [number, number, number, number] {
337
+ return (
338
+ Array.isArray(value) &&
339
+ value.length === 4 &&
340
+ value.every(v => typeof v === 'number' && !Number.isNaN(v))
341
+ )
342
+ }
343
+
344
+ const props = defineProps<{
345
+ camera: Camera
346
+ isOpen: boolean
347
+ }>()
348
+
349
+ const emit = defineEmits<{
350
+ close: []
351
+ }>()
352
+
353
+ // Time range options
354
+ type TimeRange = '1h' | '6h' | '24h'
355
+ const timeRangeOptions: { value: TimeRange; label: string }[] = [
356
+ { value: '1h', label: 'Last 1 hour' },
357
+ { value: '6h', label: 'Last 6 hours' },
358
+ { value: '24h', label: 'Last 24 hours' }
359
+ ]
360
+
361
+ // State
362
+ const loading = ref(false)
363
+ const loadingFieldValues = ref(false)
364
+ const error = ref<EenError | null>(null)
365
+ const fieldValuesError = ref<EenError | null>(null)
366
+ const events = ref<Event[]>([])
367
+ const nextPageToken = ref<string | undefined>(undefined)
368
+ const availableEventTypes = ref<string[]>([])
369
+ const selectedEventTypes = ref<string[]>([])
370
+ const timeRange = ref<TimeRange>('1h')
371
+ const eventTypeNames = ref<Map<string, string>>(new Map())
372
+ const eventImages = ref<Map<string, string>>(new Map())
373
+ const imageLoadingIds = ref<Set<string>>(new Set()) // Track which images are currently loading
374
+ const boundingBoxCache = ref<Map<string, BoundingBox[]>>(new Map()) // Cache bounding boxes per event
375
+ const enlargedEventId = ref<string | null>(null)
376
+
377
+ // Lightbox media state
378
+ const showVideo = ref(false)
379
+ const hdImageUrl = ref<string | null>(null)
380
+ const loadingHdImage = ref(false)
381
+ const hdImageError = ref<string | null>(null)
382
+ const currentMediaType = ref<'preview' | 'hd' | 'video'>('preview')
383
+
384
+ // Computed
385
+ const hasNextPage = computed(() => !!nextPageToken.value)
386
+ const hasNoEvents = computed(() => !loading.value && events.value.length === 0 && !error.value)
387
+ const enlargedEvent = computed(() => {
388
+ if (!enlargedEventId.value) return null
389
+ return events.value.find(e => e.id === enlargedEventId.value) || null
390
+ })
391
+ const enlargedImage = computed(() => {
392
+ if (!enlargedEventId.value) return null
393
+ return eventImages.value.get(enlargedEventId.value) || null
394
+ })
395
+ const enlargedBoundingBoxes = computed(() => {
396
+ if (!enlargedEvent.value) return []
397
+ return getBoundingBoxes(enlargedEvent.value)
398
+ })
399
+
400
+ // Get start timestamp based on time range
401
+ function getStartTimestamp(range: TimeRange): string {
402
+ const now = Date.now()
403
+ const hoursMap: Record<TimeRange, number> = {
404
+ '1h': 1,
405
+ '6h': 6,
406
+ '24h': 24
407
+ }
408
+ return new Date(now - hoursMap[range] * 60 * 60 * 1000).toISOString()
409
+ }
410
+
411
+ /**
412
+ * Get fallback label for detected object based on event type.
413
+ */
414
+ function getFallbackLabel(eventType: string): string {
415
+ if (eventType.includes('person')) return 'Person'
416
+ if (eventType.includes('vehicle')) return 'Vehicle'
417
+ if (eventType.includes('licensePlate') || eventType.includes('lpr')) return 'License Plate'
418
+ return 'Object'
419
+ }
420
+
421
+ /**
422
+ * Extract bounding boxes from event data.
423
+ * Looks for object detection data schemas and extracts bounding box info.
424
+ * The EEN API returns boundingBox as [x1, y1, x2, y2] normalized coordinates (0-1).
425
+ * Labels are obtained from een.objectClassification.v1 data when available.
426
+ * Results are cached per event ID to avoid redundant calculations.
427
+ */
428
+ function getBoundingBoxes(event: Event): BoundingBox[] {
429
+ // Check cache first
430
+ const cached = boundingBoxCache.value.get(event.id)
431
+ if (cached) {
432
+ return cached
433
+ }
434
+
435
+ const boxes: BoundingBox[] = []
436
+ const fallbackLabel = getFallbackLabel(event.type)
437
+
438
+ // Build a map of objectId -> classification label from objectClassification data
439
+ const classificationMap = new Map<string, string>()
440
+ for (const dataItem of event.data) {
441
+ if (dataItem.type === 'een.objectClassification.v1') {
442
+ // Use type guards for proper runtime validation
443
+ const objectId = dataItem.objectId
444
+ const label = dataItem.label
445
+ if (isNonEmptyString(objectId) && isNonEmptyString(label)) {
446
+ // Capitalize first letter of label
447
+ const formattedLabel = label.charAt(0).toUpperCase() + label.slice(1).toLowerCase()
448
+ classificationMap.set(objectId, formattedLabel)
449
+ }
450
+ }
451
+ }
452
+
453
+ // Extract bounding boxes from objectDetection data
454
+ for (const dataItem of event.data) {
455
+ if (dataItem.type === 'een.objectDetection.v1') {
456
+ const boundingBox = dataItem.boundingBox
457
+ const objectId = dataItem.objectId
458
+
459
+ // Use type guard for proper runtime validation of bounding box array
460
+ if (isValidBoundingBoxArray(boundingBox)) {
461
+ const [x1, y1, x2, y2] = boundingBox
462
+ // Use classification label if available, otherwise use fallback
463
+ const label = (isNonEmptyString(objectId) && classificationMap.get(objectId)) || fallbackLabel
464
+ boxes.push({
465
+ x: x1,
466
+ y: y1,
467
+ width: x2 - x1,
468
+ height: y2 - y1,
469
+ label
470
+ })
471
+ }
472
+ }
473
+ }
474
+
475
+ // Cache the result
476
+ boundingBoxCache.value.set(event.id, boxes)
477
+ return boxes
478
+ }
479
+
480
+ /**
481
+ * Get the count of bounding boxes for an event.
482
+ */
483
+ function getBoundingBoxCount(event: Event): number {
484
+ return getBoundingBoxes(event).length
485
+ }
486
+
487
+ // Fetch available event types for this camera
488
+ async function fetchAvailableEventTypes() {
489
+ loadingFieldValues.value = true
490
+ fieldValuesError.value = null
491
+
492
+ const result = await listEventFieldValues({
493
+ actor: `camera:${props.camera.id}`
494
+ })
495
+
496
+ if (result.error) {
497
+ fieldValuesError.value = result.error
498
+ availableEventTypes.value = []
499
+ } else {
500
+ availableEventTypes.value = result.data.type || []
501
+ // Select all by default
502
+ selectedEventTypes.value = [...availableEventTypes.value]
503
+ }
504
+
505
+ loadingFieldValues.value = false
506
+ }
507
+
508
+ // Fetch event type names for display
509
+ async function fetchEventTypeNames() {
510
+ const result = await listEventTypes({ pageSize: 100 })
511
+ if (!result.error && result.data) {
512
+ const nameMap = new Map<string, string>()
513
+ for (const et of result.data.results as EventType[]) {
514
+ nameMap.set(et.type, et.name)
515
+ }
516
+ eventTypeNames.value = nameMap
517
+ }
518
+ }
519
+
520
+ // Get human-readable event type name
521
+ function getEventTypeName(type: string): string {
522
+ return eventTypeNames.value.get(type) || formatEventType(type)
523
+ }
524
+
525
+ // Format event type for display (fallback)
526
+ function formatEventType(type: string): string {
527
+ // e.g., "een.motionDetectionEvent.v1" -> "Motion Detection"
528
+ const match = type.match(/een\.(\w+)Event\.v\d+/)
529
+ if (match) {
530
+ return match[1]
531
+ .replace(/([A-Z])/g, ' $1')
532
+ .replace(/^./, str => str.toUpperCase())
533
+ .trim()
534
+ }
535
+ return type
536
+ }
537
+
538
+ // Fetch events
539
+ async function fetchEvents(append = false) {
540
+ if (selectedEventTypes.value.length === 0) {
541
+ events.value = []
542
+ nextPageToken.value = undefined
543
+ return
544
+ }
545
+
546
+ loading.value = true
547
+ if (!append) {
548
+ error.value = null
549
+ }
550
+
551
+ const result = await listEvents({
552
+ actor: `camera:${props.camera.id}`,
553
+ type__in: selectedEventTypes.value,
554
+ startTimestamp__gte: getStartTimestamp(timeRange.value),
555
+ endTimestamp__lte: new Date().toISOString(),
556
+ pageSize: 20,
557
+ pageToken: append ? nextPageToken.value : undefined,
558
+ sort: '-startTimestamp',
559
+ include: ['data.een.fullFrameImageUrl.v1', 'data.een.croppedFrameImageUrl.v1', 'data.een.objectDetection.v1', 'data.een.objectClassification.v1']
560
+ })
561
+
562
+ if (result.error) {
563
+ error.value = result.error
564
+ if (!append) {
565
+ events.value = []
566
+ }
567
+ nextPageToken.value = undefined
568
+ } else {
569
+ const newEvents = result.data.results
570
+ if (append) {
571
+ events.value = [...events.value, ...newEvents]
572
+ } else {
573
+ events.value = newEvents
574
+ eventImages.value.clear()
575
+ }
576
+ nextPageToken.value = result.data.nextPageToken
577
+
578
+ // Load images for the new events (don't block UI)
579
+ loadEventImages(newEvents)
580
+ }
581
+
582
+ loading.value = false
583
+ }
584
+
585
+ // Load more events
586
+ async function loadMore() {
587
+ if (!nextPageToken.value) return
588
+ await fetchEvents(true)
589
+ }
590
+
591
+ /**
592
+ * Evict oldest images from cache if it exceeds the maximum size.
593
+ * Removes images that are not currently being displayed.
594
+ */
595
+ function evictOldestImages() {
596
+ if (eventImages.value.size <= MAX_IMAGE_CACHE_SIZE) return
597
+
598
+ // Get IDs of images to keep (currently visible events)
599
+ const visibleEventIds = new Set(events.value.map(e => e.id))
600
+
601
+ // Find images to evict (not currently visible)
602
+ const idsToEvict: string[] = []
603
+ for (const id of eventImages.value.keys()) {
604
+ if (!visibleEventIds.has(id)) {
605
+ idsToEvict.push(id)
606
+ }
607
+ }
608
+
609
+ // Evict oldest first (Map maintains insertion order)
610
+ const numToEvict = eventImages.value.size - MAX_IMAGE_CACHE_SIZE
611
+ for (let i = 0; i < Math.min(numToEvict, idsToEvict.length); i++) {
612
+ eventImages.value.delete(idsToEvict[i])
613
+ }
614
+ }
615
+
616
+ // Load images for events using getRecordedImage API
617
+ async function loadEventImages(eventsToLoad: Event[]) {
618
+ // Load images in parallel for all camera events
619
+ const loadPromises = eventsToLoad
620
+ .filter(event => event.actorType === 'camera')
621
+ .map(async (event) => {
622
+ // Skip if already loaded or currently loading (prevents race condition)
623
+ if (eventImages.value.has(event.id) || imageLoadingIds.value.has(event.id)) {
624
+ return
625
+ }
626
+
627
+ // Mark as loading to prevent duplicate requests
628
+ imageLoadingIds.value.add(event.id)
629
+
630
+ try {
631
+ const result = await getRecordedImage({
632
+ deviceId: event.actorId,
633
+ type: 'preview',
634
+ timestamp__gte: event.startTimestamp
635
+ })
636
+
637
+ if (!result.error && result.data) {
638
+ eventImages.value.set(event.id, result.data.imageData)
639
+ // Evict old images if cache is too large
640
+ evictOldestImages()
641
+ }
642
+ } finally {
643
+ // Remove from loading set regardless of success/failure
644
+ imageLoadingIds.value.delete(event.id)
645
+ }
646
+ })
647
+
648
+ await Promise.all(loadPromises)
649
+ }
650
+
651
+ // Get image for an event (from loaded images map)
652
+ function getEventImage(event: Event): string | null {
653
+ return eventImages.value.get(event.id) || null
654
+ }
655
+
656
+ // Format timestamp for display
657
+ function formatTimestamp(timestamp: string): string {
658
+ const date = new Date(timestamp)
659
+ return date.toLocaleString()
660
+ }
661
+
662
+ // Handle event type checkbox toggle
663
+ function toggleEventType(type: string) {
664
+ const index = selectedEventTypes.value.indexOf(type)
665
+ if (index === -1) {
666
+ selectedEventTypes.value.push(type)
667
+ } else {
668
+ selectedEventTypes.value.splice(index, 1)
669
+ }
670
+ }
671
+
672
+ // Select/deselect all event types
673
+ function toggleAllEventTypes() {
674
+ if (selectedEventTypes.value.length === availableEventTypes.value.length) {
675
+ selectedEventTypes.value = []
676
+ } else {
677
+ selectedEventTypes.value = [...availableEventTypes.value]
678
+ }
679
+ }
680
+
681
+ // Open enlarged image view
682
+ function openEnlargedImage(eventId: string) {
683
+ enlargedEventId.value = eventId
684
+ currentMediaType.value = 'preview'
685
+ showVideo.value = false
686
+ hdImageUrl.value = null
687
+ hdImageError.value = null
688
+ resetVideo()
689
+ }
690
+
691
+ // Close enlarged image view
692
+ function closeEnlargedImage() {
693
+ enlargedEventId.value = null
694
+ showVideo.value = false
695
+ hdImageUrl.value = null
696
+ hdImageError.value = null
697
+ currentMediaType.value = 'preview'
698
+ resetVideo()
699
+ }
700
+
701
+ // Switch to preview mode
702
+ function showPreview() {
703
+ currentMediaType.value = 'preview'
704
+ showVideo.value = false
705
+ resetVideo()
706
+ }
707
+
708
+ // Load and show HD image
709
+ async function showHdImage() {
710
+ if (!enlargedEvent.value) return
711
+
712
+ currentMediaType.value = 'hd'
713
+ showVideo.value = false
714
+ loadingHdImage.value = true
715
+ hdImageError.value = null
716
+ hdImageUrl.value = null
717
+ resetVideo()
718
+
719
+ const result = await getRecordedImage({
720
+ deviceId: enlargedEvent.value.actorId,
721
+ type: 'main',
722
+ timestamp__gte: enlargedEvent.value.startTimestamp
723
+ })
724
+
725
+ if (result.error) {
726
+ hdImageError.value = result.error.message
727
+ } else if (result.data?.imageData) {
728
+ hdImageUrl.value = result.data.imageData
729
+ } else {
730
+ hdImageError.value = 'No image data returned'
731
+ }
732
+
733
+ loadingHdImage.value = false
734
+ }
735
+
736
+ // Load and show video
737
+ async function showVideoPlayer() {
738
+ if (!enlargedEvent.value) return
739
+
740
+ currentMediaType.value = 'video'
741
+ showVideo.value = true
742
+ hdImageUrl.value = null
743
+ hdImageError.value = null
744
+
745
+ await loadVideo(enlargedEvent.value.actorId, enlargedEvent.value.startTimestamp)
746
+ }
747
+
748
+ // Handle keyboard events for accessibility
749
+ function handleKeydown(event: KeyboardEvent) {
750
+ if (event.key === 'Escape' && enlargedEventId.value) {
751
+ closeEnlargedImage()
752
+ }
753
+ }
754
+
755
+ // Set up keyboard event listener for ESC key
756
+ onMounted(() => {
757
+ window.addEventListener('keydown', handleKeydown)
758
+ })
759
+
760
+ onUnmounted(() => {
761
+ window.removeEventListener('keydown', handleKeydown)
762
+ })
763
+
764
+ // Watch for modal open/close
765
+ watch(() => props.isOpen, async (isOpen) => {
766
+ if (isOpen) {
767
+ events.value = []
768
+ nextPageToken.value = undefined
769
+ error.value = null
770
+ eventImages.value.clear()
771
+ imageLoadingIds.value.clear()
772
+ boundingBoxCache.value.clear()
773
+
774
+ await fetchEventTypeNames()
775
+ await fetchAvailableEventTypes()
776
+
777
+ if (availableEventTypes.value.length > 0) {
778
+ await fetchEvents()
779
+ }
780
+ } else {
781
+ // Clean up on modal close to free memory (base64 images can be large)
782
+ eventImages.value.clear()
783
+ imageLoadingIds.value.clear()
784
+ boundingBoxCache.value.clear()
785
+ events.value = []
786
+ enlargedEventId.value = null
787
+ }
788
+ }, { immediate: true })
789
+
790
+ // Watch for filter changes
791
+ watch([timeRange, selectedEventTypes], () => {
792
+ if (props.isOpen) {
793
+ fetchEvents()
794
+ }
795
+ }, { deep: true })
796
+ </script>
797
+
798
+ <template>
799
+ <div v-if="isOpen" class="modal-overlay" @click.self="emit('close')">
800
+ <div class="modal">
801
+ <div class="modal-header">
802
+ <div class="header-info">
803
+ <h2>Events: {{ camera.name }}</h2>
804
+ <div class="camera-id">Camera ID: {{ camera.id }}</div>
805
+ </div>
806
+ <button class="close-button" @click="emit('close')">&times;</button>
807
+ </div>
808
+
809
+ <div class="modal-filters" data-testid="modal-filters">
810
+ <div class="filter-row">
811
+ <label>Time Range:</label>
812
+ <select v-model="timeRange" data-testid="time-range-select">
813
+ <option v-for="option in timeRangeOptions" :key="option.value" :value="option.value">
814
+ {{ option.label }}
815
+ </option>
816
+ </select>
817
+ </div>
818
+
819
+ <div class="filter-row event-types">
820
+ <label>Event Types:</label>
821
+ <div v-if="loadingFieldValues" class="loading-types">
822
+ Loading event types...
823
+ </div>
824
+ <div v-else-if="fieldValuesError" class="error-types" data-testid="field-values-error">
825
+ Error loading event types: {{ fieldValuesError.message }}
826
+ </div>
827
+ <div v-else-if="availableEventTypes.length === 0" class="no-types">
828
+ No event types available for this camera
829
+ </div>
830
+ <div v-else class="event-type-list">
831
+ <label class="event-type-checkbox select-all">
832
+ <input
833
+ type="checkbox"
834
+ :checked="selectedEventTypes.length === availableEventTypes.length"
835
+ :indeterminate="selectedEventTypes.length > 0 && selectedEventTypes.length < availableEventTypes.length"
836
+ @change="toggleAllEventTypes"
837
+ />
838
+ <span>Select All</span>
839
+ </label>
840
+ <label
841
+ v-for="eventType in availableEventTypes"
842
+ :key="eventType"
843
+ class="event-type-checkbox"
844
+ >
845
+ <input
846
+ type="checkbox"
847
+ :checked="selectedEventTypes.includes(eventType)"
848
+ @change="toggleEventType(eventType)"
849
+ />
850
+ <span>{{ getEventTypeName(eventType) }}</span>
851
+ </label>
852
+ </div>
853
+ </div>
854
+ </div>
855
+
856
+ <div class="modal-body">
857
+ <div v-if="loading && events.length === 0" class="loading">
858
+ Loading events...
859
+ </div>
860
+
861
+ <div v-else-if="error" class="error">
862
+ Error: {{ error.message }}
863
+ </div>
864
+
865
+ <div v-else-if="hasNoEvents" class="no-events" data-testid="no-events">
866
+ No events found for the selected filters.
867
+ </div>
868
+
869
+ <div v-else class="events-list" data-testid="events-list">
870
+ <div v-for="event in events" :key="event.id" class="event-item" data-testid="event-item">
871
+ <div
872
+ class="event-thumbnail"
873
+ :class="{ clickable: getEventImage(event) }"
874
+ @click="getEventImage(event) && openEnlargedImage(event.id)"
875
+ >
876
+ <img
877
+ v-if="getEventImage(event)"
878
+ :src="getEventImage(event) || ''"
879
+ :alt="event.type"
880
+ />
881
+ <div v-else class="no-thumbnail loading-image">
882
+ Loading...
883
+ </div>
884
+ </div>
885
+ <div class="event-info">
886
+ <div class="event-type">{{ getEventTypeName(event.type) }}</div>
887
+ <div class="event-time">{{ formatTimestamp(event.startTimestamp) }}</div>
888
+ <div v-if="getBoundingBoxCount(event) > 0" class="event-detections" data-testid="event-detections">
889
+ {{ getBoundingBoxCount(event) }} detection{{ getBoundingBoxCount(event) !== 1 ? 's' : '' }}
890
+ </div>
891
+ <div class="event-id">ID: {{ event.id }}</div>
892
+ </div>
893
+ </div>
894
+ </div>
895
+
896
+ <div v-if="hasNextPage" class="load-more">
897
+ <button @click="loadMore" :disabled="loading">
898
+ {{ loading ? 'Loading...' : 'Load More' }}
899
+ </button>
900
+ </div>
901
+ </div>
902
+
903
+ <!-- Enlarged image lightbox -->
904
+ <div
905
+ v-if="enlargedEventId && (enlargedImage || showVideo)"
906
+ class="lightbox-overlay"
907
+ @click.self="closeEnlargedImage"
908
+ data-testid="lightbox-overlay"
909
+ >
910
+ <div class="lightbox-content">
911
+ <!-- Header with buttons -->
912
+ <div class="lightbox-header">
913
+ <div class="lightbox-buttons">
914
+ <button
915
+ class="media-button"
916
+ :class="{ active: currentMediaType === 'preview' }"
917
+ @click="showPreview"
918
+ >
919
+ Preview
920
+ </button>
921
+ <button
922
+ class="media-button media-button-hd"
923
+ :class="{ active: currentMediaType === 'hd' }"
924
+ @click="showHdImage"
925
+ >
926
+ HD Image
927
+ </button>
928
+ <button
929
+ class="media-button media-button-video"
930
+ :class="{ active: currentMediaType === 'video' }"
931
+ @click="showVideoPlayer"
932
+ >
933
+ Video
934
+ </button>
935
+ </div>
936
+ <button
937
+ class="lightbox-close"
938
+ @click="closeEnlargedImage"
939
+ aria-label="Close enlarged image"
940
+ data-testid="lightbox-close"
941
+ >&times;</button>
942
+ </div>
943
+
944
+ <!-- Video mode -->
945
+ <template v-if="showVideo">
946
+ <div v-if="loadingVideo" class="lightbox-loading">Loading video...</div>
947
+ <div v-else-if="videoError" class="lightbox-error">{{ videoError }}</div>
948
+ <video
949
+ v-else-if="videoUrl"
950
+ :ref="(el) => hlsPlayer.videoRef.value = el as HTMLVideoElement | null"
951
+ class="lightbox-video"
952
+ controls
953
+ autoplay
954
+ muted
955
+ playsinline
956
+ />
957
+ </template>
958
+ ```
959
+
960
+ ### MetricsChart.vue
961
+
962
+ ```vue
963
+ <script setup lang="ts">
964
+ import { ref, watch, computed } from 'vue'
965
+ import { Line } from 'vue-chartjs'
966
+ import {
967
+ Chart as ChartJS,
968
+ CategoryScale,
969
+ LinearScale,
970
+ PointElement,
971
+ LineElement,
972
+ Title,
973
+ Tooltip,
974
+ Legend,
975
+ TimeScale
976
+ } from 'chart.js'
977
+ import { getEventMetrics, listEventFieldValues, type Camera, type EventMetric, type EenError } from 'een-api-toolkit'
978
+
979
+ ChartJS.register(
980
+ CategoryScale,
981
+ LinearScale,
982
+ PointElement,
983
+ LineElement,
984
+ Title,
985
+ Tooltip,
986
+ Legend,
987
+ TimeScale
988
+ )
989
+
990
+ const props = defineProps<{
991
+ camera: Camera
992
+ timeRange: string
993
+ aggregateMinutes?: number
994
+ }>()
995
+
996
+ interface DataPoint {
997
+ timestamp: number
998
+ count: number
999
+ }
1000
+
1001
+ const dataPoints = ref<DataPoint[]>([])
1002
+ const eventTypes = ref<string[]>([])
1003
+ const selectedEventType = ref<string>('')
1004
+ const loadingEventTypes = ref(false)
1005
+ const loadingMetrics = ref(false)
1006
+ const error = ref<EenError | null>(null)
1007
+
1008
+ function getTimeRangeMs(range: string): number {
1009
+ switch (range) {
1010
+ case '1h': return 60 * 60 * 1000
1011
+ case '6h': return 6 * 60 * 60 * 1000
1012
+ case '24h': return 24 * 60 * 60 * 1000
1013
+ case '7d': return 7 * 24 * 60 * 60 * 1000
1014
+ case 'none': return 7 * 24 * 60 * 60 * 1000 // Default to 7 days when "none"
1015
+ default: return 7 * 24 * 60 * 60 * 1000
1016
+ }
1017
+ }
1018
+
1019
+ function getAggregationMinutes(range: string): number {
1020
+ // Note: EEN API requires minimum 60 minute aggregation
1021
+ switch (range) {
1022
+ case '1h': return 60 // 1 hour bucket (1 data point)
1023
+ case '6h': return 60 // 1 hour buckets (6 data points)
1024
+ case '24h': return 60 // 1 hour buckets (24 data points)
1025
+ case '7d': return 360 // 6 hour buckets (28 data points)
1026
+ case 'none': return 360 // 6 hour buckets for 7 days default
1027
+ default: return 360
1028
+ }
1029
+ }
1030
+
1031
+ function formatEventType(eventType: string): string {
1032
+ // Convert "een.motionDetectionEvent.v1" to "Motion Detection"
1033
+ const parts = eventType.split('.')
1034
+ if (parts.length >= 2) {
1035
+ const name = parts[1]
1036
+ .replace(/Event$/, '')
1037
+ .replace(/([A-Z])/g, ' $1')
1038
+ .trim()
1039
+ return name.charAt(0).toUpperCase() + name.slice(1)
1040
+ }
1041
+ return eventType
1042
+ }
1043
+
1044
+ async function fetchEventTypes() {
1045
+ if (!props.camera?.id) return
1046
+
1047
+ loadingEventTypes.value = true
1048
+ error.value = null
1049
+ eventTypes.value = []
1050
+ selectedEventType.value = ''
1051
+ dataPoints.value = []
1052
+
1053
+ const result = await listEventFieldValues({
1054
+ actor: `camera:${props.camera.id}`
1055
+ })
1056
+
1057
+ if (result.error) {
1058
+ error.value = result.error
1059
+ loadingEventTypes.value = false
1060
+ return
1061
+ }
1062
+
1063
+ eventTypes.value = result.data?.type ?? []
1064
+ // Auto-select first event type if available
1065
+ if (eventTypes.value.length > 0) {
1066
+ selectedEventType.value = eventTypes.value[0]
1067
+ fetchMetrics()
1068
+ }
1069
+ loadingEventTypes.value = false
1070
+ }
1071
+
1072
+ async function fetchMetrics() {
1073
+ if (!props.camera?.id || !selectedEventType.value) return
1074
+
1075
+ loadingMetrics.value = true
1076
+ error.value = null
1077
+ dataPoints.value = []
1078
+
1079
+ const now = new Date()
1080
+ const rangeMs = getTimeRangeMs(props.timeRange)
1081
+ const startTime = new Date(now.getTime() - rangeMs)
1082
+ // Use provided aggregateMinutes prop, or fall back to auto-calculated value
1083
+ const aggregateByMinutes = props.aggregateMinutes ?? getAggregationMinutes(props.timeRange)
1084
+
1085
+ const result = await getEventMetrics({
1086
+ actor: `camera:${props.camera.id}`,
1087
+ eventType: selectedEventType.value,
1088
+ timestamp__gte: startTime.toISOString(),
1089
+ timestamp__lte: now.toISOString(),
1090
+ aggregateByMinutes
1091
+ })
1092
+
1093
+ if (result.error) {
1094
+ error.value = result.error
1095
+ loadingMetrics.value = false
1096
+ return
1097
+ }
1098
+
1099
+ // Transform API data to chart data points
1100
+ const metrics = result.data ?? []
1101
+ const nowMs = now.getTime()
1102
+ if (metrics.length > 0) {
1103
+ // Use the first metric's data points (for count target)
1104
+ const metric = metrics.find((m: EventMetric) => m.target === 'count') ?? metrics[0]
1105
+ // Defensive check for dataPoints array
1106
+ if (metric.dataPoints && Array.isArray(metric.dataPoints)) {
1107
+ // Filter out future data points
1108
+ dataPoints.value = metric.dataPoints
1109
+ .filter(([timestamp]) => timestamp <= nowMs)
1110
+ .map(([timestamp, count]) => ({
1111
+ timestamp,
1112
+ count
1113
+ }))
1114
+ }
1115
+ }
1116
+
1117
+ loadingMetrics.value = false
1118
+ }
1119
+
1120
+ function handleEventTypeChange() {
1121
+ if (selectedEventType.value) {
1122
+ fetchMetrics()
1123
+ }
1124
+ }
1125
+
1126
+ const chartData = computed(() => {
1127
+ if (dataPoints.value.length === 0) {
1128
+ return {
1129
+ labels: [],
1130
+ datasets: []
1131
+ }
1132
+ }
1133
+
1134
+ return {
1135
+ labels: dataPoints.value.map(({ timestamp }) => {
1136
+ const date = new Date(timestamp)
1137
+ // Use time-only format for short ranges, date+time for longer ranges (including 'none' which defaults to 7 days)
1138
+ if (props.timeRange === '1h' || props.timeRange === '6h') {
1139
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
1140
+ }
1141
+ return date.toLocaleDateString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
1142
+ }),
1143
+ datasets: [
1144
+ {
1145
+ label: formatEventType(selectedEventType.value),
1146
+ data: dataPoints.value.map(({ count }) => count),
1147
+ borderColor: '#42b883',
1148
+ backgroundColor: 'rgba(66, 184, 131, 0.1)',
1149
+ tension: 0.3,
1150
+ fill: true
1151
+ }
1152
+ ]
1153
+ }
1154
+ })
1155
+
1156
+ const chartOptions = {
1157
+ responsive: true,
1158
+ maintainAspectRatio: false,
1159
+ plugins: {
1160
+ legend: {
1161
+ display: true,
1162
+ position: 'top' as const
1163
+ },
1164
+ tooltip: {
1165
+ mode: 'index' as const,
1166
+ intersect: false
1167
+ }
1168
+ },
1169
+ scales: {
1170
+ y: {
1171
+ beginAtZero: true,
1172
+ title: {
1173
+ display: true,
1174
+ text: 'Event Count'
1175
+ }
1176
+ },
1177
+ x: {
1178
+ title: {
1179
+ display: true,
1180
+ text: 'Time'
1181
+ }
1182
+ }
1183
+ }
1184
+ }
1185
+
1186
+ // Fetch event types when camera changes
1187
+ watch(
1188
+ () => props.camera?.id,
1189
+ () => {
1190
+ fetchEventTypes()
1191
+ },
1192
+ { immediate: true }
1193
+ )
1194
+
1195
+ // Fetch metrics when time range or aggregate changes (if event type is selected)
1196
+ watch(
1197
+ () => [props.timeRange, props.aggregateMinutes],
1198
+ () => {
1199
+ if (selectedEventType.value) {
1200
+ fetchMetrics()
1201
+ }
1202
+ }
1203
+ )
1204
+ </script>
1205
+
1206
+ <template>
1207
+ <div class="metrics-chart" data-testid="metrics-chart">
1208
+ <div class="event-type-selector" data-testid="event-type-selector">
1209
+ <label for="event-type-select">Event Type:</label>
1210
+ <select
1211
+ id="event-type-select"
1212
+ v-model="selectedEventType"
1213
+ @change="handleEventTypeChange"
1214
+ :disabled="loadingEventTypes || eventTypes.length === 0"
1215
+ data-testid="event-type-select"
1216
+ >
1217
+ <option value="" disabled>
1218
+ {{ loadingEventTypes ? 'Loading event types...' : (eventTypes.length === 0 ? 'No event types available' : 'Select an event type') }}
1219
+ </option>
1220
+ <option
1221
+ v-for="et in eventTypes"
1222
+ :key="et"
1223
+ :value="et"
1224
+ data-testid="event-type-option"
1225
+ >
1226
+ {{ formatEventType(et) }} ({{ et }})
1227
+ </option>
1228
+ </select>
1229
+ </div>
1230
+
1231
+ <div v-if="loadingMetrics" class="loading" data-testid="metrics-loading">
1232
+ Loading metrics...
1233
+ </div>
1234
+ <div v-else-if="error" class="error" data-testid="metrics-error">
1235
+ {{ error.message }}
1236
+ </div>
1237
+ <div v-else-if="!selectedEventType" class="no-data" data-testid="metrics-no-selection">
1238
+ Please select an event type to view metrics.
1239
+ </div>
1240
+ <div v-else-if="chartData.datasets.length === 0" class="no-data" data-testid="metrics-no-data">
1241
+ No event data available for this time range.
1242
+ </div>
1243
+ <div v-else class="chart-container">
1244
+ <Line :data="chartData" :options="chartOptions" />
1245
+ </div>
1246
+ </div>
1247
+ </template>
1248
+ ```
1249
+
1250
+ ### LiveEvents.vue
1251
+
1252
+ ```vue
1253
+ <script setup lang="ts">
1254
+ import { ref, computed, onMounted, watch } from 'vue'
1255
+ import { useRoute } from 'vue-router'
1256
+ import {
1257
+ listEventSubscriptions,
1258
+ getRecordedImage,
1259
+ type EventSubscription,
1260
+ type SSEEvent
1261
+ } from 'een-api-toolkit'
1262
+ import { useConnectionStore } from '../stores/connection'
1263
+ import { useHlsPlayer } from '../composables/useHlsPlayer'
1264
+
1265
+ const route = useRoute()
1266
+ const connectionStore = useConnectionStore()
1267
+
1268
+ // Initialize HLS player composable
1269
+ const hlsPlayer = useHlsPlayer()
1270
+ const { videoUrl, videoError, loadingVideo, loadVideo, resetVideo } = hlsPlayer
1271
+
1272
+ // Subscriptions state
1273
+ const subscriptions = ref<EventSubscription[]>([])
1274
+ const selectedSubscriptionId = ref<string | null>(null)
1275
+ const loadingSubscriptions = ref(false)
1276
+
1277
+ // Modal state
1278
+ const selectedEvent = ref<SSEEvent | null>(null)
1279
+ const showModal = ref(false)
1280
+
1281
+ // Lightbox state
1282
+ const showLightbox = ref(false)
1283
+ const lightboxImageUrl = ref<string | null>(null)
1284
+ const loadingImage = ref(false)
1285
+ const imageError = ref<string | null>(null)
1286
+ const showVideo = ref(false)
1287
+
1288
+ const selectedSubscription = computed(() => {
1289
+ return subscriptions.value.find(s => s.id === selectedSubscriptionId.value)
1290
+ })
1291
+
1292
+ const canConnect = computed(() => {
1293
+ if (!selectedSubscription.value) return false
1294
+ if (selectedSubscription.value.deliveryConfig.type !== 'serverSentEvents.v1') return false
1295
+ return !!selectedSubscription.value.deliveryConfig.sseUrl
1296
+ })
1297
+
1298
+ // Use store computed values
1299
+ const isConnected = computed(() => connectionStore.isConnected)
1300
+ const isConnecting = computed(() => connectionStore.isConnecting)
1301
+ const connectionStatus = computed(() => connectionStore.connectionStatus)
1302
+ const connectionError = computed(() => connectionStore.connectionError)
1303
+ const events = computed(() => connectionStore.events)
1304
+ const maxEvents = connectionStore.maxEvents
1305
+
1306
+ async function loadSubscriptions() {
1307
+ loadingSubscriptions.value = true
1308
+ const result = await listEventSubscriptions({ pageSize: 100 })
1309
+
1310
+ if (!result.error) {
1311
+ // Filter to only SSE subscriptions
1312
+ subscriptions.value = result.data.results.filter(
1313
+ s => s.deliveryConfig.type === 'serverSentEvents.v1'
1314
+ )
1315
+ }
1316
+
1317
+ loadingSubscriptions.value = false
1318
+ }
1319
+
1320
+ async function connect() {
1321
+ if (!canConnect.value || !selectedSubscription.value) return
1322
+
1323
+ const subscriptionId = selectedSubscription.value.id
1324
+
1325
+ // Reload all subscriptions to get fresh SSE URLs
1326
+ // SSE URLs become invalid after disconnecting
1327
+ await loadSubscriptions()
1328
+
1329
+ // Find the subscription in the refreshed list
1330
+ const freshSubscription = subscriptions.value.find(s => s.id === subscriptionId)
1331
+ if (!freshSubscription) {
1332
+ connectionStore.setConnectionError({ code: 'NOT_FOUND', message: 'Subscription no longer exists' })
1333
+ return
1334
+ }
1335
+
1336
+ if (freshSubscription.deliveryConfig.type !== 'serverSentEvents.v1') {
1337
+ connectionStore.setConnectionError({ code: 'VALIDATION_ERROR', message: 'Subscription is not an SSE type' })
1338
+ return
1339
+ }
1340
+
1341
+ const sseUrl = freshSubscription.deliveryConfig.sseUrl
1342
+ if (!sseUrl) {
1343
+ connectionStore.setConnectionError({ code: 'VALIDATION_ERROR', message: 'No SSE URL available' })
1344
+ return
1345
+ }
1346
+
1347
+ connectionStore.connect(subscriptionId, sseUrl)
1348
+ }
1349
+
1350
+ function disconnect() {
1351
+ // Get the subscription ID before disconnecting
1352
+ const disconnectedId = connectionStore.connectedSubscriptionId
1353
+
1354
+ connectionStore.disconnect()
1355
+
1356
+ // Remove the subscription from the list since SSE URLs are single-use
1357
+ // and cannot be reconnected
1358
+ if (disconnectedId) {
1359
+ subscriptions.value = subscriptions.value.filter(s => s.id !== disconnectedId)
1360
+ selectedSubscriptionId.value = null
1361
+ }
1362
+ }
1363
+
1364
+ function clearEvents() {
1365
+ connectionStore.clearEvents()
1366
+ }
1367
+
1368
+ function formatTimestamp(timestamp: string): string {
1369
+ return new Date(timestamp).toLocaleString()
1370
+ }
1371
+
1372
+ function formatEventType(type: string): string {
1373
+ // Extract the short name from the full event type ID
1374
+ // e.g., "een.motionDetectionEvent.v1" -> "Motion Detection"
1375
+ const parts = type.split('.')
1376
+ if (parts.length >= 2) {
1377
+ const name = parts[1]
1378
+ .replace('Event', '')
1379
+ .replace(/([A-Z])/g, ' $1')
1380
+ .trim()
1381
+ return name.charAt(0).toUpperCase() + name.slice(1)
1382
+ }
1383
+ return type
1384
+ }
1385
+
1386
+ function openEventModal(event: SSEEvent) {
1387
+ selectedEvent.value = event
1388
+ showModal.value = true
1389
+ // Add keyboard listener for Escape key
1390
+ document.addEventListener('keydown', handleKeyDown)
1391
+ }
1392
+
1393
+ function closeModal() {
1394
+ showModal.value = false
1395
+ selectedEvent.value = null
1396
+ // Remove keyboard listener
1397
+ document.removeEventListener('keydown', handleKeyDown)
1398
+ }
1399
+
1400
+ function handleKeyDown(e: KeyboardEvent) {
1401
+ if (e.key === 'Escape') {
1402
+ if (showLightbox.value) {
1403
+ closeLightbox()
1404
+ } else if (showModal.value) {
1405
+ closeModal()
1406
+ }
1407
+ }
1408
+ }
1409
+
1410
+ function handleModalBackdropClick(e: MouseEvent) {
1411
+ if ((e.target as HTMLElement).classList.contains('modal-backdrop')) {
1412
+ closeModal()
1413
+ }
1414
+ }
1415
+
1416
+ // Extract the image URL from event data
1417
+ function getEventImageUrl(event: SSEEvent): string | null {
1418
+ if (!event.data) return null
1419
+ const fullFrameData = event.data.find(d => d.type === 'een.fullFrameImageUrl.v1')
1420
+ if (fullFrameData && typeof fullFrameData.httpsUrl === 'string') {
1421
+ return fullFrameData.httpsUrl
1422
+ }
1423
+ return null
1424
+ }
1425
+
1426
+ // Check if event has media URLs
1427
+ function hasMediaUrls(event: SSEEvent): boolean {
1428
+ return getEventImageUrl(event) !== null
1429
+ }
1430
+
1431
+ // Handle image click (preview or HD)
1432
+ // Uses event.actorId and event.startTimestamp directly instead of parsing URL
1433
+ async function handleImageClick(quality: 'preview' | 'main' = 'preview') {
1434
+ if (!selectedEvent.value) return
1435
+
1436
+ // Use event properties directly for reliability
1437
+ const deviceId = selectedEvent.value.actorId
1438
+ const timestamp = selectedEvent.value.startTimestamp
1439
+
1440
+ if (!deviceId || !timestamp) {
1441
+ return
1442
+ }
1443
+
1444
+ loadingImage.value = true
1445
+ imageError.value = null
1446
+ lightboxImageUrl.value = null
1447
+ showLightbox.value = true
1448
+ showVideo.value = false
1449
+
1450
+ // Use the toolkit's getRecordedImage function
1451
+ const result = await getRecordedImage({
1452
+ deviceId,
1453
+ type: quality,
1454
+ timestamp__gte: timestamp
1455
+ })
1456
+
1457
+ if (result.error) {
1458
+ imageError.value = result.error.message
1459
+ } else if (result.data?.imageData) {
1460
+ lightboxImageUrl.value = result.data.imageData
1461
+ } else {
1462
+ imageError.value = 'No image data returned'
1463
+ }
1464
+
1465
+ loadingImage.value = false
1466
+ }
1467
+
1468
+ // Handle video click
1469
+ // Uses event.actorId and event.startTimestamp directly instead of parsing URL
1470
+ async function handleVideoClick() {
1471
+ if (!selectedEvent.value) return
1472
+
1473
+ // Use event properties directly for reliability
1474
+ const deviceId = selectedEvent.value.actorId
1475
+ const timestamp = selectedEvent.value.startTimestamp
1476
+
1477
+ if (!deviceId || !timestamp) {
1478
+ return
1479
+ }
1480
+
1481
+ showVideo.value = true
1482
+ showLightbox.value = true
1483
+
1484
+ // Use the composable to load and play video
1485
+ await loadVideo(deviceId, timestamp)
1486
+ }
1487
+
1488
+ // Close lightbox
1489
+ function closeLightbox() {
1490
+ showLightbox.value = false
1491
+ lightboxImageUrl.value = null
1492
+ imageError.value = null
1493
+ // Cleanup video if it was playing
1494
+ resetVideo()
1495
+ showVideo.value = false
1496
+ }
1497
+
1498
+ // Load subscriptions on mount
1499
+ onMounted(async () => {
1500
+ await loadSubscriptions()
1501
+
1502
+ // If already connected, set the selected subscription to match
1503
+ if (connectionStore.connectedSubscriptionId) {
1504
+ selectedSubscriptionId.value = connectionStore.connectedSubscriptionId
1505
+ } else {
1506
+ // Check for subscriptionId in query params
1507
+ const queryId = route.query.subscriptionId as string | undefined
1508
+ if (queryId) {
1509
+ selectedSubscriptionId.value = queryId
1510
+ }
1511
+ }
1512
+ })
1513
+
1514
+ // Auto-disconnect when changing subscription (but not if selecting the already connected one)
1515
+ watch(selectedSubscriptionId, (newId) => {
1516
+ if (newId && newId !== connectionStore.connectedSubscriptionId && connectionStore.isConnected) {
1517
+ disconnect()
1518
+ }
1519
+ })
1520
+ </script>
1521
+
1522
+ <template>
1523
+ <div class="live-events">
1524
+ <h2>Live Events</h2>
1525
+
1526
+ <!-- Subscription Selector -->
1527
+ <div class="selector-section">
1528
+ <label>Select Subscription:</label>
1529
+ <select
1530
+ v-model="selectedSubscriptionId"
1531
+ :disabled="loadingSubscriptions || isConnected"
1532
+ data-testid="subscription-select"
1533
+ >
1534
+ <option :value="null">-- Select a subscription --</option>
1535
+ <option v-for="sub in subscriptions" :key="sub.id" :value="sub.id">
1536
+ {{ sub.id.slice(0, 16) }}... ({{ sub.subscriptionConfig?.lifeCycle || 'unknown' }})
1537
+ </option>
1538
+ </select>
1539
+
1540
+ <div class="connection-controls">
1541
+ <button
1542
+ v-if="!isConnected"
1543
+ @click="connect"
1544
+ :disabled="!canConnect || isConnecting"
1545
+ data-testid="connect-button"
1546
+ >
1547
+ {{ isConnecting ? 'Connecting...' : 'Connect' }}
1548
+ </button>
1549
+ <button
1550
+ v-else
1551
+ @click="disconnect"
1552
+ class="danger"
1553
+ data-testid="disconnect-button"
1554
+ >
1555
+ Disconnect
1556
+ </button>
1557
+ <button
1558
+ @click="clearEvents"
1559
+ class="secondary"
1560
+ :disabled="events.length === 0"
1561
+ >
1562
+ Clear Events
1563
+ </button>
1564
+ </div>
1565
+ </div>
1566
+
1567
+ <!-- Connection Status -->
1568
+ <div class="status-section">
1569
+ <span class="status-label">Status:</span>
1570
+ <span
1571
+ class="status-indicator"
1572
+ :class="{
1573
+ 'connected': isConnected,
1574
+ 'connecting': isConnecting,
1575
+ 'disconnected': connectionStatus === 'disconnected',
1576
+ 'error': connectionStatus === 'error'
1577
+ }"
1578
+ data-testid="connection-status"
1579
+ >
1580
+ {{ connectionStatus }}
1581
+ </span>
1582
+ </div>
1583
+
1584
+ <div v-if="connectionError" class="error">
1585
+ Connection Error: {{ connectionError.message }}
1586
+ </div>
1587
+
1588
+ <!-- Events List -->
1589
+ <div class="events-section">
1590
+ <h3>Events ({{ events.length }})</h3>
1591
+
1592
+ <div v-if="events.length === 0" class="no-events">
1593
+ <p v-if="isConnected">Waiting for events...</p>
1594
+ <p v-else>Connect to a subscription to start receiving events.</p>
1595
+ </div>
1596
+
1597
+ <div v-else class="events-list" data-testid="events-list">
1598
+ <div
1599
+ v-for="(event, index) in events"
1600
+ :key="`${event.id}-${index}`"
1601
+ class="event-card clickable"
1602
+ @click="openEventModal(event)"
1603
+ >
1604
+ <div class="event-header">
1605
+ <span class="event-type">{{ formatEventType(event.type) }}</span>
1606
+ <span class="event-time">{{ formatTimestamp(event.startTimestamp) }}</span>
1607
+ </div>
1608
+ <div class="event-details">
1609
+ <span class="detail">
1610
+ <strong>Actor:</strong> {{ event.actorId }}
1611
+ </span>
1612
+ <span class="detail">
1613
+ <strong>Type:</strong> {{ event.type }}
1614
+ </span>
1615
+ <span v-if="event.span !== undefined" class="detail">
1616
+ <strong>Span:</strong> {{ event.span ? 'Yes' : 'No' }}
1617
+ </span>
1618
+ </div>
1619
+ </div>
1620
+ </div>
1621
+ </div>
1622
+
1623
+ <div class="help-section">
1624
+ <h4>Tips</h4>
1625
+ <ul>
1626
+ <li>Select a subscription from the dropdown to connect</li>
1627
+ <li>Events will appear in real-time as they occur</li>
1628
+ <li>Maximum {{ maxEvents }} events are displayed (oldest removed first)</li>
1629
+ <li>Click on an event card to view detailed information</li>
1630
+ </ul>
1631
+ <h4>Important</h4>
1632
+ <ul class="warning-list">
1633
+ <li>SSE URLs are single-use. Once disconnected, the subscription cannot be reconnected.</li>
1634
+ <li>To receive events again after disconnecting, create a new subscription.</li>
1635
+ <li>Subscriptions have a 15-minute TTL and expire if not connected.</li>
1636
+ </ul>
1637
+ </div>
1638
+
1639
+ <!-- Event Details Modal -->
1640
+ <div
1641
+ v-if="showModal && selectedEvent"
1642
+ class="modal-backdrop"
1643
+ role="dialog"
1644
+ aria-modal="true"
1645
+ aria-labelledby="modal-title"
1646
+ @click="handleModalBackdropClick"
1647
+ >
1648
+ <div class="modal-content" role="document">
1649
+ <div class="modal-header">
1650
+ <h3 id="modal-title">{{ formatEventType(selectedEvent.type) }}</h3>
1651
+ <div class="modal-header-buttons">
1652
+ <button
1653
+ v-if="hasMediaUrls(selectedEvent)"
1654
+ class="image-button"
1655
+ @click="handleImageClick('preview')"
1656
+ >
1657
+ Preview
1658
+ </button>
1659
+ <button
1660
+ v-if="hasMediaUrls(selectedEvent)"
1661
+ class="image-button image-button-hd"
1662
+ @click="handleImageClick('main')"
1663
+ >
1664
+ HD Image
1665
+ </button>
1666
+ <button
1667
+ v-if="hasMediaUrls(selectedEvent)"
1668
+ class="image-button image-button-video"
1669
+ @click="handleVideoClick"
1670
+ >
1671
+ Video
1672
+ </button>
1673
+ <button class="close-button" @click="closeModal" aria-label="Close modal">&times;</button>
1674
+ </div>
1675
+ </div>
1676
+ <div class="modal-body">
1677
+ <div class="modal-section">
1678
+ <h4>Event Information</h4>
1679
+ <div class="modal-field">
1680
+ <strong>Event ID:</strong>
1681
+ <span class="mono">{{ selectedEvent.id }}</span>
1682
+ </div>
1683
+ <div class="modal-field">
1684
+ <strong>Type:</strong>
1685
+ <span class="mono">{{ selectedEvent.type }}</span>
1686
+ </div>
1687
+ <div class="modal-field">
1688
+ <strong>Actor:</strong>
1689
+ <span class="mono">{{ selectedEvent.actorId }}</span>
1690
+ </div>
1691
+ <div class="modal-field">
1692
+ <strong>Start Time:</strong>
1693
+ <span>{{ formatTimestamp(selectedEvent.startTimestamp) }}</span>
1694
+ </div>
1695
+ <div v-if="selectedEvent.endTimestamp" class="modal-field">
1696
+ <strong>End Time:</strong>
1697
+ <span>{{ formatTimestamp(selectedEvent.endTimestamp) }}</span>
1698
+ </div>
1699
+ <div v-if="selectedEvent.span !== undefined" class="modal-field">
1700
+ <strong>Span Event:</strong>
1701
+ <span>{{ selectedEvent.span ? 'Yes' : 'No' }}</span>
1702
+ </div>
1703
+ </div>
1704
+
1705
+ <div v-if="selectedEvent.data && selectedEvent.data.length > 0" class="modal-section">
1706
+ <h4>Event Data ({{ selectedEvent.data.length }} items)</h4>
1707
+ <pre class="modal-json">{{ JSON.stringify(selectedEvent.data, null, 2) }}</pre>
1708
+ </div>
1709
+
1710
+ <div v-else class="modal-section">
1711
+ <h4>Event Data</h4>
1712
+ <p class="no-data-text">No additional data available for this event.</p>
1713
+ </div>
1714
+ </div>
1715
+ </div>
1716
+
1717
+ </div>
1718
+
1719
+ <!-- Image/Video Lightbox -->
1720
+ <div v-if="showLightbox" class="lightbox-overlay" @click.self="closeLightbox">
1721
+ <div class="lightbox-content">
1722
+ <button class="lightbox-close" @click="closeLightbox">&times;</button>
1723
+ <!-- Video mode -->
1724
+ <template v-if="showVideo">
1725
+ <div v-if="loadingVideo" class="lightbox-loading">Loading video...</div>
1726
+ <div v-else-if="videoError" class="lightbox-error">{{ videoError }}</div>
1727
+ <video
1728
+ v-else-if="videoUrl"
1729
+ :ref="(el) => hlsPlayer.videoRef.value = el as HTMLVideoElement | null"
1730
+ class="lightbox-video"
1731
+ controls
1732
+ autoplay
1733
+ muted
1734
+ playsinline
1735
+ />
1736
+ </template>
1737
+ ```
1738
+
1739
+ ---
1740
+
1741
+ ## Reference Examples
1742
+
1743
+ - `examples/vue-events/` - Event listing with bounding boxes
1744
+ - `examples/vue-alerts-metrics/` - Metrics, alerts, notifications
1745
+ - `examples/vue-event-subscriptions/` - Real-time SSE streaming