Chore: rename api->camera-management-api and web->management-dashboard-web-app; update compose, ignore, README references

This commit is contained in:
Alireza Vaezi
2025-08-07 22:07:25 -04:00
parent 9384c26504
commit cc63bf4110
281 changed files with 19 additions and 19 deletions

View 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()

View 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
}
}
}

View 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`
}
}