Chore: rename api->camera-management-api and web->management-dashboard-web-app; update compose, ignore, README references
This commit is contained in:
286
management-dashboard-web-app/src/lib/autoRecordingManager.ts
Normal file
286
management-dashboard-web-app/src/lib/autoRecordingManager.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Auto-Recording Manager
|
||||
*
|
||||
* This module handles automatic recording start/stop based on MQTT machine state changes.
|
||||
* It monitors MQTT events and triggers camera recording when machines turn on/off.
|
||||
*/
|
||||
|
||||
import { visionApi, type MqttEvent, type CameraConfig } from './visionApi'
|
||||
|
||||
export interface AutoRecordingState {
|
||||
cameraName: string
|
||||
machineState: 'on' | 'off'
|
||||
isRecording: boolean
|
||||
autoRecordEnabled: boolean
|
||||
lastStateChange: Date
|
||||
}
|
||||
|
||||
export class AutoRecordingManager {
|
||||
private cameras: Map<string, AutoRecordingState> = new Map()
|
||||
private mqttPollingInterval: NodeJS.Timeout | null = null
|
||||
private lastProcessedEventNumber = 0
|
||||
private isRunning = false
|
||||
|
||||
constructor(private pollingIntervalMs: number = 2000) {}
|
||||
|
||||
/**
|
||||
* Start the auto-recording manager
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.isRunning) {
|
||||
console.warn('Auto-recording manager is already running')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Starting auto-recording manager...')
|
||||
this.isRunning = true
|
||||
|
||||
// Initialize camera configurations
|
||||
await this.initializeCameras()
|
||||
|
||||
// Start polling for MQTT events
|
||||
this.startMqttPolling()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the auto-recording manager
|
||||
*/
|
||||
stop(): void {
|
||||
if (!this.isRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Stopping auto-recording manager...')
|
||||
this.isRunning = false
|
||||
|
||||
if (this.mqttPollingInterval) {
|
||||
clearInterval(this.mqttPollingInterval)
|
||||
this.mqttPollingInterval = null
|
||||
}
|
||||
|
||||
this.cameras.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize camera configurations and states
|
||||
*/
|
||||
private async initializeCameras(): Promise<void> {
|
||||
try {
|
||||
const cameras = await visionApi.getCameras()
|
||||
|
||||
for (const [cameraName, cameraStatus] of Object.entries(cameras)) {
|
||||
try {
|
||||
const config = await visionApi.getCameraConfig(cameraName)
|
||||
|
||||
this.cameras.set(cameraName, {
|
||||
cameraName,
|
||||
machineState: 'off', // Default to off
|
||||
isRecording: cameraStatus.is_recording,
|
||||
autoRecordEnabled: config.auto_record_on_machine_start,
|
||||
lastStateChange: new Date()
|
||||
})
|
||||
|
||||
console.log(`Initialized camera ${cameraName}: auto-record=${config.auto_record_on_machine_start}, machine=${config.machine_topic}`)
|
||||
} catch (error) {
|
||||
console.error(`Failed to initialize camera ${cameraName}:`, error)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize cameras:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for MQTT events
|
||||
*/
|
||||
private startMqttPolling(): void {
|
||||
this.mqttPollingInterval = setInterval(async () => {
|
||||
if (!this.isRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await this.processMqttEvents()
|
||||
} catch (error) {
|
||||
console.error('Error processing MQTT events:', error)
|
||||
}
|
||||
}, this.pollingIntervalMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process new MQTT events and trigger recording actions
|
||||
*/
|
||||
private async processMqttEvents(): Promise<void> {
|
||||
try {
|
||||
const mqttResponse = await visionApi.getMqttEvents(50) // Get recent events
|
||||
|
||||
// Filter for new events we haven't processed yet
|
||||
const newEvents = mqttResponse.events.filter(
|
||||
event => event.message_number > this.lastProcessedEventNumber
|
||||
)
|
||||
|
||||
if (newEvents.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Update last processed event number
|
||||
this.lastProcessedEventNumber = Math.max(
|
||||
...newEvents.map(event => event.message_number)
|
||||
)
|
||||
|
||||
// Process each new event
|
||||
for (const event of newEvents) {
|
||||
await this.handleMqttEvent(event)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch MQTT events:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a single MQTT event and trigger recording if needed
|
||||
*/
|
||||
private async handleMqttEvent(event: MqttEvent): Promise<void> {
|
||||
const { machine_name, normalized_state } = event
|
||||
|
||||
// Find cameras that are configured for this machine
|
||||
const affectedCameras = await this.getCamerasForMachine(machine_name)
|
||||
|
||||
for (const cameraName of affectedCameras) {
|
||||
const cameraState = this.cameras.get(cameraName)
|
||||
|
||||
if (!cameraState || !cameraState.autoRecordEnabled) {
|
||||
continue
|
||||
}
|
||||
|
||||
const newMachineState = normalized_state as 'on' | 'off'
|
||||
|
||||
// Skip if state hasn't changed
|
||||
if (cameraState.machineState === newMachineState) {
|
||||
continue
|
||||
}
|
||||
|
||||
console.log(`Machine ${machine_name} changed from ${cameraState.machineState} to ${newMachineState} - Camera: ${cameraName}`)
|
||||
|
||||
// Update camera state
|
||||
cameraState.machineState = newMachineState
|
||||
cameraState.lastStateChange = new Date()
|
||||
|
||||
// Trigger recording action
|
||||
if (newMachineState === 'on' && !cameraState.isRecording) {
|
||||
await this.startAutoRecording(cameraName, machine_name)
|
||||
} else if (newMachineState === 'off' && cameraState.isRecording) {
|
||||
await this.stopAutoRecording(cameraName, machine_name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cameras that are configured for a specific machine
|
||||
*/
|
||||
private async getCamerasForMachine(machineName: string): Promise<string[]> {
|
||||
const cameras: string[] = []
|
||||
|
||||
// Define the correct machine-to-camera mapping
|
||||
const machineToCamera: Record<string, string> = {
|
||||
'blower_separator': 'camera1', // camera1 is for blower separator
|
||||
'vibratory_conveyor': 'camera2' // camera2 is for conveyor
|
||||
}
|
||||
|
||||
const expectedCamera = machineToCamera[machineName]
|
||||
if (!expectedCamera) {
|
||||
console.warn(`No camera mapping found for machine: ${machineName}`)
|
||||
return cameras
|
||||
}
|
||||
|
||||
try {
|
||||
const allCameras = await visionApi.getCameras()
|
||||
|
||||
// Check if the expected camera exists and has auto-recording enabled
|
||||
if (allCameras[expectedCamera]) {
|
||||
try {
|
||||
const config = await visionApi.getCameraConfig(expectedCamera)
|
||||
|
||||
if (config.auto_record_on_machine_start) {
|
||||
cameras.push(expectedCamera)
|
||||
console.log(`Found camera ${expectedCamera} configured for machine ${machineName}`)
|
||||
} else {
|
||||
console.log(`Camera ${expectedCamera} exists but auto-recording is disabled`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to get config for camera ${expectedCamera}:`, error)
|
||||
}
|
||||
} else {
|
||||
console.warn(`Expected camera ${expectedCamera} not found for machine ${machineName}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get cameras for machine:', error)
|
||||
}
|
||||
|
||||
return cameras
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-recording for a camera
|
||||
*/
|
||||
private async startAutoRecording(cameraName: string, machineName: string): Promise<void> {
|
||||
try {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `auto_${machineName}_${timestamp}.mp4`
|
||||
|
||||
const result = await visionApi.startRecording(cameraName, { filename })
|
||||
|
||||
if (result.success) {
|
||||
const cameraState = this.cameras.get(cameraName)
|
||||
if (cameraState) {
|
||||
cameraState.isRecording = true
|
||||
}
|
||||
|
||||
console.log(`✅ Auto-recording started for ${cameraName}: ${result.filename}`)
|
||||
} else {
|
||||
console.error(`❌ Failed to start auto-recording for ${cameraName}:`, result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error starting auto-recording for ${cameraName}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop auto-recording for a camera
|
||||
*/
|
||||
private async stopAutoRecording(cameraName: string, machineName: string): Promise<void> {
|
||||
try {
|
||||
const result = await visionApi.stopRecording(cameraName)
|
||||
|
||||
if (result.success) {
|
||||
const cameraState = this.cameras.get(cameraName)
|
||||
if (cameraState) {
|
||||
cameraState.isRecording = false
|
||||
}
|
||||
|
||||
console.log(`⏹️ Auto-recording stopped for ${cameraName} (${result.duration_seconds}s)`)
|
||||
} else {
|
||||
console.error(`❌ Failed to stop auto-recording for ${cameraName}:`, result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error stopping auto-recording for ${cameraName}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current auto-recording states for all cameras
|
||||
*/
|
||||
getStates(): AutoRecordingState[] {
|
||||
return Array.from(this.cameras.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh camera configurations (call when configs are updated)
|
||||
*/
|
||||
async refreshConfigurations(): Promise<void> {
|
||||
await this.initializeCameras()
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
export const autoRecordingManager = new AutoRecordingManager()
|
||||
843
management-dashboard-web-app/src/lib/supabase.ts
Normal file
843
management-dashboard-web-app/src/lib/supabase.ts
Normal file
@@ -0,0 +1,843 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
// Local development configuration
|
||||
const supabaseUrl = 'http://127.0.0.1:54321'
|
||||
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0'
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
|
||||
|
||||
// Database types for TypeScript
|
||||
export type RoleName = 'admin' | 'conductor' | 'analyst' | 'data recorder'
|
||||
export type UserStatus = 'active' | 'disabled'
|
||||
export type ScheduleStatus = 'pending schedule' | 'scheduled' | 'canceled' | 'aborted'
|
||||
export type ResultsStatus = 'valid' | 'invalid'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
roles: RoleName[]
|
||||
status: UserStatus
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: string
|
||||
name: RoleName
|
||||
description: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Experiment {
|
||||
id: string
|
||||
experiment_number: number
|
||||
reps_required: number
|
||||
soaking_duration_hr: number
|
||||
air_drying_time_min: number
|
||||
plate_contact_frequency_hz: number
|
||||
throughput_rate_pecans_sec: number
|
||||
crush_amount_in: number
|
||||
entry_exit_height_diff_in: number
|
||||
results_status: ResultsStatus
|
||||
completion_status: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface CreateExperimentRequest {
|
||||
experiment_number: number
|
||||
reps_required: number
|
||||
soaking_duration_hr: number
|
||||
air_drying_time_min: number
|
||||
plate_contact_frequency_hz: number
|
||||
throughput_rate_pecans_sec: number
|
||||
crush_amount_in: number
|
||||
entry_exit_height_diff_in: number
|
||||
results_status?: ResultsStatus
|
||||
completion_status?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateExperimentRequest {
|
||||
experiment_number?: number
|
||||
reps_required?: number
|
||||
soaking_duration_hr?: number
|
||||
air_drying_time_min?: number
|
||||
plate_contact_frequency_hz?: number
|
||||
throughput_rate_pecans_sec?: number
|
||||
crush_amount_in?: number
|
||||
entry_exit_height_diff_in?: number
|
||||
results_status?: ResultsStatus
|
||||
completion_status?: boolean
|
||||
}
|
||||
|
||||
export interface CreateRepetitionRequest {
|
||||
experiment_id: string
|
||||
repetition_number: number
|
||||
scheduled_date?: string | null
|
||||
schedule_status?: ScheduleStatus
|
||||
}
|
||||
|
||||
export interface UpdateRepetitionRequest {
|
||||
scheduled_date?: string | null
|
||||
schedule_status?: ScheduleStatus
|
||||
completion_status?: boolean
|
||||
}
|
||||
|
||||
// Data Entry System Interfaces
|
||||
export type PhaseDraftStatus = 'draft' | 'submitted' | 'withdrawn'
|
||||
export type ExperimentPhase = 'pre-soaking' | 'air-drying' | 'cracking' | 'shelling'
|
||||
|
||||
export interface ExperimentPhaseDraft {
|
||||
id: string
|
||||
experiment_id: string
|
||||
repetition_id: string
|
||||
user_id: string
|
||||
phase_name: ExperimentPhase
|
||||
status: PhaseDraftStatus
|
||||
draft_name?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
submitted_at?: string | null
|
||||
withdrawn_at?: string | null
|
||||
}
|
||||
|
||||
export interface ExperimentRepetition {
|
||||
id: string
|
||||
experiment_id: string
|
||||
repetition_number: number
|
||||
scheduled_date?: string | null
|
||||
schedule_status: ScheduleStatus
|
||||
completion_status: boolean
|
||||
is_locked: boolean
|
||||
locked_at?: string | null
|
||||
locked_by?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export interface PecanDiameterMeasurement {
|
||||
id: string
|
||||
phase_data_id: string
|
||||
measurement_number: number
|
||||
diameter_in: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ExperimentPhaseData {
|
||||
id: string
|
||||
phase_draft_id: string
|
||||
phase_name: ExperimentPhase
|
||||
|
||||
// Pre-soaking phase
|
||||
batch_initial_weight_lbs?: number | null
|
||||
initial_shell_moisture_pct?: number | null
|
||||
initial_kernel_moisture_pct?: number | null
|
||||
soaking_start_time?: string | null
|
||||
|
||||
// Air-drying phase
|
||||
airdrying_start_time?: string | null
|
||||
post_soak_weight_lbs?: number | null
|
||||
post_soak_kernel_moisture_pct?: number | null
|
||||
post_soak_shell_moisture_pct?: number | null
|
||||
avg_pecan_diameter_in?: number | null
|
||||
|
||||
// Cracking phase
|
||||
cracking_start_time?: string | null
|
||||
|
||||
// Shelling phase
|
||||
shelling_start_time?: string | null
|
||||
bin_1_weight_lbs?: number | null
|
||||
bin_2_weight_lbs?: number | null
|
||||
bin_3_weight_lbs?: number | null
|
||||
discharge_bin_weight_lbs?: number | null
|
||||
bin_1_full_yield_oz?: number | null
|
||||
bin_2_full_yield_oz?: number | null
|
||||
bin_3_full_yield_oz?: number | null
|
||||
bin_1_half_yield_oz?: number | null
|
||||
bin_2_half_yield_oz?: number | null
|
||||
bin_3_half_yield_oz?: number | null
|
||||
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
||||
// Related data
|
||||
diameter_measurements?: PecanDiameterMeasurement[]
|
||||
}
|
||||
|
||||
export interface CreatePhaseDraftRequest {
|
||||
experiment_id: string
|
||||
repetition_id: string
|
||||
phase_name: ExperimentPhase
|
||||
draft_name?: string
|
||||
status?: PhaseDraftStatus
|
||||
}
|
||||
|
||||
export interface UpdatePhaseDraftRequest {
|
||||
draft_name?: string
|
||||
status?: PhaseDraftStatus
|
||||
}
|
||||
|
||||
export interface CreatePhaseDataRequest {
|
||||
data_entry_id: string
|
||||
phase_name: ExperimentPhase
|
||||
[key: string]: any // For phase-specific data fields
|
||||
}
|
||||
|
||||
export interface UpdatePhaseDataRequest {
|
||||
[key: string]: any // For phase-specific data fields
|
||||
}
|
||||
|
||||
export interface UserRole {
|
||||
id: string
|
||||
user_id: string
|
||||
role_id: string
|
||||
assigned_at: string
|
||||
assigned_by?: string
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string
|
||||
email: string
|
||||
status: UserStatus
|
||||
created_at: string
|
||||
updated_at: string
|
||||
role_id?: string // Legacy field, will be deprecated
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
email: string
|
||||
roles: RoleName[]
|
||||
tempPassword?: string
|
||||
}
|
||||
|
||||
export interface CreateUserResponse {
|
||||
user_id: string
|
||||
email: string
|
||||
temp_password: string
|
||||
roles: RoleName[]
|
||||
status: UserStatus
|
||||
}
|
||||
|
||||
// User management utility functions
|
||||
export const userManagement = {
|
||||
// Get all users with their roles
|
||||
async getAllUsers(): Promise<User[]> {
|
||||
const { data: profiles, error: profilesError } = await supabase
|
||||
.from('user_profiles')
|
||||
.select(`
|
||||
id,
|
||||
email,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
`)
|
||||
|
||||
if (profilesError) throw profilesError
|
||||
|
||||
// Get roles for each user
|
||||
const usersWithRoles = await Promise.all(
|
||||
profiles.map(async (profile) => {
|
||||
const { data: userRoles, error: rolesError } = await supabase
|
||||
.from('user_roles')
|
||||
.select(`
|
||||
roles!inner (
|
||||
name
|
||||
)
|
||||
`)
|
||||
.eq('user_id', profile.id)
|
||||
|
||||
if (rolesError) throw rolesError
|
||||
|
||||
return {
|
||||
...profile,
|
||||
roles: userRoles.map(ur => (ur.roles as any).name as RoleName)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return usersWithRoles
|
||||
},
|
||||
|
||||
// Get all available roles
|
||||
async getAllRoles(): Promise<Role[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('roles')
|
||||
.select('*')
|
||||
.order('name')
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Create a new user with roles
|
||||
async createUser(userData: CreateUserRequest): Promise<CreateUserResponse> {
|
||||
const { data, error } = await supabase.rpc('create_user_with_roles', {
|
||||
user_email: userData.email,
|
||||
role_names: userData.roles,
|
||||
temp_password: userData.tempPassword
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Update user status (enable/disable)
|
||||
async updateUserStatus(userId: string, status: UserStatus): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('user_profiles')
|
||||
.update({ status })
|
||||
.eq('id', userId)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
|
||||
// Update user roles
|
||||
async updateUserRoles(userId: string, roleNames: RoleName[]): Promise<void> {
|
||||
// First, remove all existing roles for the user
|
||||
const { error: deleteError } = await supabase
|
||||
.from('user_roles')
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (deleteError) throw deleteError
|
||||
|
||||
// Get role IDs for the new roles
|
||||
const { data: roles, error: rolesError } = await supabase
|
||||
.from('roles')
|
||||
.select('id, name')
|
||||
.in('name', roleNames)
|
||||
|
||||
if (rolesError) throw rolesError
|
||||
|
||||
// Insert new role assignments
|
||||
const roleAssignments = roles.map(role => ({
|
||||
user_id: userId,
|
||||
role_id: role.id
|
||||
}))
|
||||
|
||||
const { error: insertError } = await supabase
|
||||
.from('user_roles')
|
||||
.insert(roleAssignments)
|
||||
|
||||
if (insertError) throw insertError
|
||||
},
|
||||
|
||||
// Update user email
|
||||
async updateUserEmail(userId: string, email: string): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('user_profiles')
|
||||
.update({ email })
|
||||
.eq('id', userId)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
|
||||
// Get current user with roles
|
||||
async getCurrentUser(): Promise<User | null> {
|
||||
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser()
|
||||
|
||||
if (authError || !authUser) return null
|
||||
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from('user_profiles')
|
||||
.select(`
|
||||
id,
|
||||
email,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
`)
|
||||
.eq('id', authUser.id)
|
||||
.single()
|
||||
|
||||
if (profileError) throw profileError
|
||||
|
||||
const { data: userRoles, error: rolesError } = await supabase
|
||||
.from('user_roles')
|
||||
.select(`
|
||||
roles!inner (
|
||||
name
|
||||
)
|
||||
`)
|
||||
.eq('user_id', authUser.id)
|
||||
|
||||
if (rolesError) throw rolesError
|
||||
|
||||
return {
|
||||
...profile,
|
||||
roles: userRoles.map(ur => (ur.roles as any).name as RoleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Experiment management utility functions
|
||||
export const experimentManagement = {
|
||||
// Get all experiments
|
||||
async getAllExperiments(): Promise<Experiment[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiments')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Get experiment by ID
|
||||
async getExperimentById(id: string): Promise<Experiment | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiments')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') return null // Not found
|
||||
throw error
|
||||
}
|
||||
return data
|
||||
},
|
||||
|
||||
// Create a new experiment
|
||||
async createExperiment(experimentData: CreateExperimentRequest): Promise<Experiment> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiments')
|
||||
.insert({
|
||||
...experimentData,
|
||||
created_by: user.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Update an experiment
|
||||
async updateExperiment(id: string, updates: UpdateExperimentRequest): Promise<Experiment> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiments')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Delete an experiment (admin only)
|
||||
async deleteExperiment(id: string): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('experiments')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
|
||||
// Update experiment status
|
||||
async updateExperimentStatus(id: string, scheduleStatus?: ScheduleStatus, resultsStatus?: ResultsStatus): Promise<Experiment> {
|
||||
const updates: Partial<UpdateExperimentRequest> = {}
|
||||
if (scheduleStatus) updates.schedule_status = scheduleStatus
|
||||
if (resultsStatus) updates.results_status = resultsStatus
|
||||
|
||||
return this.updateExperiment(id, updates)
|
||||
},
|
||||
|
||||
// Get experiments by status
|
||||
async getExperimentsByStatus(scheduleStatus?: ScheduleStatus, resultsStatus?: ResultsStatus): Promise<Experiment[]> {
|
||||
let query = supabase.from('experiments').select('*')
|
||||
|
||||
if (scheduleStatus) {
|
||||
query = query.eq('schedule_status', scheduleStatus)
|
||||
}
|
||||
if (resultsStatus) {
|
||||
query = query.eq('results_status', resultsStatus)
|
||||
}
|
||||
|
||||
const { data, error } = await query.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
|
||||
|
||||
// Check if experiment number is unique
|
||||
async isExperimentNumberUnique(experimentNumber: number, excludeId?: string): Promise<boolean> {
|
||||
let query = supabase
|
||||
.from('experiments')
|
||||
.select('id')
|
||||
.eq('experiment_number', experimentNumber)
|
||||
|
||||
if (excludeId) {
|
||||
query = query.neq('id', excludeId)
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
|
||||
if (error) throw error
|
||||
return data.length === 0
|
||||
}
|
||||
}
|
||||
|
||||
// Experiment Repetitions Management
|
||||
export const repetitionManagement = {
|
||||
// Get all repetitions for an experiment
|
||||
async getExperimentRepetitions(experimentId: string): Promise<ExperimentRepetition[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.select('*')
|
||||
.eq('experiment_id', experimentId)
|
||||
.order('repetition_number', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Create a new repetition
|
||||
async createRepetition(repetitionData: CreateRepetitionRequest): Promise<ExperimentRepetition> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.insert({
|
||||
...repetitionData,
|
||||
created_by: user.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Update a repetition
|
||||
async updateRepetition(id: string, updates: UpdateRepetitionRequest): Promise<ExperimentRepetition> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Schedule a repetition
|
||||
async scheduleRepetition(id: string, scheduledDate: string): Promise<ExperimentRepetition> {
|
||||
const updates: UpdateRepetitionRequest = {
|
||||
scheduled_date: scheduledDate,
|
||||
schedule_status: 'scheduled'
|
||||
}
|
||||
|
||||
return this.updateRepetition(id, updates)
|
||||
},
|
||||
|
||||
// Remove repetition schedule
|
||||
async removeRepetitionSchedule(id: string): Promise<ExperimentRepetition> {
|
||||
const updates: UpdateRepetitionRequest = {
|
||||
scheduled_date: null,
|
||||
schedule_status: 'pending schedule'
|
||||
}
|
||||
|
||||
return this.updateRepetition(id, updates)
|
||||
},
|
||||
|
||||
// Delete a repetition
|
||||
async deleteRepetition(id: string): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
|
||||
// Get repetitions by status
|
||||
async getRepetitionsByStatus(scheduleStatus?: ScheduleStatus): Promise<ExperimentRepetition[]> {
|
||||
let query = supabase.from('experiment_repetitions').select('*')
|
||||
|
||||
if (scheduleStatus) {
|
||||
query = query.eq('schedule_status', scheduleStatus)
|
||||
}
|
||||
|
||||
const { data, error } = await query.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Get repetitions with experiment details
|
||||
async getRepetitionsWithExperiments(): Promise<(ExperimentRepetition & { experiment: Experiment })[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.select(`
|
||||
*,
|
||||
experiment:experiments(*)
|
||||
`)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Create all repetitions for an experiment
|
||||
async createAllRepetitions(experimentId: string): Promise<ExperimentRepetition[]> {
|
||||
// First get the experiment to know how many reps are required
|
||||
const { data: experiment, error: expError } = await supabase
|
||||
.from('experiments')
|
||||
.select('reps_required')
|
||||
.eq('id', experimentId)
|
||||
.single()
|
||||
|
||||
if (expError) throw expError
|
||||
|
||||
// Create repetitions for each required rep
|
||||
const repetitions: CreateRepetitionRequest[] = []
|
||||
for (let i = 1; i <= experiment.reps_required; i++) {
|
||||
repetitions.push({
|
||||
experiment_id: experimentId,
|
||||
repetition_number: i,
|
||||
schedule_status: 'pending schedule'
|
||||
})
|
||||
}
|
||||
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.insert(repetitions.map(rep => ({
|
||||
...rep,
|
||||
created_by: user.id
|
||||
})))
|
||||
.select()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Lock a repetition (admin only)
|
||||
async lockRepetition(repetitionId: string): Promise<ExperimentRepetition> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.update({
|
||||
is_locked: true,
|
||||
locked_at: new Date().toISOString(),
|
||||
locked_by: user.id
|
||||
})
|
||||
.eq('id', repetitionId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Unlock a repetition (admin only)
|
||||
async unlockRepetition(repetitionId: string): Promise<ExperimentRepetition> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.update({
|
||||
is_locked: false,
|
||||
locked_at: null,
|
||||
locked_by: null
|
||||
})
|
||||
.eq('id', repetitionId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
// Phase Draft Management
|
||||
export const phaseDraftManagement = {
|
||||
// Get all phase drafts for a repetition
|
||||
async getPhaseDraftsForRepetition(repetitionId: string): Promise<ExperimentPhaseDraft[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_drafts')
|
||||
.select('*')
|
||||
.eq('repetition_id', repetitionId)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Get user's phase drafts for a repetition
|
||||
async getUserPhaseDraftsForRepetition(repetitionId: string): Promise<ExperimentPhaseDraft[]> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_drafts')
|
||||
.select('*')
|
||||
.eq('repetition_id', repetitionId)
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Get user's phase drafts for a specific phase and repetition
|
||||
async getUserPhaseDraftsForPhase(repetitionId: string, phase: ExperimentPhase): Promise<ExperimentPhaseDraft[]> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_drafts')
|
||||
.select('*')
|
||||
.eq('repetition_id', repetitionId)
|
||||
.eq('user_id', user.id)
|
||||
.eq('phase_name', phase)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Create a new phase draft
|
||||
async createPhaseDraft(request: CreatePhaseDraftRequest): Promise<ExperimentPhaseDraft> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_drafts')
|
||||
.insert({
|
||||
...request,
|
||||
user_id: user.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Update a phase draft
|
||||
async updatePhaseDraft(id: string, updates: UpdatePhaseDraftRequest): Promise<ExperimentPhaseDraft> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_drafts')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Delete a phase draft (only drafts)
|
||||
async deletePhaseDraft(id: string): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('experiment_phase_drafts')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
|
||||
// Submit a phase draft (change status from draft to submitted)
|
||||
async submitPhaseDraft(id: string): Promise<ExperimentPhaseDraft> {
|
||||
return this.updatePhaseDraft(id, { status: 'submitted' })
|
||||
},
|
||||
|
||||
// Withdraw a phase draft (change status from submitted to withdrawn)
|
||||
async withdrawPhaseDraft(id: string): Promise<ExperimentPhaseDraft> {
|
||||
return this.updatePhaseDraft(id, { status: 'withdrawn' })
|
||||
},
|
||||
|
||||
// Get phase data for a phase draft
|
||||
async getPhaseDataForDraft(phaseDraftId: string): Promise<ExperimentPhaseData | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_data')
|
||||
.select(`
|
||||
*,
|
||||
diameter_measurements:pecan_diameter_measurements(*)
|
||||
`)
|
||||
.eq('phase_draft_id', phaseDraftId)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') return null // No rows found
|
||||
throw error
|
||||
}
|
||||
return data
|
||||
},
|
||||
|
||||
// Create or update phase data for a draft
|
||||
async upsertPhaseData(phaseDraftId: string, phaseData: Partial<ExperimentPhaseData>): Promise<ExperimentPhaseData> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_data')
|
||||
.upsert({
|
||||
phase_draft_id: phaseDraftId,
|
||||
...phaseData
|
||||
}, {
|
||||
onConflict: 'phase_draft_id,phase_name'
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Save diameter measurements
|
||||
async saveDiameterMeasurements(phaseDataId: string, measurements: number[]): Promise<PecanDiameterMeasurement[]> {
|
||||
// First, delete existing measurements
|
||||
await supabase
|
||||
.from('pecan_diameter_measurements')
|
||||
.delete()
|
||||
.eq('phase_data_id', phaseDataId)
|
||||
|
||||
// Then insert new measurements
|
||||
const measurementData = measurements.map((diameter, index) => ({
|
||||
phase_data_id: phaseDataId,
|
||||
measurement_number: index + 1,
|
||||
diameter_in: diameter
|
||||
}))
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('pecan_diameter_measurements')
|
||||
.insert(measurementData)
|
||||
.select()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Calculate average diameter from measurements
|
||||
calculateAverageDiameter(measurements: number[]): number {
|
||||
if (measurements.length === 0) return 0
|
||||
const validMeasurements = measurements.filter(m => m > 0)
|
||||
if (validMeasurements.length === 0) return 0
|
||||
return validMeasurements.reduce((sum, m) => sum + m, 0) / validMeasurements.length
|
||||
},
|
||||
|
||||
// Auto-save draft data (for periodic saves)
|
||||
async autoSaveDraft(phaseDraftId: string, phaseData: Partial<ExperimentPhaseData>): Promise<void> {
|
||||
try {
|
||||
await this.upsertPhaseData(phaseDraftId, phaseData)
|
||||
} catch (error) {
|
||||
console.warn('Auto-save failed:', error)
|
||||
// Don't throw error for auto-save failures
|
||||
}
|
||||
}
|
||||
}
|
||||
565
management-dashboard-web-app/src/lib/visionApi.ts
Normal file
565
management-dashboard-web-app/src/lib/visionApi.ts
Normal file
@@ -0,0 +1,565 @@
|
||||
// Vision System API Client
|
||||
// Base URL for the vision system API - Use environment variable or default to vision container
|
||||
// The API is accessible at vision:8000 in the current setup
|
||||
const VISION_API_BASE_URL = import.meta.env.VITE_VISION_API_URL || 'http://vision:8000'
|
||||
|
||||
// Types based on the API documentation
|
||||
export interface SystemStatus {
|
||||
system_started: boolean
|
||||
mqtt_connected: boolean
|
||||
last_mqtt_message: string
|
||||
machines: Record<string, MachineStatus>
|
||||
cameras: Record<string, CameraStatus>
|
||||
active_recordings: number
|
||||
total_recordings: number
|
||||
uptime_seconds: number
|
||||
}
|
||||
|
||||
export interface MachineStatus {
|
||||
name: string
|
||||
state: string
|
||||
last_updated: string
|
||||
last_message?: string
|
||||
mqtt_topic?: string
|
||||
}
|
||||
|
||||
export interface CameraStatus {
|
||||
name?: string
|
||||
status: string
|
||||
is_recording: boolean
|
||||
last_checked: string
|
||||
last_error?: string | null
|
||||
device_info?: {
|
||||
friendly_name?: string
|
||||
serial_number?: string
|
||||
port_type?: string
|
||||
model?: string
|
||||
firmware_version?: string
|
||||
last_checked?: number
|
||||
}
|
||||
current_recording_file?: string | null
|
||||
recording_start_time?: string | null
|
||||
last_frame_time?: string
|
||||
frame_rate?: number
|
||||
// NEW AUTO-RECORDING FIELDS
|
||||
auto_recording_enabled: boolean
|
||||
auto_recording_active: boolean
|
||||
auto_recording_failure_count: number
|
||||
auto_recording_last_attempt?: string
|
||||
auto_recording_last_error?: string
|
||||
}
|
||||
|
||||
export interface RecordingInfo {
|
||||
camera_name: string
|
||||
filename: string
|
||||
start_time: string
|
||||
state: string
|
||||
end_time?: string
|
||||
file_size_bytes?: number
|
||||
frame_count?: number
|
||||
duration_seconds?: number
|
||||
error_message?: string | null
|
||||
}
|
||||
|
||||
export interface StorageStats {
|
||||
base_path: string
|
||||
total_files: number
|
||||
total_size_bytes: number
|
||||
cameras: Record<string, {
|
||||
file_count: number
|
||||
total_size_bytes: number
|
||||
}>
|
||||
disk_usage: {
|
||||
total: number
|
||||
used: number
|
||||
free: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface RecordingFile {
|
||||
filename: string
|
||||
camera_name: string
|
||||
file_size_bytes: number
|
||||
created_date: string
|
||||
duration_seconds?: number
|
||||
}
|
||||
|
||||
export interface StartRecordingRequest {
|
||||
filename?: string
|
||||
exposure_ms?: number
|
||||
gain?: number
|
||||
fps?: number
|
||||
}
|
||||
|
||||
export interface StartRecordingResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
export interface StopRecordingResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
duration_seconds: number
|
||||
}
|
||||
|
||||
export interface StreamStartResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface StreamStopResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface CameraTestResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
camera_name: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface CameraRecoveryResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
camera_name: string
|
||||
operation: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
// Auto-Recording Response Types
|
||||
export interface AutoRecordingConfigResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
camera_name: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface AutoRecordingStatusResponse {
|
||||
running: boolean
|
||||
auto_recording_enabled: boolean
|
||||
retry_queue: Record<string, any>
|
||||
enabled_cameras: string[]
|
||||
}
|
||||
|
||||
// Camera Configuration Types
|
||||
export interface CameraConfig {
|
||||
// READ-ONLY SYSTEM FIELDS
|
||||
name: string
|
||||
machine_topic: string
|
||||
storage_path: string
|
||||
enabled: boolean
|
||||
// READ-ONLY AUTO-RECORDING FIELDS
|
||||
auto_start_recording_enabled: boolean
|
||||
auto_recording_max_retries: number
|
||||
auto_recording_retry_delay_seconds: number
|
||||
// BASIC SETTINGS (real-time configurable)
|
||||
exposure_ms: number
|
||||
gain: number
|
||||
target_fps: number
|
||||
// VIDEO RECORDING SETTINGS (restart required)
|
||||
video_format: string // 'mp4' or 'avi'
|
||||
video_codec: string // 'mp4v', 'XVID', 'MJPG'
|
||||
video_quality: number // 0-100 (higher = better quality)
|
||||
// IMAGE QUALITY SETTINGS (real-time configurable)
|
||||
sharpness: number
|
||||
contrast: number
|
||||
saturation: number
|
||||
gamma: number
|
||||
// COLOR SETTINGS (real-time configurable)
|
||||
auto_white_balance: boolean
|
||||
color_temperature_preset: number
|
||||
// WHITE BALANCE RGB GAINS (real-time configurable)
|
||||
wb_red_gain: number
|
||||
wb_green_gain: number
|
||||
wb_blue_gain: number
|
||||
// ADVANCED SETTINGS
|
||||
anti_flicker_enabled: boolean
|
||||
light_frequency: number
|
||||
// NOISE REDUCTION (restart required)
|
||||
noise_filter_enabled: boolean
|
||||
denoise_3d_enabled: boolean
|
||||
// SYSTEM SETTINGS (restart required)
|
||||
bit_depth: number
|
||||
// HDR SETTINGS (real-time configurable)
|
||||
hdr_enabled: boolean
|
||||
hdr_gain_mode: number
|
||||
}
|
||||
|
||||
export interface CameraConfigUpdate {
|
||||
// BASIC SETTINGS (real-time configurable)
|
||||
exposure_ms?: number
|
||||
gain?: number
|
||||
target_fps?: number
|
||||
// IMAGE QUALITY SETTINGS (real-time configurable)
|
||||
sharpness?: number
|
||||
contrast?: number
|
||||
saturation?: number
|
||||
gamma?: number
|
||||
// COLOR SETTINGS (real-time configurable)
|
||||
auto_white_balance?: boolean
|
||||
color_temperature_preset?: number
|
||||
// WHITE BALANCE RGB GAINS (real-time configurable)
|
||||
wb_red_gain?: number
|
||||
wb_green_gain?: number
|
||||
wb_blue_gain?: number
|
||||
// ADVANCED SETTINGS (real-time configurable)
|
||||
anti_flicker_enabled?: boolean
|
||||
light_frequency?: number
|
||||
// HDR SETTINGS (real-time configurable)
|
||||
hdr_enabled?: boolean
|
||||
hdr_gain_mode?: number
|
||||
// NOTE: Video format settings and noise reduction settings are not included
|
||||
// as they are either read-only or require restart via apply-config endpoint
|
||||
}
|
||||
|
||||
export interface CameraConfigUpdateResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
updated_settings: string[]
|
||||
}
|
||||
|
||||
export interface CameraConfigApplyResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface MqttMessage {
|
||||
timestamp: string
|
||||
topic: string
|
||||
message: string
|
||||
source: string
|
||||
}
|
||||
|
||||
export interface MqttStatus {
|
||||
connected: boolean
|
||||
broker_host: string
|
||||
broker_port: number
|
||||
subscribed_topics: string[]
|
||||
last_message_time: string
|
||||
message_count: number
|
||||
error_count: number
|
||||
uptime_seconds: number
|
||||
}
|
||||
|
||||
export interface MqttEvent {
|
||||
machine_name: string
|
||||
topic: string
|
||||
payload: string
|
||||
normalized_state: string
|
||||
timestamp: string
|
||||
message_number: number
|
||||
}
|
||||
|
||||
export interface MqttEventsResponse {
|
||||
events: MqttEvent[]
|
||||
total_events: number
|
||||
last_updated: string
|
||||
}
|
||||
|
||||
export interface FileListRequest {
|
||||
camera_name?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface FileListResponse {
|
||||
files: RecordingFile[]
|
||||
total_count: number
|
||||
}
|
||||
|
||||
export interface CleanupRequest {
|
||||
max_age_days?: number
|
||||
}
|
||||
|
||||
export interface CleanupResponse {
|
||||
files_removed: number
|
||||
bytes_freed: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
// API Client Class
|
||||
class VisionApiClient {
|
||||
private baseUrl: string
|
||||
|
||||
constructor(baseUrl: string = VISION_API_BASE_URL) {
|
||||
this.baseUrl = baseUrl
|
||||
}
|
||||
|
||||
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `${this.baseUrl}${endpoint}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// System endpoints
|
||||
async getHealth(): Promise<{ status: string; timestamp: string }> {
|
||||
return this.request('/health')
|
||||
}
|
||||
|
||||
async getSystemStatus(): Promise<SystemStatus> {
|
||||
return this.request('/system/status')
|
||||
}
|
||||
|
||||
// Machine endpoints
|
||||
async getMachines(): Promise<Record<string, MachineStatus>> {
|
||||
return this.request('/machines')
|
||||
}
|
||||
|
||||
// MQTT endpoints
|
||||
async getMqttStatus(): Promise<MqttStatus> {
|
||||
return this.request('/mqtt/status')
|
||||
}
|
||||
|
||||
async getMqttEvents(limit: number = 10): Promise<MqttEventsResponse> {
|
||||
return this.request(`/mqtt/events?limit=${limit}`)
|
||||
}
|
||||
|
||||
// Camera endpoints
|
||||
async getCameras(): Promise<Record<string, CameraStatus>> {
|
||||
return this.request('/cameras')
|
||||
}
|
||||
|
||||
async getCameraStatus(cameraName: string): Promise<CameraStatus> {
|
||||
return this.request(`/cameras/${cameraName}/status`)
|
||||
}
|
||||
|
||||
// Recording control
|
||||
async startRecording(cameraName: string, params: StartRecordingRequest = {}): Promise<StartRecordingResponse> {
|
||||
return this.request(`/cameras/${cameraName}/start-recording`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
}
|
||||
|
||||
async stopRecording(cameraName: string): Promise<StopRecordingResponse> {
|
||||
return this.request(`/cameras/${cameraName}/stop-recording`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
// Streaming control
|
||||
async startStream(cameraName: string): Promise<StreamStartResponse> {
|
||||
return this.request(`/cameras/${cameraName}/start-stream`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async stopStream(cameraName: string): Promise<StreamStopResponse> {
|
||||
return this.request(`/cameras/${cameraName}/stop-stream`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
getStreamUrl(cameraName: string): string {
|
||||
return `${this.baseUrl}/cameras/${cameraName}/stream`
|
||||
}
|
||||
|
||||
// Camera diagnostics
|
||||
async testCameraConnection(cameraName: string): Promise<CameraTestResponse> {
|
||||
return this.request(`/cameras/${cameraName}/test-connection`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async reconnectCamera(cameraName: string): Promise<CameraRecoveryResponse> {
|
||||
return this.request(`/cameras/${cameraName}/reconnect`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async restartCameraGrab(cameraName: string): Promise<CameraRecoveryResponse> {
|
||||
return this.request(`/cameras/${cameraName}/restart-grab`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async resetCameraTimestamp(cameraName: string): Promise<CameraRecoveryResponse> {
|
||||
return this.request(`/cameras/${cameraName}/reset-timestamp`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async fullCameraReset(cameraName: string): Promise<CameraRecoveryResponse> {
|
||||
return this.request(`/cameras/${cameraName}/full-reset`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async reinitializeCamera(cameraName: string): Promise<CameraRecoveryResponse> {
|
||||
return this.request(`/cameras/${cameraName}/reinitialize`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
// Camera configuration
|
||||
async getCameraConfig(cameraName: string): Promise<CameraConfig> {
|
||||
try {
|
||||
const config = await this.request(`/cameras/${cameraName}/config`) as any
|
||||
|
||||
// Map API field names to UI expected field names and ensure auto-recording fields have default values if missing
|
||||
return {
|
||||
...config,
|
||||
// Map auto_start_recording_enabled from API to auto_record_on_machine_start for UI
|
||||
auto_record_on_machine_start: config.auto_start_recording_enabled ?? false,
|
||||
auto_start_recording_enabled: config.auto_start_recording_enabled ?? false,
|
||||
auto_recording_max_retries: config.auto_recording_max_retries ?? 3,
|
||||
auto_recording_retry_delay_seconds: config.auto_recording_retry_delay_seconds ?? 5
|
||||
}
|
||||
} catch (error: any) {
|
||||
// If the error is related to missing auto-recording fields, try to handle it gracefully
|
||||
if (error.message?.includes('auto_start_recording_enabled') ||
|
||||
error.message?.includes('auto_recording_max_retries') ||
|
||||
error.message?.includes('auto_recording_retry_delay_seconds')) {
|
||||
|
||||
// Try to get the raw camera data and add default auto-recording fields
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/cameras/${cameraName}/config`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const rawConfig = await response.json()
|
||||
|
||||
// Add missing auto-recording fields with defaults and map field names
|
||||
return {
|
||||
...rawConfig,
|
||||
// Map auto_start_recording_enabled from API to auto_record_on_machine_start for UI
|
||||
auto_record_on_machine_start: rawConfig.auto_start_recording_enabled ?? false,
|
||||
auto_start_recording_enabled: rawConfig.auto_start_recording_enabled ?? false,
|
||||
auto_recording_max_retries: rawConfig.auto_recording_max_retries ?? 3,
|
||||
auto_recording_retry_delay_seconds: rawConfig.auto_recording_retry_delay_seconds ?? 5
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
throw new Error(`Failed to load camera configuration: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async updateCameraConfig(cameraName: string, config: CameraConfigUpdate): Promise<CameraConfigUpdateResponse> {
|
||||
// Map UI field names to API field names
|
||||
const apiConfig = { ...config }
|
||||
|
||||
// If auto_record_on_machine_start is present, map it to auto_start_recording_enabled for the API
|
||||
if ('auto_record_on_machine_start' in config) {
|
||||
apiConfig.auto_start_recording_enabled = config.auto_record_on_machine_start
|
||||
// Remove the UI field name to avoid confusion
|
||||
delete apiConfig.auto_record_on_machine_start
|
||||
}
|
||||
|
||||
return this.request(`/cameras/${cameraName}/config`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(apiConfig),
|
||||
})
|
||||
}
|
||||
|
||||
async applyCameraConfig(cameraName: string): Promise<CameraConfigApplyResponse> {
|
||||
return this.request(`/cameras/${cameraName}/apply-config`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
// Auto-Recording endpoints
|
||||
async enableAutoRecording(cameraName: string): Promise<AutoRecordingConfigResponse> {
|
||||
return this.request(`/cameras/${cameraName}/auto-recording/enable`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async disableAutoRecording(cameraName: string): Promise<AutoRecordingConfigResponse> {
|
||||
return this.request(`/cameras/${cameraName}/auto-recording/disable`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async getAutoRecordingStatus(): Promise<AutoRecordingStatusResponse> {
|
||||
return this.request('/auto-recording/status')
|
||||
}
|
||||
|
||||
// Recording sessions
|
||||
async getRecordings(): Promise<Record<string, RecordingInfo>> {
|
||||
return this.request('/recordings')
|
||||
}
|
||||
|
||||
// Storage endpoints
|
||||
async getStorageStats(): Promise<StorageStats> {
|
||||
return this.request('/storage/stats')
|
||||
}
|
||||
|
||||
async getFiles(params: FileListRequest = {}): Promise<FileListResponse> {
|
||||
return this.request('/storage/files', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
}
|
||||
|
||||
async cleanupStorage(params: CleanupRequest = {}): Promise<CleanupResponse> {
|
||||
return this.request('/storage/cleanup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance
|
||||
export const visionApi = new VisionApiClient()
|
||||
|
||||
// Utility functions
|
||||
export const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
export const formatDuration = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${secs}s`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${secs}s`
|
||||
} else {
|
||||
return `${secs}s`
|
||||
}
|
||||
}
|
||||
|
||||
export const formatUptime = (seconds: number): string => {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours}h ${minutes}m`
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`
|
||||
} else {
|
||||
return `${minutes}m`
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user