diff --git a/camera-management-api/usda_vision_system/api/models.py b/camera-management-api/usda_vision_system/api/models.py index 2167469..b38022a 100644 --- a/camera-management-api/usda_vision_system/api/models.py +++ b/camera-management-api/usda_vision_system/api/models.py @@ -334,3 +334,21 @@ class SuccessResponse(BaseModel): message: str data: Optional[Dict[str, Any]] = None timestamp: str = Field(default_factory=lambda: datetime.now().isoformat()) + + +class MQTTPublishRequest(BaseModel): + """MQTT publish request model""" + + topic: str = Field(..., description="MQTT topic to publish to") + payload: str = Field(..., description="Message payload") + qos: int = Field(default=0, ge=0, le=2, description="Quality of Service level (0-2)") + retain: bool = Field(default=False, description="Whether to retain the message") + + +class MQTTPublishResponse(BaseModel): + """MQTT publish response model""" + + success: bool + message: str + topic: str + payload: str diff --git a/camera-management-api/usda_vision_system/api/routes/mqtt_routes.py b/camera-management-api/usda_vision_system/api/routes/mqtt_routes.py index ed81281..87bdc3e 100644 --- a/camera-management-api/usda_vision_system/api/routes/mqtt_routes.py +++ b/camera-management-api/usda_vision_system/api/routes/mqtt_routes.py @@ -7,7 +7,7 @@ from typing import Dict from fastapi import FastAPI, HTTPException, Query from ...core.state_manager import StateManager from ...mqtt.client import MQTTClient -from ..models import MQTTStatusResponse, MQTTEventsHistoryResponse, MQTTEventResponse +from ..models import MQTTStatusResponse, MQTTEventsHistoryResponse, MQTTEventResponse, MQTTPublishRequest, MQTTPublishResponse def register_mqtt_routes( @@ -70,3 +70,33 @@ def register_mqtt_routes( logger.error(f"Error getting MQTT events: {e}") raise HTTPException(status_code=500, detail=str(e)) + @app.post("/mqtt/publish", response_model=MQTTPublishResponse) + async def publish_mqtt_message(request: MQTTPublishRequest): + """Publish an MQTT message (for testing/debugging)""" + try: + if not mqtt_client.is_connected(): + raise HTTPException(status_code=503, detail="MQTT client is not connected") + + success = mqtt_client.publish_message( + topic=request.topic, + payload=request.payload, + qos=request.qos, + retain=request.retain + ) + + if success: + logger.info(f"Published MQTT message: {request.topic} -> {request.payload}") + return MQTTPublishResponse( + success=True, + message=f"Message published successfully to {request.topic}", + topic=request.topic, + payload=request.payload + ) + else: + raise HTTPException(status_code=500, detail="Failed to publish MQTT message") + except HTTPException: + raise + except Exception as e: + logger.error(f"Error publishing MQTT message: {e}") + raise HTTPException(status_code=500, detail=str(e)) + diff --git a/camera-management-api/usda_vision_system/recording/standalone_auto_recorder.py b/camera-management-api/usda_vision_system/recording/standalone_auto_recorder.py index 28e3422..6b1b944 100644 --- a/camera-management-api/usda_vision_system/recording/standalone_auto_recorder.py +++ b/camera-management-api/usda_vision_system/recording/standalone_auto_recorder.py @@ -249,19 +249,30 @@ class StandaloneAutoRecorder: self.logger.info(f"📹 AUTO-RECORDER: Starting recording with settings - Exposure: {camera_config.exposure_ms}ms, Gain: {camera_config.gain}, FPS: {camera_config.target_fps}") # Use camera manager to start recording with camera's default settings - success = self.camera_manager.manual_start_recording( - camera_name=camera_name, - filename=filename, - exposure_ms=camera_config.exposure_ms if camera_config else None, - gain=camera_config.gain if camera_config else None, - fps=camera_config.target_fps if camera_config else None - ) + self.logger.info(f"📹 AUTO-RECORDER: Calling manual_start_recording for {camera_name} with filename: {filename}") + try: + success = self.camera_manager.manual_start_recording( + camera_name=camera_name, + filename=filename, + exposure_ms=camera_config.exposure_ms if camera_config else None, + gain=camera_config.gain if camera_config else None, + fps=camera_config.target_fps if camera_config else None + ) + self.logger.info(f"📹 AUTO-RECORDER: manual_start_recording returned: {success}") + except Exception as e: + self.logger.error(f"❌ AUTO-RECORDER: Exception calling manual_start_recording: {e}", exc_info=True) + success = False if success: self.logger.info(f"✅ AUTO-RECORDER: Successfully started auto-recording: {camera_name} -> {filename}") self.active_recordings[camera_name] = filename else: self.logger.error(f"❌ AUTO-RECORDER: Failed to start auto-recording for camera {camera_name} (manual_start_recording returned False)") + # Check camera status for more details + if self.state_manager: + camera_info = self.state_manager.get_camera_status(camera_name) + if camera_info: + self.logger.error(f"❌ AUTO-RECORDER: Camera status - is_recording: {camera_info.is_recording}, state: {camera_info.state}") else: # Standalone mode - use own recorder recorder = self._get_camera_recorder(camera_name) diff --git a/docs/DESIGN_RECOMMENDATION_SUMMARY.md b/docs/DESIGN_RECOMMENDATION_SUMMARY.md new file mode 100644 index 0000000..d62445d --- /dev/null +++ b/docs/DESIGN_RECOMMENDATION_SUMMARY.md @@ -0,0 +1,94 @@ +# Database Design Recommendation Summary + +## Critical Issue Identified + +Your current design has a **fundamental flaw** that prevents it from working correctly with repetitions: + +### The Problem + +The phase tables (`soaking`, `airdrying`, `cracking`, `shelling`) have this constraint: +```sql +CONSTRAINT unique_soaking_per_experiment UNIQUE (experiment_id) +``` + +This means you can **only have ONE soaking record per experiment**, even if you have 3 repetitions! This breaks your entire repetition system. + +### Why This Happens + +When you create an experiment with 3 repetitions: +1. ✅ 3 rows are created in `experiment_repetitions` +2. ❌ But you can only create 1 row in `soaking` (due to UNIQUE constraint) +3. ❌ The other 2 repetitions cannot have soaking data! + +## Design Assessment + +### Current Approach: Separate Tables (❌ Not Recommended) + +**Problems:** +- ❌ UNIQUE constraint breaks repetitions +- ❌ Schema duplication (same structure 4 times) +- ❌ Hard to query "all phases for a repetition" +- ❌ Sequential timing calculations are complex and error-prone +- ❌ No automatic phase creation when repetitions are created + +### Recommended Approach: Unified Table (✅ Best Practice) + +**Benefits:** +- ✅ Properly supports repetitions (one phase per repetition) +- ✅ Automatic phase creation via database trigger +- ✅ Simple sequential time calculations +- ✅ Easy to query all phases for a repetition +- ✅ Single source of truth +- ✅ Easier to maintain and extend + +## Recommended Solution + +I've created a migration file that implements a **unified `experiment_phase_executions` table**: + +### Key Features: + +1. **Single Table for All Phases** + - Uses `phase_type` enum to distinguish phases + - One row per phase per repetition + - Proper UNIQUE constraint: `(repetition_id, phase_type)` + +2. **Automatic Phase Creation** + - Database trigger automatically creates phase executions when a repetition is created + - Based on the experiment's phase configuration (`has_soaking`, `has_airdrying`, etc.) + +3. **Automatic Sequential Timing** + - Database trigger calculates sequential start times + - If soaking ends at 5pm, airdrying automatically starts at 5pm + - If airdrying ends at 5:12pm, cracking automatically starts at 5:12pm + +4. **Backward Compatibility Views** + - Views created for `soaking_view`, `airdrying_view`, etc. + - Existing code can continue to work (with minor updates) + +## Files Created + +1. **`docs/database_design_analysis.md`** - Detailed analysis with comparison matrix +2. **`management-dashboard-web-app/supabase/migrations/00012_unified_phase_executions.sql`** - Complete migration implementation + +## Migration Path + +1. Review the analysis document +2. Test the migration on a development database +3. Update application code to use the new table structure +4. Migrate existing data (if any) +5. Drop old phase tables after verification + +## Alternative: Fix Current Design + +If you prefer to keep separate tables, you MUST: +1. Remove `UNIQUE (experiment_id)` constraints from all phase tables +2. Keep only `UNIQUE (repetition_id)` constraints +3. Add trigger to auto-create phase entries when repetitions are created +4. Fix sequential timing calculations to use `repetition_id` instead of `experiment_id` + +However, this still has the drawbacks of schema duplication and complexity. + +## Recommendation + +**Use the unified table approach** - it's cleaner, more maintainable, and properly supports your repetition model. + diff --git a/docs/database_design_analysis.md b/docs/database_design_analysis.md new file mode 100644 index 0000000..7402740 --- /dev/null +++ b/docs/database_design_analysis.md @@ -0,0 +1,307 @@ +# Database Design Analysis: Experiment Phases & Repetitions + +## Current Design Issues + +### 1. **Critical UNIQUE Constraint Problem** + +The phase tables (`soaking`, `airdrying`, `cracking`, `shelling`) have a fundamental flaw: + +```sql +-- Current problematic constraints +CONSTRAINT unique_soaking_per_experiment UNIQUE (experiment_id), +CONSTRAINT unique_soaking_per_repetition UNIQUE (repetition_id) +``` + +**Problem**: The `UNIQUE (experiment_id)` constraint prevents having multiple phase records for different repetitions of the same experiment. If an experiment has 3 repetitions, you can only store phase data for ONE repetition! + +**Impact**: This completely breaks the repetition system - you cannot have separate phase executions for each repetition. + +### 2. **Ambiguous Data Model** + +The phase tables reference both `experiment_id` AND `repetition_id`, creating confusion: +- If data belongs to the experiment, why have `repetition_id`? +- If data belongs to the repetition, why have `UNIQUE (experiment_id)`? + +### 3. **Missing Automatic Phase Creation** + +When repetitions are created, corresponding phase entries are NOT automatically created. This leads to: +- Incomplete data model +- Manual work required +- Potential data inconsistencies + +### 4. **Sequential Timing Calculation Issues** + +The triggers that calculate sequential start times look up by `experiment_id`: +```sql +SELECT s.scheduled_end_time INTO NEW.scheduled_start_time +FROM public.soaking s +WHERE s.experiment_id = NEW.experiment_id -- WRONG! Should be repetition_id +``` + +This will fail when you have multiple repetitions because: +- It can't determine which repetition's soaking to use +- It might pick the wrong repetition's data + +## Design Assessment: Separate Tables vs Unified Table + +### Current Approach: Separate Tables (❌ Not Recommended) + +**Pros:** +- Clear separation of concerns +- Type-specific columns can be different +- Easier to understand at a glance + +**Cons:** +- ❌ Schema duplication (same structure repeated 4 times) +- ❌ Harder to query across phases +- ❌ More complex to maintain +- ❌ Difficult to enforce sequential relationships +- ❌ More tables to manage +- ❌ Harder to get "all phases for a repetition" queries +- ❌ Current UNIQUE constraints break the repetition model + +### Recommended Approach: Unified Phase Execution Table (✅ Best Practice) + +**Pros:** +- ✅ Single source of truth for phase executions +- ✅ Easy to query all phases for a repetition +- ✅ Simple sequential phase calculations +- ✅ Easier to enforce business rules (one phase per repetition) +- ✅ Less schema duplication +- ✅ Better for reporting and analytics +- ✅ Easier to add new phases in the future +- ✅ Can use database views for phase-specific access + +**Cons:** +- Requires phase-specific columns to be nullable (or use JSONB) +- Slightly more complex queries for phase-specific data + +## Recommended Solution: Unified Phase Execution Table + +### Option 1: Single Table with Phase Type Enum (Recommended) + +```sql +CREATE TABLE public.experiment_phase_executions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + repetition_id UUID NOT NULL REFERENCES public.experiment_repetitions(id) ON DELETE CASCADE, + phase_type TEXT NOT NULL CHECK (phase_type IN ('soaking', 'airdrying', 'cracking', 'shelling')), + + -- Scheduling fields (common to all phases) + scheduled_start_time TIMESTAMP WITH TIME ZONE NOT NULL, + scheduled_end_time TIMESTAMP WITH TIME ZONE, + actual_start_time TIMESTAMP WITH TIME ZONE, + actual_end_time TIMESTAMP WITH TIME ZONE, + + -- Phase-specific parameters (nullable, only relevant for specific phases) + -- Soaking + soaking_duration_minutes INTEGER CHECK (soaking_duration_minutes > 0), + + -- Airdrying + duration_minutes INTEGER CHECK (duration_minutes > 0), + + -- Cracking + machine_type_id UUID REFERENCES public.machine_types(id), + + -- Status tracking + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'scheduled', 'in_progress', 'completed', 'cancelled')), + + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + created_by UUID NOT NULL REFERENCES public.user_profiles(id), + + -- Ensure one execution per phase type per repetition + CONSTRAINT unique_phase_per_repetition UNIQUE (repetition_id, phase_type) +); + +-- Indexes +CREATE INDEX idx_phase_executions_repetition_id ON public.experiment_phase_executions(repetition_id); +CREATE INDEX idx_phase_executions_phase_type ON public.experiment_phase_executions(phase_type); +CREATE INDEX idx_phase_executions_status ON public.experiment_phase_executions(status); + +-- Function to automatically calculate sequential start times +CREATE OR REPLACE FUNCTION calculate_sequential_phase_start_time() +RETURNS TRIGGER AS $$ +DECLARE + prev_phase_end_time TIMESTAMP WITH TIME ZONE; + experiment_phase_config UUID; + phase_order TEXT[] := ARRAY['soaking', 'airdrying', 'cracking', 'shelling']; + current_phase_index INT; + prev_phase_name TEXT; +BEGIN + -- Get the experiment phase configuration + SELECT e.phase_id INTO experiment_phase_config + FROM public.experiments e + JOIN public.experiment_repetitions er ON e.id = er.experiment_id + WHERE er.id = NEW.repetition_id; + + -- Find current phase index + SELECT array_position(phase_order, NEW.phase_type) INTO current_phase_index; + + -- If not the first phase, get previous phase's end time + IF current_phase_index > 1 THEN + prev_phase_name := phase_order[current_phase_index - 1]; + + SELECT scheduled_end_time INTO prev_phase_end_time + FROM public.experiment_phase_executions + WHERE repetition_id = NEW.repetition_id + AND phase_type = prev_phase_name + ORDER BY created_at DESC + LIMIT 1; + + -- If previous phase exists, use its end time as start time + IF prev_phase_end_time IS NOT NULL THEN + NEW.scheduled_start_time := prev_phase_end_time; + END IF; + END IF; + + -- Calculate end time based on duration + IF NEW.phase_type = 'soaking' AND NEW.soaking_duration_minutes IS NOT NULL THEN + NEW.scheduled_end_time := NEW.scheduled_start_time + (NEW.soaking_duration_minutes || ' minutes')::INTERVAL; + ELSIF NEW.phase_type = 'airdrying' AND NEW.duration_minutes IS NOT NULL THEN + NEW.scheduled_end_time := NEW.scheduled_start_time + (NEW.duration_minutes || ' minutes')::INTERVAL; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger to automatically calculate sequential times +CREATE TRIGGER trigger_calculate_sequential_phase_times + BEFORE INSERT OR UPDATE ON public.experiment_phase_executions + FOR EACH ROW + EXECUTE FUNCTION calculate_sequential_phase_start_time(); + +-- Function to automatically create phase executions when repetition is created +CREATE OR REPLACE FUNCTION create_phase_executions_for_repetition() +RETURNS TRIGGER AS $$ +DECLARE + exp_phase_config RECORD; + phase_type_list TEXT[] := ARRAY[]::TEXT[]; + phase_name TEXT; + soaking_start_time TIMESTAMP WITH TIME ZONE; +BEGIN + -- Get experiment phase configuration + SELECT ep.*, e.soaking_duration_hr, e.air_drying_time_min + INTO exp_phase_config + FROM public.experiments e + JOIN public.experiment_phases ep ON e.phase_id = ep.id + JOIN public.experiment_repetitions er ON e.id = er.experiment_id + WHERE er.id = NEW.id; + + -- Build list of phases to create based on experiment configuration + IF exp_phase_config.has_soaking THEN + phase_type_list := array_append(phase_type_list, 'soaking'); + END IF; + IF exp_phase_config.has_airdrying THEN + phase_type_list := array_append(phase_type_list, 'airdrying'); + END IF; + IF exp_phase_config.has_cracking THEN + phase_type_list := array_append(phase_type_list, 'cracking'); + END IF; + IF exp_phase_config.has_shelling THEN + phase_type_list := array_append(phase_type_list, 'shelling'); + END IF; + + -- Create phase executions for each required phase + FOREACH phase_name IN ARRAY phase_type_list + LOOP + INSERT INTO public.experiment_phase_executions ( + repetition_id, + phase_type, + scheduled_start_time, + scheduled_end_time, + status, + created_by, + -- Phase-specific parameters + CASE + WHEN phase_name = 'soaking' THEN + soaking_duration_minutes := (exp_phase_config.soaking_duration_hr * 60)::INTEGER + WHEN phase_name = 'airdrying' THEN + duration_minutes := exp_phase_config.air_drying_time_min + END + ) + VALUES ( + NEW.id, + phase_name, + NOW(), -- Default start time, will be updated when scheduled + NULL, -- Will be calculated by trigger + 'pending', + NEW.created_by + ); + END LOOP; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger to auto-create phases when repetition is created +CREATE TRIGGER trigger_create_phase_executions + AFTER INSERT ON public.experiment_repetitions + FOR EACH ROW + EXECUTE FUNCTION create_phase_executions_for_repetition(); +``` + +### Option 2: Keep Separate Tables but Fix Constraints (Alternative) + +If you prefer to keep separate tables, you MUST fix the constraints: + +```sql +-- Remove experiment_id UNIQUE constraints +ALTER TABLE public.soaking DROP CONSTRAINT IF EXISTS unique_soaking_per_experiment; +ALTER TABLE public.airdrying DROP CONSTRAINT IF EXISTS unique_airdrying_per_experiment; +ALTER TABLE public.cracking DROP CONSTRAINT IF EXISTS unique_cracking_per_experiment; +ALTER TABLE public.shelling DROP CONSTRAINT IF EXISTS unique_shelling_per_experiment; + +-- Keep only repetition_id UNIQUE constraints (one phase per repetition) +-- These already exist, which is good + +-- Optionally, remove experiment_id if not needed +-- OR keep it for easier querying but without UNIQUE constraint +``` + +Then add triggers to auto-create phase entries when repetitions are created (similar to Option 1's trigger). + +## Comparison Matrix + +| Aspect | Separate Tables (Current) | Unified Table (Recommended) | +|--------|---------------------------|------------------------------| +| **Repetition Support** | ❌ Broken (UNIQUE constraint) | ✅ Works correctly | +| **Query Complexity** | ❌ Requires UNION or multiple queries | ✅ Simple single query | +| **Sequential Calculations** | ❌ Complex, error-prone | ✅ Simple, reliable | +| **Schema Maintenance** | ❌ 4x duplication | ✅ Single source | +| **Auto-creation** | ❌ Manual or missing | ✅ Automatic via trigger | +| **Adding New Phases** | ❌ Requires new table | ✅ Just add enum value | +| **Reporting** | ❌ Complex joins/unions | ✅ Straightforward | +| **Data Integrity** | ❌ Can have inconsistencies | ✅ Easier to enforce | + +## Recommendation + +**Use Option 1 (Unified Table)** because: +1. ✅ Fixes the repetition constraint issue +2. ✅ Simplifies the data model +3. ✅ Makes sequential phase calculations straightforward +4. ✅ Automatically creates phases when repetitions are created +5. ✅ Better for future scalability +6. ✅ Easier to maintain and query + +You can still create database **views** for phase-specific access if needed: +```sql +CREATE VIEW soaking_executions AS +SELECT * FROM experiment_phase_executions WHERE phase_type = 'soaking'; + +CREATE VIEW airdrying_executions AS +SELECT * FROM experiment_phase_executions WHERE phase_type = 'airdrying'; +-- etc. +``` + +This gives you the benefits of a unified table while maintaining phase-specific interfaces for your application code. + +## Migration Path + +1. Create new `experiment_phase_executions` table +2. Migrate existing data from separate tables +3. Create views for backward compatibility (if needed) +4. Update application code to use new table +5. Drop old tables after verification + diff --git a/management-dashboard-web-app/supabase/migrations/00012_unified_phase_executions.sql b/management-dashboard-web-app/supabase/migrations/00012_unified_phase_executions.sql new file mode 100644 index 0000000..978e21f --- /dev/null +++ b/management-dashboard-web-app/supabase/migrations/00012_unified_phase_executions.sql @@ -0,0 +1,300 @@ +-- Unified Phase Executions Table +-- This migration replaces the separate phase tables (soaking, airdrying, cracking, shelling) +-- with a unified table that properly supports repetitions + +-- ============================================= +-- 1. CREATE UNIFIED PHASE EXECUTIONS TABLE +-- ============================================= + +CREATE TABLE IF NOT EXISTS public.experiment_phase_executions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + repetition_id UUID NOT NULL REFERENCES public.experiment_repetitions(id) ON DELETE CASCADE, + phase_type TEXT NOT NULL CHECK (phase_type IN ('soaking', 'airdrying', 'cracking', 'shelling')), + + -- Scheduling fields (common to all phases) + scheduled_start_time TIMESTAMP WITH TIME ZONE NOT NULL, + scheduled_end_time TIMESTAMP WITH TIME ZONE, + actual_start_time TIMESTAMP WITH TIME ZONE, + actual_end_time TIMESTAMP WITH TIME ZONE, + + -- Phase-specific parameters (nullable, only relevant for specific phases) + -- Soaking + soaking_duration_minutes INTEGER CHECK (soaking_duration_minutes > 0), + + -- Airdrying + duration_minutes INTEGER CHECK (duration_minutes > 0), + + -- Cracking + machine_type_id UUID REFERENCES public.machine_types(id), + + -- Status tracking + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'scheduled', 'in_progress', 'completed', 'cancelled')), + + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + created_by UUID NOT NULL REFERENCES public.user_profiles(id), + + -- Ensure one execution per phase type per repetition + CONSTRAINT unique_phase_per_repetition UNIQUE (repetition_id, phase_type) +); + +-- ============================================= +-- 2. INDEXES FOR PERFORMANCE +-- ============================================= + +CREATE INDEX IF NOT EXISTS idx_phase_executions_repetition_id + ON public.experiment_phase_executions(repetition_id); +CREATE INDEX IF NOT EXISTS idx_phase_executions_phase_type + ON public.experiment_phase_executions(phase_type); +CREATE INDEX IF NOT EXISTS idx_phase_executions_status + ON public.experiment_phase_executions(status); +CREATE INDEX IF NOT EXISTS idx_phase_executions_scheduled_start_time + ON public.experiment_phase_executions(scheduled_start_time); +CREATE INDEX IF NOT EXISTS idx_phase_executions_machine_type_id + ON public.experiment_phase_executions(machine_type_id); +CREATE INDEX IF NOT EXISTS idx_phase_executions_created_by + ON public.experiment_phase_executions(created_by); + +-- ============================================= +-- 3. FUNCTION: Calculate Sequential Phase Start Times +-- ============================================= + +CREATE OR REPLACE FUNCTION calculate_sequential_phase_start_time() +RETURNS TRIGGER AS $$ +DECLARE + prev_phase_end_time TIMESTAMP WITH TIME ZONE; + phase_order TEXT[] := ARRAY['soaking', 'airdrying', 'cracking', 'shelling']; + current_phase_index INT; + prev_phase_name TEXT; +BEGIN + -- Find current phase index in the sequence + SELECT array_position(phase_order, NEW.phase_type) INTO current_phase_index; + + -- If not the first phase, get previous phase's end time from the same repetition + IF current_phase_index > 1 THEN + prev_phase_name := phase_order[current_phase_index - 1]; + + SELECT scheduled_end_time INTO prev_phase_end_time + FROM public.experiment_phase_executions + WHERE repetition_id = NEW.repetition_id + AND phase_type = prev_phase_name + ORDER BY created_at DESC + LIMIT 1; + + -- If previous phase exists and has an end time, use it as start time + IF prev_phase_end_time IS NOT NULL THEN + NEW.scheduled_start_time := prev_phase_end_time; + END IF; + END IF; + + -- Calculate end time based on duration (for phases with duration) + IF NEW.phase_type = 'soaking' AND NEW.soaking_duration_minutes IS NOT NULL THEN + NEW.scheduled_end_time := NEW.scheduled_start_time + + (NEW.soaking_duration_minutes || ' minutes')::INTERVAL; + ELSIF NEW.phase_type = 'airdrying' AND NEW.duration_minutes IS NOT NULL THEN + NEW.scheduled_end_time := NEW.scheduled_start_time + + (NEW.duration_minutes || ' minutes')::INTERVAL; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ============================================= +-- 4. FUNCTION: Auto-create Phase Executions for New Repetition +-- ============================================= + +CREATE OR REPLACE FUNCTION create_phase_executions_for_repetition() +RETURNS TRIGGER AS $$ +DECLARE + exp_phase_config RECORD; + phase_type_list TEXT[] := ARRAY[]::TEXT[]; + phase_name TEXT; +BEGIN + -- Get experiment phase configuration + -- Note: Phase durations may need to be set later when scheduling + SELECT + ep.has_soaking, + ep.has_airdrying, + ep.has_cracking, + ep.has_shelling, + ep.cracking_machine_type_id + INTO exp_phase_config + FROM public.experiments e + JOIN public.experiment_phases ep ON e.phase_id = ep.id + WHERE e.id = NEW.experiment_id; + + -- Build list of phases to create based on experiment configuration + IF exp_phase_config.has_soaking THEN + phase_type_list := array_append(phase_type_list, 'soaking'); + END IF; + IF exp_phase_config.has_airdrying THEN + phase_type_list := array_append(phase_type_list, 'airdrying'); + END IF; + IF exp_phase_config.has_cracking THEN + phase_type_list := array_append(phase_type_list, 'cracking'); + END IF; + IF exp_phase_config.has_shelling THEN + phase_type_list := array_append(phase_type_list, 'shelling'); + END IF; + + -- Create phase executions for each required phase + FOREACH phase_name IN ARRAY phase_type_list + LOOP + INSERT INTO public.experiment_phase_executions ( + repetition_id, + phase_type, + scheduled_start_time, + status, + created_by, + -- Phase-specific parameters + soaking_duration_minutes, + duration_minutes, + machine_type_id + ) + VALUES ( + NEW.id, + phase_name, + NOW(), -- Default start time, will be updated when scheduled or by sequential calculation + 'pending', + NEW.created_by, + -- Set phase-specific parameters + -- Note: Durations will be set when the repetition is scheduled + -- These can be NULL initially and updated later + NULL, -- soaking_duration_minutes (set when scheduled) + NULL, -- duration_minutes (set when scheduled) + CASE WHEN phase_name = 'cracking' + THEN exp_phase_config.cracking_machine_type_id + ELSE NULL END + ); + END LOOP; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ============================================= +-- 5. TRIGGERS +-- ============================================= + +-- Trigger to automatically calculate sequential times and durations +CREATE TRIGGER trigger_calculate_sequential_phase_times + BEFORE INSERT OR UPDATE ON public.experiment_phase_executions + FOR EACH ROW + EXECUTE FUNCTION calculate_sequential_phase_start_time(); + +-- Trigger to auto-create phases when repetition is created +CREATE TRIGGER trigger_create_phase_executions + AFTER INSERT ON public.experiment_repetitions + FOR EACH ROW + EXECUTE FUNCTION create_phase_executions_for_repetition(); + +-- Trigger for updated_at +CREATE TRIGGER set_updated_at_phase_executions + BEFORE UPDATE ON public.experiment_phase_executions + FOR EACH ROW + EXECUTE FUNCTION public.handle_updated_at(); + +-- ============================================= +-- 6. CREATE VIEWS FOR PHASE-SPECIFIC ACCESS (Backward Compatibility) +-- ============================================= + +-- These views allow existing code to work with phase-specific "tables" +CREATE OR REPLACE VIEW public.soaking_view AS +SELECT + id, + (SELECT experiment_id FROM experiment_repetitions WHERE id = repetition_id) as experiment_id, + repetition_id, + scheduled_start_time, + actual_start_time, + soaking_duration_minutes, + scheduled_end_time, + actual_end_time, + created_at, + updated_at, + created_by +FROM public.experiment_phase_executions +WHERE phase_type = 'soaking'; + +CREATE OR REPLACE VIEW public.airdrying_view AS +SELECT + id, + (SELECT experiment_id FROM experiment_repetitions WHERE id = repetition_id) as experiment_id, + repetition_id, + scheduled_start_time, + actual_start_time, + duration_minutes, + scheduled_end_time, + actual_end_time, + created_at, + updated_at, + created_by +FROM public.experiment_phase_executions +WHERE phase_type = 'airdrying'; + +CREATE OR REPLACE VIEW public.cracking_view AS +SELECT + id, + (SELECT experiment_id FROM experiment_repetitions WHERE id = repetition_id) as experiment_id, + repetition_id, + machine_type_id, + scheduled_start_time, + actual_start_time, + actual_end_time, + created_at, + updated_at, + created_by +FROM public.experiment_phase_executions +WHERE phase_type = 'cracking'; + +CREATE OR REPLACE VIEW public.shelling_view AS +SELECT + id, + (SELECT experiment_id FROM experiment_repetitions WHERE id = repetition_id) as experiment_id, + repetition_id, + scheduled_start_time, + actual_start_time, + actual_end_time, + created_at, + updated_at, + created_by +FROM public.experiment_phase_executions +WHERE phase_type = 'shelling'; + +-- ============================================= +-- 7. GRANT PERMISSIONS +-- ============================================= + +GRANT ALL ON public.experiment_phase_executions TO authenticated; +GRANT SELECT ON public.soaking_view TO authenticated; +GRANT SELECT ON public.airdrying_view TO authenticated; +GRANT SELECT ON public.cracking_view TO authenticated; +GRANT SELECT ON public.shelling_view TO authenticated; + +-- ============================================= +-- 8. ENABLE ROW LEVEL SECURITY +-- ============================================= + +ALTER TABLE public.experiment_phase_executions ENABLE ROW LEVEL SECURITY; + +-- ============================================= +-- 9. CREATE RLS POLICIES +-- ============================================= + +CREATE POLICY "Phase executions are viewable by authenticated users" + ON public.experiment_phase_executions + FOR SELECT USING (auth.role() = 'authenticated'); + +CREATE POLICY "Phase executions are insertable by authenticated users" + ON public.experiment_phase_executions + FOR INSERT WITH CHECK (auth.role() = 'authenticated'); + +CREATE POLICY "Phase executions are updatable by authenticated users" + ON public.experiment_phase_executions + FOR UPDATE USING (auth.role() = 'authenticated'); + +CREATE POLICY "Phase executions are deletable by authenticated users" + ON public.experiment_phase_executions + FOR DELETE USING (auth.role() = 'authenticated'); + diff --git a/management-dashboard-web-app/vite.config.ts b/management-dashboard-web-app/vite.config.ts index bd8d19a..7d7b35f 100755 --- a/management-dashboard-web-app/vite.config.ts +++ b/management-dashboard-web-app/vite.config.ts @@ -40,6 +40,12 @@ export default defineConfig({ changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, ''), }, + '/media-api': { + // Proxy media-api requests to the media-api container + target: 'http://host.docker.internal:8090', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/media-api/, ''), + }, }, }, }) diff --git a/media-api/main.py b/media-api/main.py index 97bf055..980b7ce 100644 --- a/media-api/main.py +++ b/media-api/main.py @@ -20,10 +20,10 @@ transcoding_semaphore = threading.Semaphore(MAX_CONCURRENT_TRANSCODING) app = FastAPI(title="Media API", version="0.1.0") -# CORS for dashboard at exp-dash:8080 (and localhost for convenience) +# CORS for dashboard - allow all origins to support access from different IPs/hostnames app.add_middleware( CORSMiddleware, - allow_origins=["http://exp-dash:8080", "http://localhost:8080"], + allow_origins=["*"], # Allow all origins for flexibility allow_credentials=True, allow_methods=["*"], allow_headers=["*"] @@ -143,15 +143,25 @@ def get_video_mime_type(path: pathlib.Path) -> str: return mime_types.get(ext, "video/mp4") -def open_file_range(path: pathlib.Path, start: int, end: Optional[int]): +def generate_file_range(path: pathlib.Path, start: int, end: Optional[int], chunk_size: int = 8192): + """ + Generator that yields file chunks in a memory-efficient way. + This prevents MemoryError when streaming large video files. + """ file_size = path.stat().st_size if end is None or end >= file_size: end = file_size - 1 - length = end - start + 1 + + remaining = end - start + 1 with open(path, 'rb') as f: f.seek(start) - chunk = f.read(length) - return chunk, file_size, start, end + while remaining > 0: + read_size = min(chunk_size, remaining) + chunk = f.read(read_size) + if not chunk: + break + yield chunk + remaining -= len(chunk) @app.head("/videos/{file_id:path}/stream") @@ -212,14 +222,22 @@ def stream_file(request: Request, file_id: str): if start > end: raise HTTPException(status_code=416, detail="Range Not Satisfiable") - chunk, size, actual_start, actual_end = open_file_range(p, start, end) + # Calculate content length + content_length = end - start + 1 + # Use streaming response to avoid loading entire chunk into memory + # This prevents MemoryError for large video files headers = { **base_headers, - "Content-Range": f"bytes {actual_start}-{actual_end}/{size}", - "Content-Length": str(len(chunk)), + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Content-Length": str(content_length), } - return Response(content=chunk, status_code=206, headers=headers) + return StreamingResponse( + generate_file_range(p, start, end), + media_type=content_type, + headers=headers, + status_code=206 + ) # Convenience endpoint: pass file_id via query instead of path (accepts raw or URL-encoded) @app.head("/videos/stream") diff --git a/vision-system-remote/src/App.tsx b/vision-system-remote/src/App.tsx index cccbe3c..77adad1 100644 --- a/vision-system-remote/src/App.tsx +++ b/vision-system-remote/src/App.tsx @@ -8,6 +8,7 @@ import { CameraCountWidget } from './widgets/CameraCountWidget' import { CameraCard } from './components/CameraCard' import { CameraPreviewModal } from './components/CameraPreviewModal' import { CameraConfigModal } from './components/CameraConfigModal' +import { MqttDebugPanel } from './components/MqttDebugPanel' // Get WebSocket URL from environment or construct it const getWebSocketUrl = () => { @@ -38,6 +39,7 @@ export default function App() { const [previewCamera, setPreviewCamera] = useState(null) const [configModalOpen, setConfigModalOpen] = useState(false) const [selectedCamera, setSelectedCamera] = useState(null) + const [debugPanelOpen, setDebugPanelOpen] = useState(false) // WebSocket connection const { isConnected, subscribe } = useWebSocket(getWebSocketUrl()) @@ -513,7 +515,25 @@ export default function App() { setNotification({ type: 'error', message: error }) }} /> - )} + + {/* MQTT Debug Panel */} + setDebugPanelOpen(false)} + /> + + {/* Debug Button - Bottom Right */} + ) } diff --git a/vision-system-remote/src/components/MqttDebugPanel.tsx b/vision-system-remote/src/components/MqttDebugPanel.tsx new file mode 100644 index 0000000..6d5e7b8 --- /dev/null +++ b/vision-system-remote/src/components/MqttDebugPanel.tsx @@ -0,0 +1,120 @@ +import React, { useState } from 'react' +import { visionApi } from '../services/api' + +interface MqttDebugPanelProps { + isOpen: boolean + onClose: () => void +} + +export const MqttDebugPanel: React.FC = ({ isOpen, onClose }) => { + const [loading, setLoading] = useState(null) + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) + + const publishMessage = async (topic: string, payload: string) => { + const action = `${topic.split('/').pop()} → ${payload}` + setLoading(action) + setMessage(null) + + try { + const result = await visionApi.publishMqttMessage(topic, payload, 0, false) + if (result.success) { + setMessage({ type: 'success', text: `Published: ${action}` }) + setTimeout(() => setMessage(null), 3000) + } else { + setMessage({ type: 'error', text: `Failed: ${result.message}` }) + } + } catch (error: any) { + setMessage({ type: 'error', text: `Error: ${error.message || 'Failed to publish message'}` }) + } finally { + setLoading(null) + } + } + + if (!isOpen) return null + + return ( +
+
+ {/* Header */} +
+

MQTT Debug Panel

+ +
+ + {/* Content */} +
+ {/* Message Status */} + {message && ( +
+ {message.text} +
+ )} + + {/* Vibratory Conveyor Section */} +
+

Vibratory Conveyor

+

Topic: vision/vibratory_conveyor/state

+
+ + +
+
+ + {/* Blower Separator Section */} +
+

Blower Separator

+

Topic: vision/blower_separator/state

+
+ + +
+
+ + {/* Info */} +
+

+ Note: These buttons publish MQTT messages to test auto-recording. + Check the logs to see if cameras start/stop recording automatically. +

+
+
+
+
+ ) +} + diff --git a/vision-system-remote/src/services/api.ts b/vision-system-remote/src/services/api.ts index 82c0324..bc07cad 100644 --- a/vision-system-remote/src/services/api.ts +++ b/vision-system-remote/src/services/api.ts @@ -239,6 +239,13 @@ class VisionApiClient { return this.request(`/mqtt/events?limit=${limit}`) } + async publishMqttMessage(topic: string, payload: string, qos: number = 0, retain: boolean = false): Promise<{ success: boolean; message: string; topic: string; payload: string }> { + return this.request('/mqtt/publish', { + method: 'POST', + body: JSON.stringify({ topic, payload, qos, retain }), + }) + } + async startRecording(cameraName: string, filename?: string): Promise { return this.request(`/cameras/${cameraName}/start-recording`, { method: 'POST',