diff --git a/GUI/grafana/dashboards/ultrasonic-dashboard.json b/GUI/grafana/dashboards/ultrasonic-dashboard.json new file mode 100644 index 000000000..15c98c4b3 --- /dev/null +++ b/GUI/grafana/dashboards/ultrasonic-dashboard.json @@ -0,0 +1,857 @@ +{ + "id": null, + "uid": "ultrasonic-plant-dashboard-bw-01", + "title": "Plant Health Monitoring - Professional Dashboard", + "tags": [ + "postgres", + "ultrasonic", + "plants", + "agriculture" + ], + "timezone": "browser", + "schemaVersion": 36, + "version": 6, + "refresh": "10s", + "time": { + "from": "now-30d", + "to": "now" + }, + "panels": [ + { + "id": 1, + "type": "stat", + "title": "Total Predictions", + "gridPos": { + "h": 8, + "w": 6, + "x": 0, + "y": 0 + }, + "targets": [ + { + "expr": "last_over_time(ultrasonic_predictions_total_total[5m])", + "refId": "A", + "legendFormat": "Total" + } + ], + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "options": { + "colorMode": "value", + "graphMode": "none", + "orientation": "auto", + "textMode": "auto" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "#000000" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#000000", + "value": null + } + ] + } + } + } + }, + { + "id": 2, + "type": "stat", + "title": "Success Rate", + "gridPos": { + "h": 8, + "w": 6, + "x": 6, + "y": 0 + }, + "targets": [ + { + "expr": "avg(last_over_time(ultrasonic_success_rate_by_sensor_success_rate[5m]))", + "refId": "A", + "legendFormat": "Success Rate" + } + ], + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "options": { + "colorMode": "value", + "graphMode": "none", + "orientation": "auto", + "textMode": "auto" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "unit": "percent", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#DC2626", + "value": null + }, + { + "color": "#000000", + "value": 60 + }, + { + "color": "#15803d", + "value": 90 + } + ] + } + } + } + }, + { + "id": 3, + "type": "stat", + "title": "Healthy Status", + "gridPos": { + "h": 8, + "w": 6, + "x": 12, + "y": 0 + }, + "targets": [ + { + "expr": "last_over_time(ultrasonic_class_distribution_healthy_healthy_percentage[5m])", + "refId": "A", + "legendFormat": "Healthy %" + } + ], + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "options": { + "colorMode": "value", + "graphMode": "none", + "orientation": "auto", + "textMode": "auto" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "#000000" + }, + "unit": "percent", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#000000", + "value": null + } + ] + } + } + } + }, + { + "id": 4, + "type": "stat", + "title": "Stress Status", + "gridPos": { + "h": 8, + "w": 6, + "x": 18, + "y": 0 + }, + "targets": [ + { + "expr": "last_over_time(ultrasonic_class_distribution_stress_stress_percentage[5m])", + "refId": "A", + "legendFormat": "Stress %" + } + ], + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "options": { + "colorMode": "value", + "graphMode": "none", + "orientation": "auto", + "textMode": "auto" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "#000000" + }, + "unit": "percent", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#000000", + "value": null + } + ] + } + } + } + }, + { + "id": 5, + "type": "piechart", + "title": "Plant Health Classification", + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 8 + }, + "targets": [ + { + "expr": "ultrasonic_predictions_by_class_count", + "refId": "A", + "legendFormat": "{{predicted_class}}", + "instant": true + } + ], + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "multi" + }, + "pieType": "pie", + "displayLabels": [ + "name", + "percent" + ] + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Drought_Plant" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#FBBF24" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Pest_Plant" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#EF4444" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Control_Greenhouse" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#10B981" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Control_Empty" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#3B82F6" + } + } + ] + } + ] + } + }, + { + "id": 6, + "type": "gauge", + "title": "Average Confidence Score", + "gridPos": { + "h": 9, + "w": 6, + "x": 12, + "y": 8 + }, + "targets": [ + { + "expr": "last_over_time(ultrasonic_predictions_avg_confidence_avg_confidence[5m])", + "refId": "A", + "legendFormat": "Avg Confidence" + } + ], + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "options": { + "showThresholdLabels": true, + "showThresholdMarkers": true + }, + "fieldConfig": { + "defaults": { + "min": 0, + "max": 1, + "unit": "percentunit", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#DC2626", + "value": null + }, + { + "color": "#6B7280", + "value": 0.5 + }, + { + "color": "#15803d", + "value": 0.85 + } + ] + } + } + } + }, + { + "id": 7, + "type": "barchart", + "title": "Sensor Confidence by ID", + "gridPos": { + "h": 9, + "w": 6, + "x": 18, + "y": 8 + }, + "targets": [ + { + "expr": "ultrasonic_confidence_by_sensor_avg_confidence", + "refId": "A", + "legendFormat": "{{sensor_id}}", + "instant": true + } + ], + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "options": { + "legend": { + "displayMode": "hidden", + "placement": "bottom", + "showLegend": false + }, + "orientation": "horizontal", + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0, + "showValue": "always" + }, + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 90, + "lineWidth": 1 + }, + "color": { + "mode": "fixed", + "fixedColor": "#374151" + }, + "unit": "percentunit", + "decimals": 2 + } + }, + "transformations": [ + { + "id": "reduce", + "options": { + "calcs": [ + "last" + ] + } + } + ] + }, + { + "id": 8, + "type": "timeseries", + "title": "Predictions per Hour (Last 24h)", + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 17 + }, + "timeFrom": "now-24h", + "targets": [ + { + "expr": "ultrasonic_predictions_per_hour_count", + "refId": "A", + "legendFormat": "Predictions" + } + ], + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "tooltip": { + "mode": "multi" + } + }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "bars", + "lineInterpolation": "linear", + "fillOpacity": 80, + "lineWidth": 0 + }, + "color": { + "mode": "fixed", + "fixedColor": "#6B7280" + } + } + } + }, + { + "id": 9, + "type": "barchart", + "title": "Daily Stress Events", + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 17 + }, + "targets": [ + { + "expr": "ultrasonic_daily_stress_count_event_count", + "refId": "A", + "legendFormat": "{{event_date}}", + "format": "time_series" + } + ], + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "options": { + "legend": { + "displayMode": "hidden", + "placement": "bottom", + "showLegend": false + }, + "orientation": "auto", + "xTickLabelRotation": -45, + "xTickLabelSpacing": 50, + "showValue": "auto" + }, + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 85, + "lineWidth": 1 + }, + "color": { + "mode": "fixed", + "fixedColor": "#6B7280" + } + } + } + }, + { + "id": 10, + "type": "piechart", + "title": "Reading Status Distribution", + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 26 + }, + "targets": [ + { + "expr": "ultrasonic_predictions_by_status_count", + "refId": "A", + "legendFormat": "{{status}}", + "instant": true + } + ], + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "displayLabels": [ + "name", + "percent" + ] + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Success" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#9CA3AF" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Error" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#DC2626" + } + } + ] + } + ] + } + }, + { + "id": 11, + "type": "barchart", + "title": "Sensor Success Rates", + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 26 + }, + "targets": [ + { + "expr": "ultrasonic_success_rate_by_sensor_success_rate", + "refId": "A", + "legendFormat": "{{sensor_id}}", + "instant": true + } + ], + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "options": { + "legend": { + "displayMode": "hidden", + "showLegend": false + }, + "orientation": "auto", + "xTickLabelRotation": -45, + "showValue": "always" + }, + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 90 + }, + "color": { + "mode": "thresholds" + }, + "unit": "percent", + "decimals": 1, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#DC2626", + "value": 0 + }, + { + "color": "#6B7280", + "value": 70 + }, + { + "color": "#15803d", + "value": 95 + } + ] + } + } + }, + "transformations": [ + { + "id": "reduce", + "options": { + "calcs": [ + "last" + ] + } + } + ] + }, + { + "id": 12, + "type": "piechart", + "title": "Confidence Distribution", + "gridPos": { + "h": 9, + "w": 8, + "x": 16, + "y": 26 + }, + "targets": [ + { + "expr": "ultrasonic_confidence_distribution_count", + "refId": "A", + "legendFormat": "{{confidence_range}}", + "instant": true + } + ], + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "displayLabels": [ + "name", + "percent" + ] + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "90-100% (High)" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#34D399" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "75-90% (Good)" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#60A5FA" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "60-75% (Medium)" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#FCD34D" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "< 60% (Low)" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#F87171" + } + } + ] + } + ] + } + }, + { + "id": 13, + "type": "table", + "title": "Sensor Activity Summary", + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 35 + }, + "targets": [ + { + "expr": "ultrasonic_confidence_by_sensor_avg_confidence", + "refId": "A", + "format": "table", + "instant": true + } + ], + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "options": { + "showHeader": true, + "sortBy": [ + { + "displayName": "avg_confidence", + "desc": true + } + ] + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "width": "auto" + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "avg_confidence" + }, + "properties": [ + { + "id": "unit", + "value": "percentunit" + }, + { + "id": "decimals", + "value": 2 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "min_confidence" + }, + "properties": [ + { + "id": "unit", + "value": "percentunit" + }, + { + "id": "decimals", + "value": 2 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "max_confidence" + }, + "properties": [ + { + "id": "unit", + "value": "percentunit" + }, + { + "id": "decimals", + "value": 2 + } + ] + } + ] + }, + "transformations": [] + } + ] +} \ No newline at end of file diff --git a/GUI/src/vast/dashboard_api.py b/GUI/src/vast/dashboard_api.py index 845ff093b..ab0a6d281 100644 --- a/GUI/src/vast/dashboard_api.py +++ b/GUI/src/vast/dashboard_api.py @@ -445,121 +445,318 @@ def get_phi_for_current_image(self) -> dict: # ===================================================== - # ===== ADDED: AUDIO ANALYTICS METHODS ===== + # ===== AUDIO ANALYTICS METHODS WITH SOUND FILTER ===== # ===================================================== - def get_audio_stats(self, time_range: str = 'all') -> Dict: - """ - Get aggregated audio classification statistics. - """ + + def get_audio_stats(self, time_range: str = 'all', sound_types: List[str] = None) -> Dict: + """Get aggregated audio classification statistics""" time_filter = { 'all': '', 'hour': "AND r.started_at > NOW() - INTERVAL '1 hour'", 'day': "AND r.started_at > NOW() - INTERVAL '24 hours'", - 'week': "AND r.started_at > NOW() - INTERVAL '7 days'" + 'week': "AND r.started_at > NOW() - INTERVAL '7 days'", + 'month': "AND r.started_at > NOW() - INTERVAL '30 days'" }.get(time_range, '') + sound_filter = "" + if sound_types and len(sound_types) > 0: + sound_list = "'" + "','".join(sound_types) + "'" + sound_filter = f"AND fa.head_pred_label IN ({sound_list})" + query = f""" SELECT - COUNT(*) AS total_files, - SUM(CASE WHEN fa.head_is_another = true THEN 1 ELSE 0 END) AS unknown_count, - AVG(fa.head_pred_prob) AS avg_confidence, - AVG(fa.processing_ms) AS avg_processing_ms + COUNT(*) as total_files, + SUM(CASE WHEN head_is_another = true THEN 1 ELSE 0 END) as unknown_count, + AVG(head_pred_prob) as avg_confidence, + AVG(processing_ms) as avg_processing_ms FROM agcloud_audio.file_aggregates fa - JOIN agcloud_audio.runs r - ON fa.run_id = r.run_id - WHERE 1=1 {time_filter} + JOIN agcloud_audio.runs r ON fa.run_id = r.run_id + WHERE 1=1 {time_filter} {sound_filter} """ + results = self.run_query(query) return results[0] if results else {} - def get_audio_distribution(self, time_range: str = 'all', limit: int = 10) -> List[Dict]: - """ - Get distribution of audio classifications (for pie chart). - """ + def get_audio_distribution(self, time_range: str = 'all', limit: int = 10, sound_types: List[str] = None) -> List[Dict]: + """Get distribution of audio classifications""" time_filter = { 'all': '', 'hour': "AND r.started_at > NOW() - INTERVAL '1 hour'", 'day': "AND r.started_at > NOW() - INTERVAL '24 hours'", - 'week': "AND r.started_at > NOW() - INTERVAL '7 days'" + 'week': "AND r.started_at > NOW() - INTERVAL '7 days'", + 'month': "AND r.started_at > NOW() - INTERVAL '30 days'" }.get(time_range, '') + sound_filter = "" + if sound_types and len(sound_types) > 0: + sound_list = "'" + "','".join(sound_types) + "'" + sound_filter = f"AND fa.head_pred_label IN ({sound_list})" + query = f""" SELECT - fa.head_pred_label, - COUNT(*) AS count + head_pred_label, + COUNT(*) as count FROM agcloud_audio.file_aggregates fa - JOIN agcloud_audio.runs r - ON fa.run_id = r.run_id - WHERE fa.head_pred_label IS NOT NULL - {time_filter} - GROUP BY fa.head_pred_label + JOIN agcloud_audio.runs r ON fa.run_id = r.run_id + WHERE head_pred_label IS NOT NULL {time_filter} {sound_filter} + GROUP BY head_pred_label ORDER BY count DESC LIMIT {limit} """ + return self.run_query(query) - def get_audio_confidence_by_class(self, time_range: str = 'all', limit: int = 10) -> List[Dict]: - """ - Get average confidence levels by classification (for bar chart). - """ + def get_audio_confidence_by_class(self, time_range: str = 'all', limit: int = 10, sound_types: List[str] = None) -> List[Dict]: + """Get average confidence levels by classification""" time_filter = { 'all': '', 'hour': "AND r.started_at > NOW() - INTERVAL '1 hour'", 'day': "AND r.started_at > NOW() - INTERVAL '24 hours'", - 'week': "AND r.started_at > NOW() - INTERVAL '7 days'" + 'week': "AND r.started_at > NOW() - INTERVAL '7 days'", + 'month': "AND r.started_at > NOW() - INTERVAL '30 days'" }.get(time_range, '') + sound_filter = "" + if sound_types and len(sound_types) > 0: + sound_list = "'" + "','".join(sound_types) + "'" + sound_filter = f"AND fa.head_pred_label IN ({sound_list})" + query = f""" SELECT - fa.head_pred_label, - AVG(fa.head_pred_prob) AS avg_confidence + head_pred_label, + AVG(head_pred_prob) as avg_confidence FROM agcloud_audio.file_aggregates fa - JOIN agcloud_audio.runs r - ON fa.run_id = r.run_id - WHERE fa.head_pred_label IS NOT NULL - AND fa.head_pred_prob IS NOT NULL - {time_filter} - GROUP BY fa.head_pred_label + JOIN agcloud_audio.runs r ON fa.run_id = r.run_id + WHERE head_pred_label IS NOT NULL + AND head_pred_prob IS NOT NULL + {time_filter} + {sound_filter} + GROUP BY head_pred_label ORDER BY avg_confidence DESC LIMIT {limit} """ + return self.run_query(query) - def get_audio_critical_events(self, time_range: str = 'day', limit: int = 100) -> List[Dict]: - """ - Get critical sound events (fire, screaming, shotgun, predatory animals). + def get_audio_detailed_table(self, time_range: str = 'all', limit: int = 20, sound_types: List[str] = None) -> List[Dict]: + """Get detailed table data with class probabilities""" + time_filter = { + 'all': '', + 'hour': "AND r.started_at > NOW() - INTERVAL '1 hour'", + 'day': "AND r.started_at > NOW() - INTERVAL '24 hours'", + 'week': "AND r.started_at > NOW() - INTERVAL '7 days'", + 'month': "AND r.started_at > NOW() - INTERVAL '30 days'" + }.get(time_range, '') + + sound_filter = "" + if sound_types and len(sound_types) > 0: + sound_list = "'" + "','".join(sound_types) + "'" + sound_filter = f"AND fa.head_pred_label IN ({sound_list})" + + query = f""" + SELECT + head_pred_label, + COUNT(*) as count, + AVG(head_pred_prob) as avg_prob, + AVG((head_probs_json->>'predatory_animals')::float) as p_predatory, + AVG((head_probs_json->>'birds')::float) as p_birds, + AVG((head_probs_json->>'fire')::float) as p_fire, + AVG((head_probs_json->>'screaming')::float) as p_screaming, + AVG((head_probs_json->>'shotgun')::float) as p_shotgun + FROM agcloud_audio.file_aggregates fa + JOIN agcloud_audio.runs r ON fa.run_id = r.run_id + WHERE head_pred_label IS NOT NULL {time_filter} {sound_filter} + GROUP BY head_pred_label + ORDER BY count DESC + LIMIT {limit} """ + + return self.run_query(query) + + def get_audio_critical_events(self, time_range: str = 'day', limit: int = 100, sound_types: List[str] = None) -> List[Dict]: + """Get critical sound events""" time_filter = { 'hour': "AND r.started_at > NOW() - INTERVAL '1 hour'", - 'day': "AND r.started_at > NOW() - INTERVAL '24 hours'", - 'week': "AND r.started_at > NOW() - INTERVAL '7 days'" + 'day': "AND r.started_at > NOW() - INTERVAL '24 hours'", + 'week': "AND r.started_at > NOW() - INTERVAL '7 days'", + 'month': "AND r.started_at > NOW() - INTERVAL '30 days'" }.get(time_range, "AND r.started_at > NOW() - INTERVAL '24 hours'") + if sound_types and len(sound_types) > 0: + sound_list = "'" + "','".join(sound_types) + "'" + sound_filter = f"AND fa.head_pred_label IN ({sound_list})" + else: + sound_filter = "AND fa.head_pred_label IN ('fire', 'screaming', 'shotgun', 'predatory_animals')" + query = f""" SELECT r.run_id, r.started_at, - snsc.file_name, - snsc.key AS s3_key, - sm.device_id, - sm.capture_time, - fa.head_pred_label AS event_type, - fa.head_pred_prob AS confidence, + f.path as file_path, + fa.head_pred_label as event_type, + fa.head_pred_prob as confidence, fa.head_probs_json FROM agcloud_audio.file_aggregates fa - JOIN agcloud_audio.runs r - ON fa.run_id = r.run_id - JOIN public.sound_new_sounds_connections snsc - ON fa.file_id = snsc.id - LEFT JOIN public.sounds_metadata sm - ON snsc.file_name = sm.file_name - WHERE fa.head_pred_label IN ('fire', 'screaming', 'shotgun', 'predatory_animals') - {time_filter} + JOIN agcloud_audio.runs r ON fa.run_id = r.run_id + JOIN agcloud_audio.files f ON fa.file_id = f.file_id + WHERE 1=1 + {time_filter} + {sound_filter} ORDER BY r.started_at DESC, fa.head_pred_prob DESC LIMIT {limit} """ + + return self.run_query(query) + + def get_audio_timeline(self, time_range: str = 'day', sound_types: List[str] = None) -> List[Dict]: + """Get audio alert timeline data grouped by time buckets""" + bucket_interval = { + 'day': 1, + 'week': 6, + 'month': 24 + }.get(time_range, 1) + + time_filter_map = { + 'day': "AND r.started_at > NOW() - INTERVAL '24 hours'", + 'week': "AND r.started_at > NOW() - INTERVAL '7 days'", + 'month': "AND r.started_at > NOW() - INTERVAL '30 days'" + } + time_filter = time_filter_map.get(time_range, "AND r.started_at > NOW() - INTERVAL '24 hours'") + + sound_filter = "" + if sound_types and len(sound_types) > 0: + sound_list = "'" + "','".join(sound_types) + "'" + sound_filter = f"AND fa.head_pred_label IN ({sound_list})" + + query = f""" + SELECT + date_trunc('hour', r.started_at) + + INTERVAL '{bucket_interval} hours' * + (EXTRACT(hour FROM r.started_at)::int / {bucket_interval}) as time_bucket, + fa.head_pred_label, + COUNT(*) as count + FROM agcloud_audio.file_aggregates fa + JOIN agcloud_audio.runs r ON fa.run_id = r.run_id + WHERE fa.head_pred_label IS NOT NULL + {time_filter} + {sound_filter} + GROUP BY time_bucket, fa.head_pred_label + ORDER BY time_bucket ASC, count DESC + """ + + return self.run_query(query) + + def get_audio_heatmap(self, time_range: str = 'week', sound_types: List[str] = None) -> List[Dict]: + """Get audio detection heatmap data - hour of day vs day of week""" + time_filter_map = { + 'day': "AND r.started_at > NOW() - INTERVAL '24 hours'", + 'week': "AND r.started_at > NOW() - INTERVAL '7 days'", + 'month': "AND r.started_at > NOW() - INTERVAL '30 days'" + } + time_filter = time_filter_map.get(time_range, "AND r.started_at > NOW() - INTERVAL '7 days'") + + sound_filter = "" + if sound_types and len(sound_types) > 0: + sound_list = "'" + "','".join(sound_types) + "'" + sound_filter = f"AND fa.head_pred_label IN ({sound_list})" + + query = f""" + SELECT + EXTRACT(HOUR FROM r.started_at) as hour_of_day, + EXTRACT(DOW FROM r.started_at) as day_of_week, + fa.head_pred_label as sound_type, + COUNT(*) as count + FROM agcloud_audio.file_aggregates fa + JOIN agcloud_audio.runs r ON fa.run_id = r.run_id + WHERE fa.head_pred_label IS NOT NULL + {time_filter} + {sound_filter} + GROUP BY hour_of_day, day_of_week, fa.head_pred_label + ORDER BY day_of_week, hour_of_day + """ + + return self.run_query(query) + + def get_audio_correlations(self, time_range: str = 'day', sound_types: List[str] = None) -> List[Dict]: + """Get sound detection data for correlation analysis using linked_time from sound_new_sounds_connections""" + bucket_interval = { + 'day': 1, + 'week': 6, + 'month': 24 + }.get(time_range, 1) + + time_filter_map = { + 'day': "AND c.linked_time > NOW() - INTERVAL '24 hours'", + 'week': "AND c.linked_time > NOW() - INTERVAL '7 days'", + 'month': "AND c.linked_time > NOW() - INTERVAL '30 days'" + } + time_filter = time_filter_map.get(time_range, "AND c.linked_time > NOW() - INTERVAL '24 hours'") + + sound_filter = "" + if sound_types and len(sound_types) > 0: + sound_list = "'" + "','".join(sound_types) + "'" + sound_filter = f"AND fa.head_pred_label IN ({sound_list})" + + query = f""" + SELECT + (date_trunc('hour', c.linked_time) + - (INTERVAL '1 hour' * (EXTRACT(hour FROM c.linked_time)::int % {bucket_interval})) + ) AS time_bucket, + fa.head_pred_label AS sound_type, + COUNT(*) AS detection_count + FROM agcloud_audio.file_aggregates fa + JOIN public.sound_new_sounds_connections c + ON c.id = fa.file_id + WHERE fa.head_pred_label IS NOT NULL + {time_filter} + {sound_filter} + GROUP BY time_bucket, fa.head_pred_label + ORDER BY time_bucket ASC + """ + return self.run_query(query) + def get_model_health_metrics(self, time_range: str = 'day', sound_types: List[str] = None) -> List[Dict]: + """Get model health metrics over time""" + bucket_interval = { + 'day': 1, + 'week': 6, + 'month': 24 + }.get(time_range, 1) + + time_filter_map = { + 'day': "AND r.started_at > NOW() - INTERVAL '24 hours'", + 'week': "AND r.started_at > NOW() - INTERVAL '7 days'", + 'month': "AND r.started_at > NOW() - INTERVAL '30 days'" + } + time_filter = time_filter_map.get(time_range, "AND r.started_at > NOW() - INTERVAL '24 hours'") + + sound_filter = "" + if sound_types and len(sound_types) > 0: + sound_list = "'" + "','".join(sound_types) + "'" + sound_filter = f"AND fa.head_pred_label IN ({sound_filter})" + + query = f""" + SELECT + date_trunc('hour', r.started_at) + + INTERVAL '{bucket_interval} hours' * + (EXTRACT(hour FROM r.started_at)::int / {bucket_interval}) as time_bucket, + AVG(fa.head_pred_prob) as avg_confidence, + AVG(fa.processing_ms) as avg_processing_ms, + COUNT(*) as total_predictions, + SUM(CASE WHEN fa.head_is_another = true THEN 1 ELSE 0 END) as unknown_count, + (SUM(CASE WHEN fa.head_is_another = true THEN 1 ELSE 0 END)::float / + NULLIF(COUNT(*), 0)) * 100 as error_rate_pct + FROM agcloud_audio.file_aggregates fa + JOIN agcloud_audio.runs r ON fa.run_id = r.run_id + WHERE fa.head_pred_label IS NOT NULL + {time_filter} + {sound_filter} + GROUP BY time_bucket + ORDER BY time_bucket ASC + """ + + return self.run_query(query) # ===================================================== @@ -614,4 +811,304 @@ def get_ripeness_stats(self) -> Dict: FROM ripeness_predictions """ results = self.run_query(query) - return results[0] if results else {} \ No newline at end of file + return results[0] if results else {} + + + # === Aerial imagery dashboard extensions === + def get_all_fields_images(self): + + rows = self.list_latest_images_per_gis() + results = [] + + for row in rows: + img_key = row.get("img_key") or row.get("image_key") or row.get("file_key") + + if not img_key: + print("[WARN] missing img_key in row:", row) + continue + + img_bytes = self.get_image_bytes_from_minio(img_key) + if not img_bytes: + print("[WARN] failed to load image:", img_key) + continue + + results.append({ + "field": row.get("gis", "Unknown"), + "timestamp": row.get("timestamp_utc", ""), + "image_bytes": img_bytes, + "key": img_key + }) + + return results + def _get_table_rows_paged(self, table_name: str, page_size: int = 200) -> List[dict]: + """Fetch ALL rows from a table using pagination (client-side).""" + all_rows = [] + offset = 0 + + while True: + params = {"limit": page_size, "offset": offset} + url = f"{self.base}/api/tables/{table_name}" + try: + r = self.http.get(url, params=params, timeout=10) + if r.status_code != 200: + break + batch = r.json().get("rows", []) + if not batch: + break + all_rows.extend(batch) + offset += page_size + except Exception as e: + print(f"[API FAIL PAGED] {e}") + break + + return all_rows + + def list_aerial_metadata(self) -> list[dict]: + """ + Images metadata – time, drone, geom. + Backed by table: aerial_images_metadata + """ + return self._get_table_rows_paged("aerial_images_metadata", page_size=200) + + + def list_aerial_complete_metadata(self) -> list[dict]: + """ + Complete metadata including img_key mapping. + Backed by table: aerial_images_complete_metadata + """ + return self._get_table_rows_paged("aerial_images_complete_metadata", page_size=200) + + def list_object_detections(self) -> list[dict]: + """ + Detected objects from aerial images. + Backed by table: aerial_image_object_detections + """ + return self._get_table_rows_paged("aerial_image_object_detections", page_size=200) + + def list_anomaly_detections(self) -> list[dict]: + """ + Detected anomalies from aerial images. + Backed by table: aerial_image_anomaly_detections + """ + return self._get_table_rows_paged("aerial_image_anomaly_detections", page_size=200) + + def list_aerial_connections(self) -> list[dict]: + """ + New aerial image connections over time. + Backed by table: image_new_aerial_connections + """ + return self._get_table_rows_paged("image_new_aerial_connections", page_size=200) + + + # --- Latest image per GIS --- + def list_latest_images_per_gis(self): + """Get the most recent image for each GIS""" + url = f"{self.base}/api/tables/aerial_images_complete_metadata" + try: + r = self.http.get(url, timeout=10) + if r.status_code != 200: + print(f"[API ERROR] {r.status_code}: {r.text[:200]}") + return [] + data = r.json() + rows = data.get("rows", data) + if not rows: + return [] + + rows.sort(key=lambda x: x.get("timestamp_utc") or x.get("created_at") or "", reverse=True) + + latest = {} + for row in rows: + gis = row.get("gis") + if gis and gis not in latest: + latest[gis] = row + return list(latest.values()) + except Exception as e: + print(f"[API FAIL] {e}") + return [] + + # --- All dates for a given GIS --- + def list_dates_for_gis(self, gis_point: str): + """Return all available dates for same GIS location""" + url = f"{self.base}/api/tables/aerial_images_complete_metadata" + try: + r = self.http.get(url, timeout=10) + if r.status_code == 200: + rows = r.json().get("rows", []) + gis_rows = [x for x in rows if x.get("gis") == gis_point] + return sorted({x["timestamp_utc"][:10] for x in gis_rows if x.get("timestamp_utc")}) + except Exception as e: + print(f"[API FAIL] {e}") + return [] + + # --- All images for a specific GIS --- + def list_all_images_for_gis(self, gis_point: str): + url = f"{self.base}/api/tables/aerial_images_complete_metadata" + try: + r = self.http.get(url, timeout=10) + if r.status_code == 200: + rows = r.json().get("rows", []) + return sorted( + [x for x in rows if x.get("gis") == gis_point], + key=lambda x: x.get("timestamp_utc"), + reverse=True + ) + except Exception as e: + print(f"[API FAIL] {e}") + return [] + + def list_anomalies(self): + url = f"{self.base}/api/tables/aerial_image_anomaly_detections" + try: + r = self.http.get(url, timeout=10) + if r.status_code == 200: + return r.json().get("rows", []) + except Exception as e: + print(f"[API FAIL] {e}") + return [] + + def list_objects(self): + url = f"{self.base}/api/tables/aerial_image_object_detections" + try: + r = self.http.get(url, timeout=10) + if r.status_code == 200: + return r.json().get("rows", []) + except Exception as e: + print(f"[API FAIL] {e}") + return [] + + def list_polygons(self): + url = f"{self.base}/api/tables/field_polygons" + try: + r = self.http.get(url, timeout=10) + if r.status_code == 200: + return r.json().get("rows", []) + except Exception as e: + print(f"[API FAIL] {e}") + return [] + + + + def get_image_bytes_from_minio(self, key: str) -> bytes | None: + try: + bucket = "imagery" + if key.startswith(f"{bucket}/"): + key = key[len(f"{bucket}/"):] + client = Minio( + "minio-hot:9000", + access_key="minioadmin", + secret_key="minioadmin123", + secure=False, + ) + response = client.get_object(bucket, key) + data = response.read() + response.close() + response.release_conn() + return data + except S3Error as e: + print(f"[MINIO ERROR] {e}") + except Exception as e: + print(f"[GENERAL ERROR] {e}") + return None + + + # ---------- DB Writer ---------- + def write_to_db_api(self, table: str, payload: dict) -> bool: + url = f"{self.base}/api/tables/{table}" + + print("\n=== DEBUG INSERT ===") + print("URL:", url) + print("Payload:", json.dumps(payload, indent=4)) + print("====================\n") + + try: + r = self.http.post(url, json=payload, timeout=10) + if 200 <= r.status_code < 300: + print(f"[DB] INSERT OK → {table}") + return True + + print(f"[DB] INSERT FAILED ({table}): {r.status_code} {r.text[:300]}") + return False + + except Exception as e: + print("[DB][ERROR]", e) + return False + + + + + def update_db_api(self, table: str, row_id: int, payload: dict) -> bool: + url = f"{self.base}/api/tables/{table}" + + request_body = { + "keys": {"id": row_id}, + "data": payload + } + + print("\n=== DEBUG UPDATE ===") + print("URL:", url) + print("Payload:", json.dumps(request_body, indent=4)) + print("====================\n") + + try: + r = self.http.put(url, json=request_body, timeout=10) + if 200 <= r.status_code < 300: + print(f"[DB] UPDATE OK → row {row_id}") + return True + + print(f"[DB] UPDATE FAILED ({table}): {r.status_code} {r.text[:300]}") + return False + + except Exception as e: + print("[DB][ERROR]", e) + return False + + + + + + + def get_mask_bytes_from_minio(self, mask_path: str) -> bytes | None: + try: + bucket = "imagery" + if mask_path.startswith(f"{bucket}/"): + mask_path = mask_path[len(f"{bucket}/"):] + client = Minio( + "minio-hot:9000", + access_key="minioadmin", + secret_key="minioadmin123", + secure=False, + ) + response = client.get_object(bucket, mask_path) + data = response.read() + response.close() + response.release_conn() + return data + except Exception as e: + print(f"[MASK ERROR] {e}") + return None + + + def get_segmentation_record(self, img_key: str): + url = f"{self.base}/api/tables/aerial_image_segmentation" + try: + r = self.http.get(url, timeout=10) + + print("DEBUG segmentation API result:", r.json()) + + if r.status_code == 200: + rows = r.json().get("rows", []) + return next((x for x in rows if x.get("img_key") == img_key), None) + + except Exception as e: + print(f"[API FAIL] {e}") + return None + + + def get_anomalies_map(self): + all_anomalies = self.list_anomalies() + anomaly_map = {} + for anomaly in all_anomalies: + field_id = anomaly.get("img_key") + if field_id: + anomaly_map[field_id] = anomaly_map.get(field_id, 0) + 1 + return anomaly_map \ No newline at end of file diff --git a/GUI/src/vast/desktop/Dockerfile b/GUI/src/vast/desktop/Dockerfile index 56a4c5d04..4e86a4b2d 100644 --- a/GUI/src/vast/desktop/Dockerfile +++ b/GUI/src/vast/desktop/Dockerfile @@ -2,7 +2,17 @@ FROM python:3.11-slim ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 WORKDIR /app -# ───────── system dependencies ───────── +# ───────────────────── optional CA certs ───────────────────── +COPY certs /app/certs +RUN if [ -d ./certs ] && [ "$(ls ./certs/*.crt 2>/dev/null)" ]; then \ + cp ./certs/*.crt /usr/local/share/ca-certificates/ && update-ca-certificates; \ + fi + +ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt +ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt +ENV PIP_CERT=/etc/ssl/certs/ca-certificates.crt + +# ───────────────────── system deps ───────────────────── RUN apt-get update && apt-get install -y --no-install-recommends \ libgl1 libegl1 libx11-6 libxcomposite1 libxext6 libxi6 libxtst6 libsm6 \ libxkbcommon0 libxkbcommon-x11-0 libxkbfile1 libxrender1 libxrandr2 \ @@ -13,27 +23,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libnspr4 libdbus-1-3 libkrb5-3 libgssapi-krb5-2 libasound2 libpulse0 \ fluxbox x11vnc xvfb wget net-tools python3-tk ca-certificates \ procps iproute2 xauth git vlc libvlc5 libvlccore9 \ - fonts-dejavu-core fonts-noto-core fonts-noto-color-emoji\ - && rm -rf /var/lib/apt/lists/* + fonts-dejavu-core fonts-noto-core fonts-noto-color-emoji && \ + rm -rf /var/lib/apt/lists/* -# (optional) minimal extra XCB deps for PyQt +# Extra XCB deps for PyQt RUN apt-get update && apt-get install -y --no-install-recommends \ libxcb-xinerama0 libxcb-cursor0 libxcb-keysyms1 libxcb-render-util0 \ libxcb-randr0 && rm -rf /var/lib/apt/lists/* -# ───────── optional CA certs ───────── -COPY certs /app/certs -RUN if [ -d ./certs ] && [ "$(ls ./certs/*.crt 2>/dev/null)" ]; then \ - echo "Configuring NetFree certificates..."; \ - cp ./certs/*.crt /usr/local/share/ca-certificates/; \ - update-ca-certificates; \ - fi - -ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt -ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt -ENV PIP_CERT=/etc/ssl/certs/ca-certificates.crt - -# ───────── noVNC for remote GUI ───────── +# ───────────────────── noVNC ───────────────────── RUN mkdir -p /opt && \ wget --tries=3 --timeout=30 -O /tmp/novnc.tar.gz https://github.com/novnc/noVNC/archive/refs/tags/v1.4.0.tar.gz && \ tar xzf /tmp/novnc.tar.gz -C /opt && \ @@ -41,26 +39,35 @@ RUN mkdir -p /opt && \ rm /tmp/novnc.tar.gz && \ git clone --depth 1 https://github.com/novnc/websockify /opt/noVNC/utils/websockify -# ───────── Python deps ───────── +# ───────────────────── PulseAudio FIX ───────────────────── +RUN apt-get update && apt-get install -y --no-install-recommends pulseaudio && \ + mkdir -p /etc/pulse && \ + echo "load-module module-native-protocol-unix" >> /etc/pulse/default.pa && \ + echo "load-module module-always-sink" >> /etc/pulse/default.pa && \ + echo "set-default-sink default" >> /etc/pulse/default.pa + +RUN mkdir -p /run/user/1000 && chmod -R 777 /run/user/1000 + +# ───────────────────── Python deps ───────────────────── COPY requirements.txt /app/requirements.txt + +# upgrade pip FIRST +RUN pip install --no-cache-dir --upgrade pip setuptools wheel + +# install requirements RUN pip install --no-cache-dir -r requirements.txt -RUN pip install --no-cache-dir --upgrade pip \ - && pip install --no-cache-dir \ - "PyQt6==6.9.0" \ - "PyQt6-WebEngine==6.9.0" \ - "argon2-cffi" \ - "requests" \ - "numpy" \ - --extra-index-url https://pypi.org/simple \ - --prefer-binary \ - --break-system-packages \ - && pip show PyQt6 PyQt6-WebEngine argon2-cffi -RUN pip install plotly -RUN pip install PyJWT -# ───────── app setup ───────── -RUN useradd -m -s /bin/bash appuser \ - && mkdir -p /app /tmp/.X11-unix \ - && chown -R appuser:appuser /app /tmp /opt/noVNC /var/tmp + +# install PyQt + extras +RUN pip install --no-cache-dir \ + PyQt6==6.9.0 \ + PyQt6-WebEngine==6.9.0 \ + argon2-cffi \ + requests \ + numpy + +# ───────────────────── App setup ───────────────────── +RUN useradd -m -s /bin/bash appuser && \ + mkdir -p /app /tmp/.X11-unix && chown -R appuser:appuser /app /tmp /opt/noVNC /var/tmp RUN apt-get update && apt-get install -y --no-install-recommends gosu && rm -rf /var/lib/apt/lists/* @@ -70,8 +77,11 @@ RUN sed -i 's/\r$//' /app/start.sh && chmod +x /app/start.sh && chown -R appuser RUN mkdir -p /app/secrets && chmod -R 777 /app/secrets +RUN mkdir -p /run/user/1000 && chmod -R 777 /run/user/1000 + USER appuser -EXPOSE 5900 6080 + +EXPOSE 5900 6080 ENV PYTHONPATH=/app/src:/app ENV DISPLAY=:0 ENV NO_VNC_PORT=6080 @@ -79,6 +89,3 @@ ENV PORT=19100 ENV MEDIA_BASE=http://media-proxy:8080 CMD ["/app/start.sh"] - - - diff --git a/GUI/src/vast/desktop/start.sh b/GUI/src/vast/desktop/start.sh index 460dfcafe..12884fe5d 100644 --- a/GUI/src/vast/desktop/start.sh +++ b/GUI/src/vast/desktop/start.sh @@ -5,6 +5,10 @@ set -x export DISPLAY=:0 rm -f /tmp/.X0-lock +echo "[INFO] Starting PulseAudio..." +pulseaudio --start --exit-idle-time=-1 --log-target=stderr +sleep 1 + echo "[INFO] Starting Xvfb..." Xvfb :0 -screen 0 1920x1080x24 & sleep 3 @@ -22,10 +26,3 @@ echo "[INFO] Starting noVNC..." echo "[INFO] Starting PyQt application..." exec python /app/src/vast/main.py - - - -# # ------------------------------ -# # 🚀 Launch the main PyQt application -# # ------------------------------ -# exec /opt/venv/bin/python /app/src/vast/main.py diff --git a/GUI/src/vast/main_window.py b/GUI/src/vast/main_window.py index 34310e234..b238a59e1 100644 --- a/GUI/src/vast/main_window.py +++ b/GUI/src/vast/main_window.py @@ -1,918 +1,8 @@ -# # from PyQt6.QtCore import Qt, pyqtSignal, QSize -# # from PyQt6.QtWidgets import ( -# # QMainWindow, QDockWidget, QListWidget, QListWidgetItem, QStatusBar, -# # QStackedWidget, QToolButton, QLabel, QWidget, QHBoxLayout, QVBoxLayout, -# # QGraphicsDropShadowEffect, QPushButton, -# # QMainWindow, QDockWidget, QListWidget, QListWidgetItem, QStatusBar, -# # QStackedWidget, QToolButton, QLabel, QWidget, QHBoxLayout, QVBoxLayout, -# # QGraphicsDropShadowEffect, QPushButton -# # ) -# # from PyQt6.QtGui import QAction, QIcon, QFont, QColor -# # import os - -# # from PyQt6.QtGui import QAction, QIcon, QFont, QColor -# # import os - -# # from home_view import HomeView -# # from views.sensors_view import SensorsView -# # from views.alerts_panel import AlertsPanel -# # from views.notification_view import NotificationView -# # from views.fruits_view import FruitsView -# # from views.sound.sound_view import SoundView -# # from views.ground_view import GroundView -# # from views.auth_status_view import AuthStatusView -# # from views.ground_view import GroundView -# # from views.auth_status_view import AuthStatusView -# # from dashboard_api import DashboardApi -# # from vast.alerts.alert_service import AlertService -# # from views.leaf_diseases import LeafDiseaseView - - -# # # === New Sensors GUI imports === -# # from views.sensorsMainView import SensorsMainView -# # from views.sensorsMapView import SensorsMapView -# # from views.sensorDetailsTab import SensorDetailsTab -# # from views.sensors_status_summary import SensorsStatusSummary - -# # from views.security.incident_player_vlc import IncidentPlayerVLC -# # # === New Sensors GUI imports === -# # from views.sensorsMainView import SensorsMainView -# # from views.sensorsMapView import SensorsMapView -# # from views.sensorDetailsTab import SensorDetailsTab -# # from views.sensors_status_summary import SensorsStatusSummary - - -# # class MainWindow(QMainWindow): -# # logoutRequested = pyqtSignal() - -# # def __init__(self, api: DashboardApi, parent=None): -# # super().__init__(parent) -# # self.setWindowTitle("AgCloud – Dashboard") -# # self.resize(1280, 760) -# # self.setWindowTitle("AgCloud – Dashboard") -# # self.resize(1280, 760) -# # self.api = api - -# # # ─────────────────────────────── -# # # GLOBAL STYLE -# # # ─────────────────────────────── -# # self.setStyleSheet(""" -# # QMainWindow { background-color: #f9fafb; } -# # QMenuBar { background-color: #e5e7eb; font-size: 11.5pt; padding: 4px 10px; } -# # QToolBar { -# # background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #ffffff, stop:1 #f3f4f6); -# # border-bottom: 1px solid #d1d5db; padding: 2px 10px; min-height: 42px; -# # } -# # QToolButton { background-color: transparent; border: none; padding: 4px; border-radius: 8px; font-size: 20px; } -# # QToolButton:hover { background-color: #e5e7eb; } -# # QListWidget { background-color: #ffffff; border: none; font-size: 12pt; color: #111827; } -# # QListWidget::item { padding: 10px; border-radius: 6px; } -# # QListWidget::item:selected { background-color: #10b981; color: white; } -# # QStatusBar { background-color: #f3f4f6; font-size: 10pt; } -# # """) - -# # # ─────────────────────────────── -# # # MENU -# # # ─────────────────────────────── -# # # ─────────────────────────────── -# # # GLOBAL STYLE -# # # ─────────────────────────────── -# # self.setStyleSheet(""" -# # QMainWindow { background-color: #f9fafb; } -# # QMenuBar { background-color: #e5e7eb; font-size: 11.5pt; padding: 4px 10px; } -# # QToolBar { -# # background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #ffffff, stop:1 #f3f4f6); -# # border-bottom: 1px solid #d1d5db; padding: 2px 10px; min-height: 42px; -# # } -# # QToolButton { background-color: transparent; border: none; padding: 4px; border-radius: 8px; font-size: 20px; } -# # QToolButton:hover { background-color: #e5e7eb; } -# # QListWidget { background-color: #ffffff; border: none; font-size: 12pt; color: #111827; } -# # QListWidget::item { padding: 10px; border-radius: 6px; } -# # QListWidget::item:selected { background-color: #10b981; color: white; } -# # QStatusBar { background-color: #f3f4f6; font-size: 10pt; } -# # """) - -# # # ─────────────────────────────── -# # # MENU -# # # ─────────────────────────────── -# # file_menu = self.menuBar().addMenu("&File") -# # self.back_action = QAction(QIcon.fromTheme("go-previous"), "Back", self) -# # self.back_action.setShortcut("Alt+Left") -# # self.back_action.triggered.connect(self.go_back) -# # file_menu.addAction(self.back_action) -# # self.logout_action = QAction("Log out", self) -# # self.logout_action = QAction("Log out", self) -# # self.logout_action.triggered.connect(self._logout) -# # file_menu.addAction(self.logout_action) - -# # # ─────────────────────────────── -# # # TOP BAR (toolbar) -# # # ─────────────────────────────── -# # # ─────────────────────────────── -# # # TOP BAR (toolbar) -# # # ─────────────────────────────── -# # toolbar = self.addToolBar("Main Toolbar") -# # toolbar.setMovable(False) -# # toolbar.setFloatable(False) -# # toolbar.setIconSize(QSize(32, 32)) - -# # top_bar = QWidget() -# # top_bar_layout = QHBoxLayout(top_bar) -# # top_bar_layout.setContentsMargins(8, 0, 8, 0) -# # top_bar_layout.setSpacing(10) - -# # # Logout button -# # logout_btn = QPushButton("Logout") -# # logout_btn.setToolTip("Log out") -# # logout_btn.setCursor(Qt.CursorShape.PointingHandCursor) -# # logout_btn.setStyleSheet(""" -# # QPushButton { -# # background-color: #10b981; -# # color: white; -# # border: none; -# # border-radius: 8px; -# # padding: 6px 16px; -# # font-size: 11pt; -# # font-weight: 600; -# # } -# # QPushButton:hover { background-color: #059669; } -# # QPushButton:pressed { background-color: #047857; } -# # """) -# # logout_btn.clicked.connect(self._logout) - -# # # Alert bell -# # toolbar.setIconSize(QSize(32, 32)) - -# # top_bar = QWidget() -# # top_bar_layout = QHBoxLayout(top_bar) -# # top_bar_layout.setContentsMargins(8, 0, 8, 0) -# # top_bar_layout.setSpacing(10) - -# # # Logout button -# # logout_btn = QPushButton("Logout") -# # logout_btn.setToolTip("Log out") -# # logout_btn.setCursor(Qt.CursorShape.PointingHandCursor) -# # logout_btn.setStyleSheet(""" -# # QPushButton { -# # background-color: #10b981; -# # color: white; -# # border: none; -# # border-radius: 8px; -# # padding: 6px 16px; -# # font-size: 11pt; -# # font-weight: 600; -# # } -# # QPushButton:hover { background-color: #059669; } -# # QPushButton:pressed { background-color: #047857; } -# # """) -# # logout_btn.clicked.connect(self._logout) - -# # # Alert bell -# # self.alert_button = QToolButton() -# # self.alert_button.setToolTip("Show alerts") -# # self.alert_button.setText("🔔") -# # self.alert_button.setIconSize(QSize(40, 40)) -# # self.alert_button.setIconSize(QSize(40, 40)) -# # self.alert_button.setStyleSheet(""" -# # QToolButton { -# # font-size: 30px; -# # font-size: 30px; -# # border: none; -# # background: transparent; -# # padding: 4px; -# # border-radius: 8px; -# # border-radius: 8px; -# # } -# # QToolButton:hover { background-color: #e5e7eb; } -# # QToolButton:hover { background-color: #e5e7eb; } -# # """) - -# # # Alert badge -# # # Alert badge -# # self.alert_badge = QLabel("0", self.alert_button) -# # self.alert_badge.setAlignment(Qt.AlignmentFlag.AlignCenter) -# # self.alert_badge.setFixedSize(24, 24) -# # self.alert_badge.setFixedSize(24, 24) -# # self.alert_badge.setStyleSheet(""" -# # QLabel { -# # background-color: #3b82f6; -# # background-color: #3b82f6; -# # color: white; -# # font-size: 10pt; -# # font-size: 10pt; -# # font-weight: bold; -# # border-radius: 12px; -# # border: 2px solid white; -# # border-radius: 12px; -# # border: 2px solid white; -# # } -# # """) -# # self.alert_badge.hide() - -# # def reposition_badge(): -# # btn_w = self.alert_button.width() -# # self.alert_badge.move(btn_w - 22, 2) -# # self.alert_badge.move(btn_w - 22, 2) -# # self.alert_badge.raise_() - -# # self.alert_button.resizeEvent = lambda e: ( -# # QToolButton.resizeEvent(self.alert_button, e), -# # reposition_badge() -# # ) -# # reposition_badge() - -# # # ─────────────────────────────── -# # # TITLE AREA (Updated) -# # # ─────────────────────────────── -# # title_container = QWidget() -# # title_layout = QVBoxLayout(title_container) -# # title_layout.setContentsMargins(0, 0, 0, 0) -# # title_layout.setSpacing(0) - -# # main_title = QLabel("AgCloud") -# # main_title.setAlignment(Qt.AlignmentFlag.AlignCenter) -# # main_title.setStyleSheet(""" -# # QLabel { -# # font-size: 22pt; -# # font-weight: 700; -# # color: #047857; -# # letter-spacing: 1px; -# # } -# # """) - -# # subtitle = QLabel("The Smart Platform that Protects and Optimizes Your Field") -# # subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter) -# # subtitle.setStyleSheet(""" -# # QLabel { -# # font-size: 11pt; -# # font-weight: 500; -# # color: #374151; -# # margin-top: 2px; -# # } -# # """) - -# # title_layout.addWidget(main_title) -# # title_layout.addWidget(subtitle) - -# # shadow = QGraphicsDropShadowEffect() -# # shadow.setBlurRadius(8) -# # shadow.setColor(QColor(0, 0, 0, 35)) -# # shadow.setOffset(0, 2) -# # top_bar.setGraphicsEffect(shadow) - -# # top_bar_layout.addWidget(logout_btn) -# # top_bar_layout.addWidget(self.alert_button) -# # top_bar_layout.addStretch() -# # top_bar_layout.addWidget(title_container) -# # top_bar_layout.addStretch() -# # toolbar.addWidget(top_bar) - -# # # ─────────────────────────────── -# # # NAVIGATION -# # # ─────────────────────────────── -# # # ─────────────────────────────── -# # # TITLE AREA (Updated) -# # # ─────────────────────────────── -# # title_container = QWidget() -# # title_layout = QVBoxLayout(title_container) -# # title_layout.setContentsMargins(0, 0, 0, 0) -# # title_layout.setSpacing(0) - -# # main_title = QLabel("AgCloud") -# # main_title.setAlignment(Qt.AlignmentFlag.AlignCenter) -# # main_title.setStyleSheet(""" -# # QLabel { -# # font-size: 22pt; -# # font-weight: 700; -# # color: #047857; -# # letter-spacing: 1px; -# # } -# # """) - -# # subtitle = QLabel("The Smart Platform that Protects and Optimizes Your Field") -# # subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter) -# # subtitle.setStyleSheet(""" -# # QLabel { -# # font-size: 11pt; -# # font-weight: 500; -# # color: #374151; -# # margin-top: 2px; -# # } -# # """) - -# # title_layout.addWidget(main_title) -# # title_layout.addWidget(subtitle) - -# # shadow = QGraphicsDropShadowEffect() -# # shadow.setBlurRadius(8) -# # shadow.setColor(QColor(0, 0, 0, 35)) -# # shadow.setOffset(0, 2) -# # top_bar.setGraphicsEffect(shadow) - -# # top_bar_layout.addWidget(logout_btn) -# # top_bar_layout.addWidget(self.alert_button) -# # top_bar_layout.addStretch() -# # top_bar_layout.addWidget(title_container) -# # top_bar_layout.addStretch() -# # toolbar.addWidget(top_bar) - -# # # ─────────────────────────────── -# # # NAVIGATION -# # # ─────────────────────────────── -# # self.nav_dock = QDockWidget("Navigation", self) -# # self.nav_dock.setFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures) -# # self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.nav_dock) -# # self.nav_list = QListWidget(self.nav_dock) -# # self.nav_dock.setWidget(self.nav_list) -# # self.nav_dock.setMinimumWidth(220) -# # self.nav_dock.setMinimumWidth(220) - -# # font = QFont(); font.setPointSize(12) -# # self.nav_list.setFont(font) - -# # for main_item in ["Home", "Sensors", "Sound", "Ground Image", "Aerial Image", "Fruits", "Security", "Settings", "Notifications", "Auth", "Leaf Diseases"]: -# # item.setData(Qt.ItemDataRole.UserRole, {"type": "main"}) -# # self.nav_list.addItem(item) -# # if main_item == "Sensors": -# # for sub in ["Live Data", "Sensor Health", "Location Map"]: -# # sub_item = QListWidgetItem(f" ↳ {sub}") -# # sub_item.setData(Qt.ItemDataRole.UserRole, {"type": "sub", "parent": main_item, "name": sub}) -# # sub_item.setHidden(True) -# # self.nav_list.addItem(sub_item) - -# # font = QFont(); font.setPointSize(12) -# # self.nav_list.setFont(font) -# # for main_item in ["Home", "Sensors", "Sound", "Ground Image", "Aerial Image", "Fruits", "Security", "Settings", "Notifications", "Auth", "Leaf Diseases"]: -# # item = QListWidgetItem(main_item) -# # item.setData(Qt.ItemDataRole.UserRole, {"type": "main"}) -# # self.nav_list.addItem(item) -# # if main_item == "Sensors": -# # for sub in ["Live Data", "Sensor Health", "Location Map"]: -# # sub_item = QListWidgetItem(f" ↳ {sub}") -# # sub_item.setData(Qt.ItemDataRole.UserRole, {"type": "sub", "parent": main_item, "name": sub}) -# # sub_item.setHidden(True) -# # self.nav_list.addItem(sub_item) - -# # self.nav_list.currentRowChanged.connect(self._on_nav_change) -# # self.nav_list.itemClicked.connect(self._on_nav_click) -# # self.nav_list.itemClicked.connect(self._on_nav_click) - -# # # ─────────────────────────────── -# # # ALERT SERVICE + PANEL -# # # ─────────────────────────────── -# # # ─────────────────────────────── -# # # ALERT SERVICE + PANEL -# # # ─────────────────────────────── -# # ws_url = os.getenv("ALERTS_WS", "ws://alerts-gateway:8000/ws/alerts") -# # self.alert_service = AlertService(ws_url, api) -# # self.alert_service.alertsUpdated.connect(self.update_alert_badge) -# # self.alert_service.alertAdded.connect(lambda _: self.update_alert_badge()) - -# # self.alerts_panel = AlertsPanel(self.alert_service) -# # self.alerts_panel.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Tool) -# # self.alerts_panel.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) -# # self.alerts_panel.setStyleSheet(""" -# # QWidget { -# # background-color: #ffffff; -# # border: 1px solid #d1d5db; -# # border: 1px solid #d1d5db; -# # border-radius: 10px; -# # } -# # """) -# # self.alerts_panel.hide() -# # self.alert_button.clicked.connect(self.toggle_alert_panel) - -# # # ─────────────────────────────── -# # # CENTRAL STACKED VIEWS -# # # ─────────────────────────────── -# # # ─────────────────────────────── -# # # CENTRAL STACKED VIEWS -# # # ─────────────────────────────── -# # self.home = HomeView(api, self.alert_service, self) -# # self.sensors_view = SensorsView(api, self) -# # self.notification_view = NotificationView(self) -# # self.fruits_view = FruitsView(api, self) -# # self.sound_view = SoundView(api, self) -# # self.ground_view = GroundView(api, self) -# # self.auth_status = AuthStatusView(api, self) -# # self.leaf_diseases_view = LeafDiseaseView(api, self) -# # self.sensors_status_summary = SensorsStatusSummary(api, self) -# # self.sensors_health = SensorsView(api, self) -# # self.sensors_main = SensorsMainView(api, self) -# # self.security_view = IncidentPlayerVLC(api, self.alert_service, self) -# # self.ground_view = GroundView(api, self) -# # self.auth_status = AuthStatusView(api, self) - -# # self.sensors_status_summary = SensorsStatusSummary(api, self) -# # self.sensors_health = SensorsView(api, self) -# # self.sensors_main = SensorsMainView(api, self) - -# # self.stack = QStackedWidget() -# # self.setCentralWidget(self.stack) -# # self.views = { -# # "Home": self.home, -# # "Sensors": self.sensors_view, -# # "Sound": self.sound_view, -# # "Sensors - Live Data": self.sensors_status_summary, -# # "Sensors - Sensor Health": self.sensors_health, -# # "Sensors - Location Map": self.sensors_main, -# # "Notifications": self.notification_view, -# # "Leaf Diseases": self.leaf_diseases_view, -# # "Fruits": self.fruits_view, -# # "Ground Image": self.ground_view, -# # "Auth": self.auth_status, -# # "Security": self.security_view, -# # } - -# # for view in self.views.values(): -# # self.stack.addWidget(view) -# # self.stack.setCurrentWidget(self.home) -# # self.history = [] -# # self.history = [] - -# # # ─────────────────────────────── -# # # STATUS BAR -# # # ─────────────────────────────── -# # # ─────────────────────────────── -# # # STATUS BAR -# # # ─────────────────────────────── -# # sb = QStatusBar(self) -# # sb.setStyleSheet("QStatusBar { background-color: #f3f4f6; color: #374151; font-size: 10.5pt; }") -# # sb.setStyleSheet("QStatusBar { background-color: #f3f4f6; color: #374151; font-size: 10.5pt; }") -# # self.setStatusBar(sb) -# # sb.showMessage("Ready") - -# # # ─────────────────────────────── -# # # ALERT BADGE -# # # ─────────────────────────────── -# # # ─────────────────────────────── -# # # ALERT BADGE -# # # ─────────────────────────────── -# # def update_alert_badge(self): -# # unacked = sum(1 for a in self.alert_service.alerts if not a.get("ack", False)) -# # if unacked > 0: -# # self.alert_badge.setText(str(unacked)) -# # self.alert_badge.show() -# # else: -# # self.alert_badge.hide() - -# # def toggle_alert_panel(self): -# # if self.alerts_panel.isVisible(): -# # self.alerts_panel.hide() -# # return - -# # panel_width, panel_height = 420, 540 -# # panel_width, panel_height = 420, 540 -# # self.alerts_panel.resize(panel_width, panel_height) -# # rect = self.alert_button.geometry() -# # bottom_left = self.alert_button.mapToGlobal(rect.bottomLeft()) -# # bottom_right = self.alert_button.mapToGlobal(rect.bottomRight()) -# # center_x = (bottom_left.x() + bottom_right.x()) // 2 - (panel_width // 2) -# # pos_y = bottom_left.y() + 8 -# # pos_y = bottom_left.y() + 8 -# # self.alerts_panel.move(center_x, pos_y) -# # self.alerts_panel.show() -# # self.alerts_panel.raise_() - -# # if hasattr(self.alert_service, "mark_all_acknowledged"): -# # self.alert_service.mark_all_acknowledged() -# # self.update_alert_badge() - -# # # ─────────────────────────────── -# # # NAVIGATION -# # # ─────────────────────────────── -# # # ─────────────────────────────── -# # # NAVIGATION -# # # ─────────────────────────────── -# # def _on_nav_change(self, row: int) -> None: -# # name = self.nav_list.item(row).text().strip() -# # name = self.nav_list.item(row).text().strip() -# # if name in self.views: -# # self.navigate_to(self.views[name]) -# # else: -# # self.statusBar().showMessage(f"Section '{name}' not implemented yet.") - -# # def _on_nav_click(self, item): -# # data = item.data(Qt.ItemDataRole.UserRole) -# # if data and data.get("type") == "main": -# # parent = item.text() -# # expanded = False -# # for i in range(self.nav_list.count()): -# # sub_item = self.nav_list.item(i) -# # sub_data = sub_item.data(Qt.ItemDataRole.UserRole) -# # if sub_data and sub_data.get("type") == "sub" and sub_data.get("parent") == parent: -# # expanded = sub_item.isHidden() -# # break -# # for i in range(self.nav_list.count()): -# # sub_item = self.nav_list.item(i) -# # sub_data = sub_item.data(Qt.ItemDataRole.UserRole) -# # if sub_data and sub_data.get("type") == "sub" and sub_data.get("parent") == parent: -# # sub_item.setHidden(not expanded) -# # elif data and data.get("type") == "sub": -# # parent = data.get("parent") -# # sub_name = data.get("name") -# # key = f"{parent} - {sub_name}" -# # if key in self.views: -# # self.stack.setCurrentWidget(self.views[key]) - -# # def _on_nav_click(self, item): -# # data = item.data(Qt.ItemDataRole.UserRole) -# # if data and data.get("type") == "main": -# # parent = item.text() -# # expanded = False -# # for i in range(self.nav_list.count()): -# # sub_item = self.nav_list.item(i) -# # sub_data = sub_item.data(Qt.ItemDataRole.UserRole) -# # if sub_data and sub_data.get("type") == "sub" and sub_data.get("parent") == parent: -# # expanded = sub_item.isHidden() -# # break -# # for i in range(self.nav_list.count()): -# # sub_item = self.nav_list.item(i) -# # sub_data = sub_item.data(Qt.ItemDataRole.UserRole) -# # if sub_data and sub_data.get("type") == "sub" and sub_data.get("parent") == parent: -# # sub_item.setHidden(not expanded) -# # elif data and data.get("type") == "sub": -# # parent = data.get("parent") -# # sub_name = data.get("name") -# # key = f"{parent} - {sub_name}" -# # if key in self.views: -# # self.stack.setCurrentWidget(self.views[key]) - -# # def navigate_to(self, widget): -# # current = self.stack.currentWidget() -# # if current not in self.history: -# # self.history.append(current) -# # self.stack.setCurrentWidget(widget) - -# # def go_back(self): -# # if self.history: -# # last = self.history.pop() -# # self.stack.setCurrentWidget(last) -# # else: -# # self.statusBar().showMessage("No previous view to go back to.") - -# # def _logout(self) -> None: -# # self.statusBar().showMessage("Logged out (demo)") -# # self.logoutRequested.emit() - -# from PyQt6.QtCore import Qt, pyqtSignal, QSize -# from PyQt6.QtWidgets import ( -# QMainWindow, QDockWidget, QListWidget, QListWidgetItem, QStatusBar, -# QStackedWidget, QToolButton, QLabel, QWidget, QHBoxLayout, QVBoxLayout, -# QGraphicsDropShadowEffect, QPushButton -# ) -# from PyQt6.QtGui import QAction, QIcon, QFont, QColor -# import os - -# from home_view import HomeView -# from views.sensors_view import SensorsView -# from views.alerts_panel import AlertsPanel -# from views.notification_view import NotificationView -# from views.fruits_view import FruitsView -# from views.ground_view import GroundView -# from views.auth_status_view import AuthStatusView -# from dashboard_api import DashboardApi -# from vast.alerts.alert_service import AlertService - -# # === New Sensors GUI imports === -# from views.sensorsMainView import SensorsMainView -# from views.sensorsMapView import SensorsMapView -# from views.sensorDetailsTab import SensorDetailsTab -# from views.sensors_status_summary import SensorsStatusSummary - - -# class MainWindow(QMainWindow): -# logoutRequested = pyqtSignal() - -# def __init__(self, api: DashboardApi, parent=None): -# super().__init__(parent) -# self.setWindowTitle("AgCloud – Dashboard") -# self.resize(1280, 760) -# self.api = api - -# # ─────────────────────────────── -# # GLOBAL STYLE -# # ─────────────────────────────── -# self.setStyleSheet(""" -# QMainWindow { background-color: #f9fafb; } -# QMenuBar { background-color: #e5e7eb; font-size: 11.5pt; padding: 4px 10px; } -# QToolBar { -# background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #ffffff, stop:1 #f3f4f6); -# border-bottom: 1px solid #d1d5db; padding: 2px 10px; min-height: 42px; -# } -# QToolButton { background-color: transparent; border: none; padding: 4px; border-radius: 8px; font-size: 20px; } -# QToolButton:hover { background-color: #e5e7eb; } -# QListWidget { background-color: #ffffff; border: none; font-size: 12pt; color: #111827; } -# QListWidget::item { padding: 10px; border-radius: 6px; } -# QListWidget::item:selected { background-color: #10b981; color: white; } -# QStatusBar { background-color: #f3f4f6; font-size: 10pt; } -# """) - -# # ─────────────────────────────── -# # MENU -# # ─────────────────────────────── -# file_menu = self.menuBar().addMenu("&File") -# self.back_action = QAction(QIcon.fromTheme("go-previous"), "Back", self) -# self.back_action.setShortcut("Alt+Left") -# self.back_action.triggered.connect(self.go_back) -# file_menu.addAction(self.back_action) -# self.logout_action = QAction("Log out", self) -# self.logout_action.triggered.connect(self._logout) -# file_menu.addAction(self.logout_action) - -# # ─────────────────────────────── -# # TOP BAR (toolbar) -# # ─────────────────────────────── -# toolbar = self.addToolBar("Main Toolbar") -# toolbar.setMovable(False) -# toolbar.setFloatable(False) -# toolbar.setIconSize(QSize(32, 32)) - -# top_bar = QWidget() -# top_bar_layout = QHBoxLayout(top_bar) -# top_bar_layout.setContentsMargins(8, 0, 8, 0) -# top_bar_layout.setSpacing(10) - -# # Logout button -# logout_btn = QPushButton("Logout") -# logout_btn.setToolTip("Log out") -# logout_btn.setCursor(Qt.CursorShape.PointingHandCursor) -# logout_btn.setStyleSheet(""" -# QPushButton { -# background-color: #10b981; -# color: white; -# border: none; -# border-radius: 8px; -# padding: 6px 16px; -# font-size: 11pt; -# font-weight: 600; -# } -# QPushButton:hover { background-color: #059669; } -# QPushButton:pressed { background-color: #047857; } -# """) -# logout_btn.clicked.connect(self._logout) - -# # Alert bell -# self.alert_button = QToolButton() -# self.alert_button.setToolTip("Show alerts") -# self.alert_button.setText("🔔") -# self.alert_button.setIconSize(QSize(40, 40)) -# self.alert_button.setStyleSheet(""" -# QToolButton { -# font-size: 30px; -# border: none; -# background: transparent; -# padding: 4px; -# border-radius: 8px; -# } -# QToolButton:hover { background-color: #e5e7eb; } -# """) - -# # Alert badge -# self.alert_badge = QLabel("0", self.alert_button) -# self.alert_badge.setAlignment(Qt.AlignmentFlag.AlignCenter) -# self.alert_badge.setFixedSize(24, 24) -# self.alert_badge.setStyleSheet(""" -# QLabel { -# background-color: #3b82f6; -# color: white; -# font-size: 10pt; -# font-weight: bold; -# border-radius: 12px; -# border: 2px solid white; -# } -# """) -# self.alert_badge.hide() - -# def reposition_badge(): -# btn_w = self.alert_button.width() -# self.alert_badge.move(btn_w - 22, 2) -# self.alert_badge.raise_() - -# self.alert_button.resizeEvent = lambda e: ( -# QToolButton.resizeEvent(self.alert_button, e), -# reposition_badge() -# ) -# reposition_badge() - -# # ─────────────────────────────── -# # TITLE AREA (Updated) -# # ─────────────────────────────── -# title_container = QWidget() -# title_layout = QVBoxLayout(title_container) -# title_layout.setContentsMargins(0, 0, 0, 0) -# title_layout.setSpacing(0) - -# main_title = QLabel("AgCloud") -# main_title.setAlignment(Qt.AlignmentFlag.AlignCenter) -# main_title.setStyleSheet(""" -# QLabel { -# font-size: 22pt; -# font-weight: 700; -# color: #047857; -# letter-spacing: 1px; -# } -# """) - -# subtitle = QLabel("The Smart Platform that Protects and Optimizes Your Field") -# subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter) -# subtitle.setStyleSheet(""" -# QLabel { -# font-size: 11pt; -# font-weight: 500; -# color: #374151; -# margin-top: 2px; -# } -# """) - -# title_layout.addWidget(main_title) -# title_layout.addWidget(subtitle) - -# shadow = QGraphicsDropShadowEffect() -# shadow.setBlurRadius(8) -# shadow.setColor(QColor(0, 0, 0, 35)) -# shadow.setOffset(0, 2) -# top_bar.setGraphicsEffect(shadow) - -# top_bar_layout.addWidget(logout_btn) -# top_bar_layout.addWidget(self.alert_button) -# top_bar_layout.addStretch() -# top_bar_layout.addWidget(title_container) -# top_bar_layout.addStretch() -# toolbar.addWidget(top_bar) - -# # ─────────────────────────────── -# # NAVIGATION -# # ─────────────────────────────── -# self.nav_dock = QDockWidget("Navigation", self) -# self.nav_dock.setFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures) -# self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.nav_dock) -# self.nav_list = QListWidget(self.nav_dock) -# self.nav_dock.setWidget(self.nav_list) -# self.nav_dock.setMinimumWidth(220) - -# font = QFont(); font.setPointSize(12) -# self.nav_list.setFont(font) - -# for main_item in ["Home", "Sensors", "Sound", "Ground Image", "Aerial Image", "Fruits", "Security", "Settings", "Notifications", "Auth"]: -# item = QListWidgetItem(main_item) -# item.setData(Qt.ItemDataRole.UserRole, {"type": "main"}) -# self.nav_list.addItem(item) -# if main_item == "Sensors": -# for sub in ["Live Data", "Sensor Health", "Location Map"]: -# sub_item = QListWidgetItem(f" ↳ {sub}") -# sub_item.setData(Qt.ItemDataRole.UserRole, {"type": "sub", "parent": main_item, "name": sub}) -# sub_item.setHidden(True) -# self.nav_list.addItem(sub_item) - -# self.nav_list.currentRowChanged.connect(self._on_nav_change) -# self.nav_list.itemClicked.connect(self._on_nav_click) - -# # ─────────────────────────────── -# # ALERT SERVICE + PANEL -# # ─────────────────────────────── -# ws_url = os.getenv("ALERTS_WS", "ws://alerts-gateway:8000/ws/alerts") -# self.alert_service = AlertService(ws_url, api) -# self.alert_service.alertsUpdated.connect(self.update_alert_badge) -# self.alert_service.alertAdded.connect(lambda _: self.update_alert_badge()) - -# self.alerts_panel = AlertsPanel(self.alert_service) -# self.alerts_panel.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Tool) -# self.alerts_panel.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) -# self.alerts_panel.setStyleSheet(""" -# QWidget { -# background-color: #ffffff; -# border: 1px solid #d1d5db; -# border-radius: 10px; -# } -# """) -# self.alerts_panel.hide() -# self.alert_button.clicked.connect(self.toggle_alert_panel) - -# # ─────────────────────────────── -# # CENTRAL STACKED VIEWS -# # ─────────────────────────────── -# self.home = HomeView(api, self.alert_service, self) -# self.sensors_view = SensorsView(api, self) -# self.notification_view = NotificationView(self) -# self.fruits_view = FruitsView(api, self) -# self.ground_view = GroundView(api, self) -# self.auth_status = AuthStatusView(api, self) - -# self.sensors_status_summary = SensorsStatusSummary(api, self) -# self.sensors_health = SensorsView(api, self) -# self.sensors_main = SensorsMainView(api, self) - -# self.stack = QStackedWidget() -# self.setCentralWidget(self.stack) -# self.views = { -# "Home": self.home, -# "Sensors": self.sensors_view, -# "Sensors - Live Data": self.sensors_status_summary, -# "Sensors - Sensor Health": self.sensors_health, -# "Sensors - Location Map": self.sensors_main, -# "Notifications": self.notification_view, -# "Fruits": self.fruits_view, -# "Ground Image": self.ground_view, -# "Auth": self.auth_status -# } - -# for view in self.views.values(): -# self.stack.addWidget(view) -# self.stack.setCurrentWidget(self.home) -# self.history = [] - -# # ─────────────────────────────── -# # STATUS BAR -# # ─────────────────────────────── -# sb = QStatusBar(self) -# sb.setStyleSheet("QStatusBar { background-color: #f3f4f6; color: #374151; font-size: 10.5pt; }") -# self.setStatusBar(sb) -# sb.showMessage("Ready") - -# # ─────────────────────────────── -# # ALERT BADGE -# # ─────────────────────────────── -# def update_alert_badge(self): -# unacked = sum(1 for a in self.alert_service.alerts if not a.get("ack", False)) -# if unacked > 0: -# self.alert_badge.setText(str(unacked)) -# self.alert_badge.show() -# else: -# self.alert_badge.hide() - -# def toggle_alert_panel(self): -# if self.alerts_panel.isVisible(): -# self.alerts_panel.hide() -# return - -# panel_width, panel_height = 420, 540 -# self.alerts_panel.resize(panel_width, panel_height) -# rect = self.alert_button.geometry() -# bottom_left = self.alert_button.mapToGlobal(rect.bottomLeft()) -# bottom_right = self.alert_button.mapToGlobal(rect.bottomRight()) -# center_x = (bottom_left.x() + bottom_right.x()) // 2 - (panel_width // 2) -# pos_y = bottom_left.y() + 8 -# self.alerts_panel.move(center_x, pos_y) -# self.alerts_panel.show() -# self.alerts_panel.raise_() - -# if hasattr(self.alert_service, "mark_all_acknowledged"): -# self.alert_service.mark_all_acknowledged() -# self.update_alert_badge() - -# # ─────────────────────────────── -# # NAVIGATION -# # ─────────────────────────────── -# def _on_nav_change(self, row: int) -> None: -# name = self.nav_list.item(row).text().strip() -# if name in self.views: -# self.navigate_to(self.views[name]) -# else: -# self.statusBar().showMessage(f"Section '{name}' not implemented yet.") - -# def _on_nav_click(self, item): -# data = item.data(Qt.ItemDataRole.UserRole) -# if data and data.get("type") == "main": -# parent = item.text() -# expanded = False -# for i in range(self.nav_list.count()): -# sub_item = self.nav_list.item(i) -# sub_data = sub_item.data(Qt.ItemDataRole.UserRole) -# if sub_data and sub_data.get("type") == "sub" and sub_data.get("parent") == parent: -# expanded = sub_item.isHidden() -# break -# for i in range(self.nav_list.count()): -# sub_item = self.nav_list.item(i) -# sub_data = sub_item.data(Qt.ItemDataRole.UserRole) -# if sub_data and sub_data.get("type") == "sub" and sub_data.get("parent") == parent: -# sub_item.setHidden(not expanded) -# elif data and data.get("type") == "sub": -# parent = data.get("parent") -# sub_name = data.get("name") -# key = f"{parent} - {sub_name}" -# if key in self.views: -# self.stack.setCurrentWidget(self.views[key]) - -# def navigate_to(self, widget): -# current = self.stack.currentWidget() -# if current not in self.history: -# self.history.append(current) -# self.stack.setCurrentWidget(widget) - -# def go_back(self): -# if self.history: -# last = self.history.pop() -# self.stack.setCurrentWidget(last) -# else: -# self.statusBar().showMessage("No previous view to go back to.") - -# def _logout(self) -> None: -# self.statusBar().showMessage("Logged out (demo)") -# self.logoutRequested.emit() - from PyQt6.QtCore import Qt, pyqtSignal, QSize from PyQt6.QtWidgets import ( + QMainWindow, QDockWidget, QListWidget, QListWidgetItem, QStatusBar, + QStackedWidget, QToolButton, QLabel, QWidget, QHBoxLayout, QVBoxLayout, + QGraphicsDropShadowEffect, QPushButton, QMainWindow, QDockWidget, QListWidget, QListWidgetItem, QStatusBar, QStackedWidget, QToolButton, QLabel, QWidget, QHBoxLayout, QVBoxLayout, QGraphicsDropShadowEffect, QPushButton @@ -920,16 +10,38 @@ from PyQt6.QtGui import QAction, QIcon, QFont, QColor import os +from PyQt6.QtGui import QAction, QIcon, QFont, QColor +import os + from home_view import HomeView from views.sensors_view import SensorsView -from views.security.incident_player_vlc import IncidentPlayerVLC from views.alerts_panel import AlertsPanel from views.notification_view import NotificationView from views.fruits_view import FruitsView +from views.sound.sound_view import SoundView +from views.ground_view import GroundView +from views.auth_status_view import AuthStatusView +from views.ground_view import GroundView +from views.auth_status_view import AuthStatusView from dashboard_api import DashboardApi from vast.alerts.alert_service import AlertService +from views.leaf_diseases import LeafDiseaseView +from views.aerial_img_galery import FieldsGridView +from views.graphs_aerial_view import AerialGraphsView +from views.aerial_main_view import AerialView +# === New Sensors GUI imports === +from views.sensorsMainView import SensorsMainView +from views.sensorsMapView import SensorsMapView +from views.sensorDetailsTab import SensorDetailsTab +from views.sensors_status_summary import SensorsStatusSummary +from views.security.incident_player_vlc import IncidentPlayerVLC +# === New Sensors GUI imports === +from views.sensorsMainView import SensorsMainView +from views.sensorsMapView import SensorsMapView +from views.sensorDetailsTab import SensorDetailsTab +from views.sensors_status_summary import SensorsStatusSummary class MainWindow(QMainWindow): @@ -937,7 +49,9 @@ class MainWindow(QMainWindow): def __init__(self, api: DashboardApi, parent=None): super().__init__(parent) - self.setWindowTitle("VAST – Dashboard") + self.setWindowTitle("AgCloud – Dashboard") + self.resize(1280, 760) + self.setWindowTitle("AgCloud – Dashboard") self.resize(1280, 760) self.api = api @@ -945,52 +59,39 @@ def __init__(self, api: DashboardApi, parent=None): # GLOBAL STYLE # ─────────────────────────────── self.setStyleSheet(""" - QMainWindow { - background-color: #f9fafb; - } - QMenuBar { - background-color: #e5e7eb; - font-size: 11.5pt; - padding: 4px 10px; - } + QMainWindow { background-color: #f9fafb; } + QMenuBar { background-color: #e5e7eb; font-size: 11.5pt; padding: 4px 10px; } QToolBar { - background: qlineargradient( - x1:0, y1:0, x2:0, y2:1, - stop:0 #ffffff, - stop:1 #f3f4f6 - ); - border-bottom: 1px solid #d1d5db; - padding: 2px 10px; - min-height: 42px; - } - QToolButton { - background-color: transparent; - border: none; - padding: 4px; - border-radius: 8px; - font-size: 20px; - } - QToolButton:hover { - background-color: #e5e7eb; + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #ffffff, stop:1 #f3f4f6); + border-bottom: 1px solid #d1d5db; padding: 2px 10px; min-height: 42px; } - QListWidget { - background-color: #ffffff; - border: none; - font-size: 12pt; - color: #111827; - } - QListWidget::item { - padding: 10px; - border-radius: 6px; - } - QListWidget::item:selected { - background-color: #10b981; - color: white; - } - QStatusBar { - background-color: #f3f4f6; - font-size: 10pt; + QToolButton { background-color: transparent; border: none; padding: 4px; border-radius: 8px; font-size: 20px; } + QToolButton:hover { background-color: #e5e7eb; } + QListWidget { background-color: #ffffff; border: none; font-size: 12pt; color: #111827; } + QListWidget::item { padding: 10px; border-radius: 6px; } + QListWidget::item:selected { background-color: #10b981; color: white; } + QStatusBar { background-color: #f3f4f6; font-size: 10pt; } + """) + + # ─────────────────────────────── + # MENU + # ─────────────────────────────── + # ─────────────────────────────── + # GLOBAL STYLE + # ─────────────────────────────── + self.setStyleSheet(""" + QMainWindow { background-color: #f9fafb; } + QMenuBar { background-color: #e5e7eb; font-size: 11.5pt; padding: 4px 10px; } + QToolBar { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #ffffff, stop:1 #f3f4f6); + border-bottom: 1px solid #d1d5db; padding: 2px 10px; min-height: 42px; } + QToolButton { background-color: transparent; border: none; padding: 4px; border-radius: 8px; font-size: 20px; } + QToolButton:hover { background-color: #e5e7eb; } + QListWidget { background-color: #ffffff; border: none; font-size: 12pt; color: #111827; } + QListWidget::item { padding: 10px; border-radius: 6px; } + QListWidget::item:selected { background-color: #10b981; color: white; } + QStatusBar { background-color: #f3f4f6; font-size: 10pt; } """) # ─────────────────────────────── @@ -1001,13 +102,16 @@ def __init__(self, api: DashboardApi, parent=None): self.back_action.setShortcut("Alt+Left") self.back_action.triggered.connect(self.go_back) file_menu.addAction(self.back_action) - + self.logout_action = QAction("Log out", self) self.logout_action = QAction("Log out", self) self.logout_action.triggered.connect(self._logout) file_menu.addAction(self.logout_action) # ─────────────────────────────── - # TOP BAR + # TOP BAR (toolbar) + # ─────────────────────────────── + # ─────────────────────────────── + # TOP BAR (toolbar) # ─────────────────────────────── toolbar = self.addToolBar("Main Toolbar") toolbar.setMovable(False) @@ -1019,13 +123,6 @@ def __init__(self, api: DashboardApi, parent=None): top_bar_layout.setContentsMargins(8, 0, 8, 0) top_bar_layout.setSpacing(10) - # Back button - back_btn = QToolButton() - back_btn.setIcon(QIcon.fromTheme("go-previous")) - back_btn.setIconSize(QSize(28, 28)) - back_btn.setToolTip("Go back") - back_btn.clicked.connect(self.go_back) - # Logout button logout_btn = QPushButton("Logout") logout_btn.setToolTip("Log out") @@ -1040,53 +137,84 @@ def __init__(self, api: DashboardApi, parent=None): font-size: 11pt; font-weight: 600; } - QPushButton:hover { - background-color: #059669; - } - QPushButton:pressed { - background-color: #047857; + QPushButton:hover { background-color: #059669; } + QPushButton:pressed { background-color: #047857; } + """) + logout_btn.clicked.connect(self._logout) + + # Alert bell + toolbar.setIconSize(QSize(32, 32)) + + top_bar = QWidget() + top_bar_layout = QHBoxLayout(top_bar) + top_bar_layout.setContentsMargins(8, 0, 8, 0) + top_bar_layout.setSpacing(10) + + # Logout button + logout_btn = QPushButton("Logout") + logout_btn.setToolTip("Log out") + logout_btn.setCursor(Qt.CursorShape.PointingHandCursor) + logout_btn.setStyleSheet(""" + QPushButton { + background-color: #10b981; + color: white; + border: none; + border-radius: 8px; + padding: 6px 16px; + font-size: 11pt; + font-weight: 600; } + QPushButton:hover { background-color: #059669; } + QPushButton:pressed { background-color: #047857; } """) logout_btn.clicked.connect(self._logout) - # Bell button + # Alert bell self.alert_button = QToolButton() self.alert_button.setToolTip("Show alerts") self.alert_button.setText("🔔") self.alert_button.setIconSize(QSize(40, 40)) + self.alert_button.setIconSize(QSize(40, 40)) self.alert_button.setStyleSheet(""" QToolButton { + font-size: 30px; font-size: 30px; border: none; background: transparent; padding: 4px; border-radius: 8px; + border-radius: 8px; } - QToolButton:hover { - background-color: #e5e7eb; - } + QToolButton:hover { background-color: #e5e7eb; } + QToolButton:hover { background-color: #e5e7eb; } """) - # Larger blue badge + # Alert badge + # Alert badge self.alert_badge = QLabel("0", self.alert_button) self.alert_badge.setAlignment(Qt.AlignmentFlag.AlignCenter) self.alert_badge.setFixedSize(24, 24) + self.alert_badge.setFixedSize(24, 24) self.alert_badge.setStyleSheet(""" QLabel { - background-color: #3b82f6; /* blue */ + background-color: #3b82f6; + background-color: #3b82f6; color: white; font-size: 10pt; + font-size: 10pt; font-weight: bold; border-radius: 12px; border: 2px solid white; + border-radius: 12px; + border: 2px solid white; } """) self.alert_badge.hide() - # Position badge dynamically def reposition_badge(): btn_w = self.alert_button.width() self.alert_badge.move(btn_w - 22, 2) + self.alert_badge.move(btn_w - 22, 2) self.alert_badge.raise_() self.alert_button.resizeEvent = lambda e: ( @@ -1095,18 +223,39 @@ def reposition_badge(): ) reposition_badge() - # Title - title_label = QLabel("VAST Dashboard") - title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - title_label.setStyleSheet(""" + # ─────────────────────────────── + # TITLE AREA (Updated) + # ─────────────────────────────── + title_container = QWidget() + title_layout = QVBoxLayout(title_container) + title_layout.setContentsMargins(0, 0, 0, 0) + title_layout.setSpacing(0) + + main_title = QLabel("AgCloud") + main_title.setAlignment(Qt.AlignmentFlag.AlignCenter) + main_title.setStyleSheet(""" QLabel { - font-size: 17pt; - font-weight: 600; - color: #111827; + font-size: 22pt; + font-weight: 700; + color: #047857; + letter-spacing: 1px; } """) - # Shadow + subtitle = QLabel("The Smart Platform that Protects and Optimizes Your Field") + subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter) + subtitle.setStyleSheet(""" + QLabel { + font-size: 11pt; + font-weight: 500; + color: #374151; + margin-top: 2px; + } + """) + + title_layout.addWidget(main_title) + title_layout.addWidget(subtitle) + shadow = QGraphicsDropShadowEffect() shadow.setBlurRadius(8) shadow.setColor(QColor(0, 0, 0, 35)) @@ -1116,44 +265,124 @@ def reposition_badge(): top_bar_layout.addWidget(logout_btn) top_bar_layout.addWidget(self.alert_button) top_bar_layout.addStretch() - top_bar_layout.addWidget(title_label) + top_bar_layout.addWidget(title_container) top_bar_layout.addStretch() + toolbar.addWidget(top_bar) + # ─────────────────────────────── + # NAVIGATION + # ─────────────────────────────── + # ─────────────────────────────── + # TITLE AREA (Updated) + # ─────────────────────────────── + title_container = QWidget() + title_layout = QVBoxLayout(title_container) + title_layout.setContentsMargins(0, 0, 0, 0) + title_layout.setSpacing(0) + + main_title = QLabel("AgCloud") + main_title.setAlignment(Qt.AlignmentFlag.AlignCenter) + main_title.setStyleSheet(""" + QLabel { + font-size: 22pt; + font-weight: 700; + color: #047857; + letter-spacing: 1px; + } + """) + + subtitle = QLabel("The Smart Platform that Protects and Optimizes Your Field") + subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter) + subtitle.setStyleSheet(""" + QLabel { + font-size: 11pt; + font-weight: 500; + color: #374151; + margin-top: 2px; + } + """) + + title_layout.addWidget(main_title) + title_layout.addWidget(subtitle) + + shadow = QGraphicsDropShadowEffect() + shadow.setBlurRadius(8) + shadow.setColor(QColor(0, 0, 0, 35)) + shadow.setOffset(0, 2) + top_bar.setGraphicsEffect(shadow) + + top_bar_layout.addWidget(logout_btn) + top_bar_layout.addWidget(self.alert_button) + top_bar_layout.addStretch() + top_bar_layout.addWidget(title_container) + top_bar_layout.addStretch() toolbar.addWidget(top_bar) # ─────────────────────────────── - # NAVIGATION DOCK + # NAVIGATION # ─────────────────────────────── self.nav_dock = QDockWidget("Navigation", self) self.nav_dock.setFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures) self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.nav_dock) - self.nav_list = QListWidget(self.nav_dock) self.nav_dock.setWidget(self.nav_list) self.nav_dock.setMinimumWidth(220) + self.nav_dock.setMinimumWidth(220) - font = QFont() - font.setPointSize(12) + font = QFont(); font.setPointSize(12) self.nav_list.setFont(font) - for name in [ - "Home", "Sensors", "Sound", "Ground Image", - "Aerial Image", "Fruits", "Security", "Settings", "Notifications" - ]: - QListWidgetItem(f" {name}", self.nav_list) + # for main_item in ["Home", "Sensors", "Sound", "Ground Image", "Aerial Image", "Fruits", "Security", "Settings", "Notifications", "Auth", "Leaf Diseases"]: + # item.setData(Qt.ItemDataRole.UserRole, {"type": "main"}) + # self.nav_list.addItem(item) + # if main_item == "Sensors": + # for sub in ["Live Data", "Sensor Health", "Location Map"]: + # sub_item = QListWidgetItem(f" ↳ {sub}") + # sub_item.setData(Qt.ItemDataRole.UserRole, {"type": "sub", "parent": main_item, "name": sub}) + # sub_item.setHidden(True) + # self.nav_list.addItem(sub_item) + + # if main_item == "Aerial Image": + # for sub in ["Galery", "Graph"]: + # sub_item = QListWidgetItem(f" ↳ {sub}") + # sub_item.setData(Qt.ItemDataRole.UserRole, {"type": "sub", "parent": main_item, "name": sub}) + # sub_item.setHidden(True) + # self.nav_list.addItem(sub_item) + + font = QFont(); font.setPointSize(12) + self.nav_list.setFont(font) + for main_item in ["Home", "Sensors", "Sound", "Ground Image", "Aerial Image", "Fruits", "Security", "Settings", "Notifications", "Auth", "Leaf Diseases"]: + item = QListWidgetItem(main_item) + item.setData(Qt.ItemDataRole.UserRole, {"type": "main"}) + self.nav_list.addItem(item) + if main_item == "Sensors": + for sub in ["Live Data", "Sensor Health", "Location Map"]: + sub_item = QListWidgetItem(f" ↳ {sub}") + sub_item.setData(Qt.ItemDataRole.UserRole, {"type": "sub", "parent": main_item, "name": sub}) + sub_item.setHidden(True) + self.nav_list.addItem(sub_item) + if main_item == "Aerial Image": + for sub in ["Galery", "Graph"]: + sub_item = QListWidgetItem(f" ↳ {sub}") + sub_item.setData(Qt.ItemDataRole.UserRole, {"type": "sub", "parent": main_item, "name": sub}) + sub_item.setHidden(True) + self.nav_list.addItem(sub_item) - self.nav_list.setCurrentRow(0) self.nav_list.currentRowChanged.connect(self._on_nav_change) + self.nav_list.itemClicked.connect(self._on_nav_click) + self.nav_list.itemClicked.connect(self._on_nav_click) # ─────────────────────────────── - # ALERT SERVICE + # ALERT SERVICE + PANEL + # ─────────────────────────────── + # ─────────────────────────────── + # ALERT SERVICE + PANEL # ─────────────────────────────── ws_url = os.getenv("ALERTS_WS", "ws://alerts-gateway:8000/ws/alerts") self.alert_service = AlertService(ws_url, api) self.alert_service.alertsUpdated.connect(self.update_alert_badge) self.alert_service.alertAdded.connect(lambda _: self.update_alert_badge()) - # Alerts panel self.alerts_panel = AlertsPanel(self.alert_service) self.alerts_panel.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Tool) self.alerts_panel.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) @@ -1161,51 +390,79 @@ def reposition_badge(): QWidget { background-color: #ffffff; border: 1px solid #d1d5db; + border: 1px solid #d1d5db; border-radius: 10px; } """) self.alerts_panel.hide() self.alert_button.clicked.connect(self.toggle_alert_panel) + # ─────────────────────────────── + # CENTRAL STACKED VIEWS + # ─────────────────────────────── # ─────────────────────────────── # CENTRAL STACKED VIEWS # ─────────────────────────────── self.home = HomeView(api, self.alert_service, self) self.sensors_view = SensorsView(api, self) self.notification_view = NotificationView(self) + # self.fruits_view = FruitsView(api, self) + self.sound_view = SoundView(api, self) + self.ground_view = GroundView(api, self) + self.auth_status = AuthStatusView(api, self) + self.leaf_diseases_view = LeafDiseaseView(api, self) + self.sensors_status_summary = SensorsStatusSummary(api, self) + self.sensors_health = SensorsView(api, self) + self.sensors_main = SensorsMainView(api, self) self.security_view = IncidentPlayerVLC(api, self.alert_service, self) - self.fruits_view = FruitsView(api, self) + self.ground_view = GroundView(api, self) + self.auth_status = AuthStatusView(api, self) + + self.aerial_view = AerialView(api,self) + self.aerial_galery = AerialView(api,self) + self.aerial_graph = AerialGraphsView(api,self) self.stack = QStackedWidget() self.setCentralWidget(self.stack) - self.views = { "Home": self.home, "Sensors": self.sensors_view, + "Sound": self.sound_view, + "Sensors - Live Data": self.sensors_status_summary, + "Sensors - Sensor Health": self.sensors_health, + "Sensors - Location Map": self.sensors_main, "Notifications": self.notification_view, - "Security": self.security_view, - "Fruits": self.fruits_view, - + "Leaf Diseases": self.leaf_diseases_view, + # "Fruits": self.fruits_view, + "Ground Image": self.ground_view, + "Auth": self.auth_status, + "Aerial Image": self.aerial_view, + "Aerial Image - Galery": self.aerial_galery, + "Aerial Image - Graph": self.aerial_graph + # "Security": self.security_view, } + for view in self.views.values(): self.stack.addWidget(view) self.stack.setCurrentWidget(self.home) - self.history: list = [] + self.history = [] + self.history = [] + # ─────────────────────────────── + # STATUS BAR + # ─────────────────────────────── # ─────────────────────────────── # STATUS BAR # ─────────────────────────────── sb = QStatusBar(self) - sb.setStyleSheet(""" - QStatusBar { - background-color: #f3f4f6; - color: #374151; - font-size: 10.5pt; - } - """) + sb.setStyleSheet("QStatusBar { background-color: #f3f4f6; color: #374151; font-size: 10.5pt; }") + sb.setStyleSheet("QStatusBar { background-color: #f3f4f6; color: #374151; font-size: 10.5pt; }") self.setStatusBar(sb) sb.showMessage("Ready") + # ─────────────────────────────── + # ALERT BADGE + # ─────────────────────────────── # ─────────────────────────────── # ALERT BADGE # ─────────────────────────────── @@ -1222,6 +479,7 @@ def toggle_alert_panel(self): self.alerts_panel.hide() return + panel_width, panel_height = 420, 540 panel_width, panel_height = 420, 540 self.alerts_panel.resize(panel_width, panel_height) rect = self.alert_button.geometry() @@ -1229,6 +487,7 @@ def toggle_alert_panel(self): bottom_right = self.alert_button.mapToGlobal(rect.bottomRight()) center_x = (bottom_left.x() + bottom_right.x()) // 2 - (panel_width // 2) pos_y = bottom_left.y() + 8 + pos_y = bottom_left.y() + 8 self.alerts_panel.move(center_x, pos_y) self.alerts_panel.show() self.alerts_panel.raise_() @@ -1240,13 +499,68 @@ def toggle_alert_panel(self): # ─────────────────────────────── # NAVIGATION # ─────────────────────────────── +# ─────────────────────────────── + # NAVIGATION + # ─────────────────────────────── def _on_nav_change(self, row: int) -> None: - name = self.nav_list.item(row).text().strip() - if name in self.views: - self.navigate_to(self.views[name]) + item = self.nav_list.item(row) + name = item.text().strip() + data = item.data(Qt.ItemDataRole.UserRole) + + target_name = name + + if data and data.get("type") == "main" and name == "Aerial Image": + target_name = "Aerial Image - Galery" + + + if target_name in self.views: + self.navigate_to(self.views[target_name]) + elif name in ["Sensors", "Security", "Sound", "Settings"]: + self.statusBar().showMessage(f"Section '{name}' is a category. Please select a sub-item.") else: self.statusBar().showMessage(f"Section '{name}' not implemented yet.") + def _on_nav_click(self, item): + data = item.data(Qt.ItemDataRole.UserRole) + + if data and data.get("type") == "main": + parent = item.text() + + expanded = False + for i in range(self.nav_list.count()): + sub_item = self.nav_list.item(i) + sub_data = sub_item.data(Qt.ItemDataRole.UserRole) + if sub_data and sub_data.get("type") == "sub" and sub_data.get("parent") == parent: + expanded = sub_item.isHidden() + break + + + for i in range(self.nav_list.count()): + sub_item = self.nav_list.item(i) + sub_data = sub_item.data(Qt.ItemDataRole.UserRole) + if sub_data and sub_data.get("type") == "sub" and sub_data.get("parent") == parent: + sub_item.setHidden(not expanded) + + + if expanded and parent == "Aerial Image": + for i in range(self.nav_list.count()): + sub_item = self.nav_list.item(i) + sub_data = sub_item.data(Qt.ItemDataRole.UserRole) + if sub_data and sub_data.get("type") == "sub" and sub_data.get("parent") == parent and sub_data.get("name") == "Galery": + self.nav_list.setCurrentItem(sub_item) + break + + + elif data and data.get("type") == "sub": + parent = data.get("parent") + sub_name = data.get("name") + key = f"{parent} - {sub_name}" + if key in self.views: + self.navigate_to(self.views[key]) + else: + self.statusBar().showMessage(f"Sub-section '{key}' not implemented yet.") + + def navigate_to(self, widget): current = self.stack.currentWidget() if current not in self.history: diff --git a/GUI/src/vast/views/aerial_img_galery.py b/GUI/src/vast/views/aerial_img_galery.py new file mode 100644 index 000000000..871a1f8a2 --- /dev/null +++ b/GUI/src/vast/views/aerial_img_galery.py @@ -0,0 +1,478 @@ +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QScrollArea, QComboBox, QFrame, QGridLayout, + QSpacerItem, QSizePolicy, QToolTip +) +from PyQt6.QtGui import QPixmap +from PyQt6.QtCore import Qt, QPoint, QPointF, QSize, QDateTime +from datetime import datetime + + +from PyQt6.QtWebSockets import QWebSocket +from PyQt6.QtCore import QUrl +import json + +class FieldsGridView(QWidget): + def __init__(self, api, open_field_callback, parent=None): + super().__init__(parent) + self.api = api + self.open_field_callback = open_field_callback + self.all_fields_data = [] + self.anomalies_map = {} + + self.setStyleSheet(""" + QWidget { + background-color: #F8F9FA; + font-family: 'Segoe UI', 'Heebo', sans-serif; + color: #343A40; + } + QFrame#card { + background-color: #FFFFFF; + border: 1px solid #E0E0E0; + border-radius: 20px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + transition: transform 0.2s, box-shadow 0.2s; + } + QFrame#card:hover { + border: 2px solid #00897B; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1); + } + QPushButton { + background-color: #00897B; + color: white; + font-weight: 600; + font-size: 15px; + padding: 10px 18px; + border-radius: 12px; + border: none; + min-width: 120px; + } + QPushButton:hover { background-color: #00695C; } + QPushButton:pressed { background-color: #004D40; } + + QLabel { padding: 0; } + """) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + self.header_layout = self._create_header_and_tools() + layout.addLayout(self.header_layout) + + layout.addWidget(self._create_separator()) + + self.scroll = QScrollArea() + self.scroll.setWidgetResizable(True) + self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.scroll.setStyleSheet("QScrollArea { border: none; }") + layout.addWidget(self.scroll) + + self.container = QWidget() + self.grid = QGridLayout(self.container) + self.grid.setSpacing(40) + self.grid.setContentsMargins(40, 30, 40, 40) + + self.scroll.setWidget(self.container) + + self.load_grid_images() + + self.ws = QWebSocket() + self.ws.textMessageReceived.connect(self._on_ws_message) + self.ws.connected.connect(lambda: print("WS CONNECTED")) + self.ws.disconnected.connect(lambda: print("WS CLOSED")) + self.ws.errorOccurred.connect(lambda e: print("WS ERROR:", e)) + self.ws.open(QUrl("ws://host.docker.internal:8001/ws/aerial-updates")) + + def _on_ws_message(self, message): + try: + data = json.loads(message) + if data.get("type") == "new_image_metadata": + print(f"[AerialImagesView] 🛰️ New image added: {data['img_key']}") + self.load_grid_images() + except Exception as e: + print("[AerialImagesView] WebSocket error:", e) + + + def _create_separator(self): + sep = QFrame() + sep.setFrameShape(QFrame.Shape.HLine) + sep.setFrameShadow(QFrame.Shadow.Sunken) + sep.setFixedHeight(1) + sep.setStyleSheet("QFrame { background-color: #E0E0E0; border: none; margin: 0 0px; }") + return sep + + + def _create_header_and_tools(self): + h_layout = QHBoxLayout() + h_layout.setContentsMargins(30, 20, 30, 10) + + header_label = QLabel("🌱 Fields Maps") + header_label.setStyleSheet(""" + QLabel { + font-size: 28px; + font-weight: 700; + color: #00897B; + } + """) + h_layout.addWidget(header_label) + + h_layout.addStretch(1) + + sort_label = QLabel("Sort by:") + sort_label.setStyleSheet("QLabel { font-size: 16px; font-weight: 500; color: #343A40; }") + h_layout.addWidget(sort_label) + + self.sort_combo = QComboBox() + self.sort_combo.addItems([ + "Last Update (New > Old)", + "Anomaly Count (High > Low)", + "Field Name (A-Z)" + ]) + self.sort_combo.setMinimumWidth(200) + self.sort_combo.setStyleSheet(""" + QComboBox { + border: 1px solid #CED4DA; + border-radius: 8px; + padding: 5px 10px; + font-size: 15px; + background-color: white; + } + QComboBox::drop-down { + border: none; + } + """) + + self.sort_combo.currentIndexChanged.connect(self.sort_and_display_grid) + + h_layout.addWidget(self.sort_combo) + + return h_layout + + + def sort_and_display_grid(self): + sort_option = self.sort_combo.currentText() + + if "Last Update" in sort_option: + key_func = lambda item: self.safe_parse_datetime(item.get("timestamp", "")) + reverse_sort = True + + elif "Anomaly Count" in sort_option: + key_func = lambda item: item.get("anomaly_count", 0) + reverse_sort = True + + elif "Field Name" in sort_option: + key_func = lambda item: item.get("field", "") + reverse_sort = False + + else: + return + + try: + self.all_fields_data.sort(key=key_func, reverse=reverse_sort) + self._render_grid(self.all_fields_data) + + except Exception as e: + print("Sorting error:", e) + + def load_grid_images(self): + try: + fields = self.api.get_all_fields_images() + self.anomalies_map = self.api.get_anomalies_map() + print("Anomalies Map:", self.anomalies_map) + + for item in fields: + gis_value = item.get("key") + if not gis_value: + gis_value = item.get("gis") + print("ITEM KEYS:", item.get("key"), item.get("field")) + + item["anomaly_count"] = self.anomalies_map.get(gis_value, 0) + item["gis"] = gis_value if gis_value else item.get("gis", "Unknown") + + self.all_fields_data = fields + + except Exception as e: + print("API Error:", e) + self.all_fields_data = [] + + self.sort_and_display_grid() + + + def _render_grid(self, fields_to_display): + for i in reversed(range(self.grid.count())): + item = self.grid.itemAt(i) + if item: + w = item.widget() + if w: + w.deleteLater() + elif item.spacerItem(): + self.grid.removeItem(item) + + cols = 3 + row = 0 + col = 0 + + for item in fields_to_display: + card = self.build_card(item) + self.grid.addWidget(card, row, col) + + col += 1 + if col >= cols: + col = 0 + row += 1 + + if self.grid.count() > 0: + spacer_h = QSpacerItem(20, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + self.grid.addItem(spacer_h, 0, cols) + + spacer_v = QSpacerItem(0, 20, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) + self.grid.addItem(spacer_v, row, 0) + + def safe_parse_datetime(self, ts: str): + if not ts: + return datetime.min + + ts = ts.strip() + + # נסיון לתקן ISO עם T → רווח + if "T" in ts: + ts = ts.replace("T", " ") + + formats = [ + "%Y-%m-%d %H:%M:%S.%f", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M", + "%d/%m/%y %H:%M", + "%Y-%m-%d", + "%d/%m/%y" + ] + + for fmt in formats: + try: + return datetime.strptime(ts, fmt) + except: + pass + + # fallback אחרון + try: + return datetime.fromisoformat(ts) + except: + return datetime.min + + + + def parse_timestamp(self, ts: str): + if not ts or ts == "---": + return "", "" + + ts = ts.strip() + + # להחליף T → רווח (כמו ב-2025-11-25T09:11:30) + ts = ts.replace("T", " ") + + # רשימת פורמטים אפשריים שמגיעים מה-API שלך + formats = [ + "%Y-%m-%d %H:%M:%S%z", # עם timezone בסוף + "%Y-%m-%d %H:%M:%S", # עם שניות + "%Y-%m-%d %H:%M", # בלי שניות + "%Y-%m-%d", # רק תאריך + ] + + for fmt in formats: + try: + dt = datetime.strptime(ts, fmt) + + # הפורמט שרצית: + # 25/11/2025 09:11 + pretty = dt.strftime("%d/%m/%Y %H:%M") + + return pretty, "" # מחזירים שדה אחד בלבד להצגה + except: + pass + + # fallback — אם לא הצלחנו לפענח + return ts, "" + + + def build_card(self, data): + gis_value = data.get("gis", "Unknown") + timestamp = data.get("timestamp", "---") + img_bytes = data.get("image_bytes", b"") + name = data.get("field", "Unknown") + anomaly_count = data.get("anomaly_count", 0) + + pretty_timestamp, _ = self.parse_timestamp(timestamp) + + + if len(name) > 15: + short_gis_value = f"{name[22:27]}" + else: + short_gis_value = name + + card = QFrame() + card.setObjectName("card") + # card.setCursor(Qt.CursorShape.PointingHandCursor) + + CARD_WIDTH = 400 + CARD_HEIGHT = 520 + card.setFixedSize(CARD_WIDTH, CARD_HEIGHT) + img_key = data.get("key") + card.mousePressEvent = lambda e: ( + print("[Metadata] Card clicked → img_key:", img_key), + self.open_field_callback(img_key) + ) + layout = QVBoxLayout(card) + layout.setSpacing(10) + layout.setContentsMargins(15, 15, 15, 15) + + IMG_WIDTH = CARD_WIDTH - 30 + IMG_HEIGHT = 220 + + pixmap = QPixmap() + pixmap.loadFromData(img_bytes) + + thumb = pixmap.scaled( + IMG_WIDTH, + IMG_HEIGHT, + Qt.AspectRatioMode.KeepAspectRatioByExpanding, + Qt.TransformationMode.SmoothTransformation + ) + + img_frame = QFrame() + img_frame.setFixedSize(IMG_WIDTH, IMG_HEIGHT) + img_frame.setStyleSheet(""" + QFrame { + border-radius: 15px; + background-color: #E9ECEF; + } + """) + + img_layout = QVBoxLayout(img_frame) + img_layout.setContentsMargins(0, 0, 0, 0) + + img_label = QLabel() + img_label.setPixmap(thumb) + img_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + img_layout.addWidget(img_label) + layout.addWidget(img_frame) + + layout.addSpacing(15) + + field_name_label = QLabel(f"Field Name: {short_gis_value}") + field_name_label.setToolTip(gis_value) + field_name_label.setWordWrap(True) + field_name_label.setStyleSheet(""" + QLabel { + font-size: 17px; + font-weight: 600; + color: #2C3E50; + } + """) + layout.addWidget(field_name_label, alignment=Qt.AlignmentFlag.AlignLeft) + + date_time_container = QFrame() + date_time_layout = QVBoxLayout(date_time_container) + date_time_layout.setSpacing(6) + date_time_layout.setContentsMargins(0, 5, 0, 5) + + if pretty_timestamp: + date_label = QLabel(f"📅 Date: {pretty_timestamp}") + date_label.setStyleSheet(""" + QLabel { + font-size: 16px; + color: #2C3E50; + font-weight: 600; + } + """) + date_time_layout.addWidget(date_label, alignment=Qt.AlignmentFlag.AlignLeft) + + layout.addWidget(date_time_container) + + anomaly_container = QFrame() + anomaly_layout = QVBoxLayout(anomaly_container) + anomaly_layout.setContentsMargins(0, 8, 0, 8) + anomaly_layout.setSpacing(5) + + anomaly_text_label = QLabel("Anomalies:") + anomaly_text_label.setStyleSheet(""" + QLabel { + font-size: 15px; + color: #343A40; + font-weight: 600; + } + """) + anomaly_layout.addWidget(anomaly_text_label, alignment=Qt.AlignmentFlag.AlignLeft) + + # Create horizontal bar graph + bar_container = QFrame() + bar_layout = QHBoxLayout(bar_container) + bar_layout.setContentsMargins(0, 0, 0, 0) + bar_layout.setSpacing(0) + + # Determine color based on anomaly count + if isinstance(anomaly_count, int): + if anomaly_count == 0: + bar_color = "#28A745" # Green + bar_width_percent = 10 + elif anomaly_count <= 2: + bar_color = "#FFC107" # Orange + bar_width_percent = min(anomaly_count * 20, 60) + else: + bar_color = "#DC3545" # Red + bar_width_percent = min(anomaly_count * 15, 100) + else: + bar_color = "#28A745" + bar_width_percent = 10 + + # Create the filled bar + filled_bar = QFrame() + filled_bar.setFixedHeight(20) + filled_bar.setStyleSheet(f""" + QFrame {{ + background-color: {bar_color}; + border-radius: 10px; + }} + """) + + # Create the empty bar background + empty_bar = QFrame() + empty_bar.setFixedHeight(20) + empty_bar.setStyleSheet(""" + QFrame { + background-color: #E9ECEF; + border-radius: 10px; + } + """) + + # Add bars with proper ratio + bar_layout.addWidget(filled_bar, bar_width_percent) + bar_layout.addWidget(empty_bar, 100 - bar_width_percent) + + anomaly_layout.addWidget(bar_container) + + count_label = QLabel(f"{anomaly_count} Anomalies Detected") + count_label.setStyleSheet(""" + QLabel { + font-size: 13px; + color: #6C757D; + } + """) + anomaly_layout.addWidget(count_label, alignment=Qt.AlignmentFlag.AlignLeft) + + layout.addWidget(anomaly_container) + + layout.addSpacing(10) + + separator = QFrame() + separator.setFrameShape(QFrame.Shape.HLine) + separator.setStyleSheet("QFrame { background-color: #E0E0E0; border: none; height: 1px; }") + layout.addWidget(separator) + + layout.addSpacing(8) + open_button = QPushButton("Open Field") + open_button.clicked.connect(lambda: self.open_field_callback(gis_value)) + layout.addWidget(open_button, alignment=Qt.AlignmentFlag.AlignCenter) + + layout.addStretch(1) + + return card diff --git a/GUI/src/vast/views/aerial_main_view.py b/GUI/src/vast/views/aerial_main_view.py new file mode 100644 index 000000000..5c536cd84 --- /dev/null +++ b/GUI/src/vast/views/aerial_main_view.py @@ -0,0 +1,256 @@ +# # # from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QTabWidget +# # # from dashboard_api import DashboardApi +# # # from views.graphs_aerial_view import AerialGraphsView +# # # from views.aerial_view import AerialImagesView +# # # from views.aerial_img_galery import FieldsGridView + + +# # # class AerialView(QWidget): +# # # def __init__(self, api: DashboardApi, parent=None): +# # # super().__init__(parent) +# # # self.api = api + +# # # layout = QVBoxLayout(self) +# # # title = QLabel("Aerial Image Dashboard") +# # # title.setStyleSheet(""" +# # # QLabel { +# # # font-size: 18pt; +# # # font-weight: 600; +# # # color: #111827; +# # # margin-bottom: 8px; +# # # } +# # # """) +# # # layout.addWidget(title) + +# # # self.tabs = QTabWidget() +# # # layout.addWidget(self.tabs, stretch=1) + +# # # # === Sub Tabs === +# # # self.graphs_tab = AerialGraphsView(api, self) +# # # self.metadata_tab = FieldsGridView( +# # # api=self.api, +# # # open_field_callback=self.open_field_page +# # # ) +# # # self.map_tab = AerialImagesView(api, self) + +# # # self.tabs.addTab(self.graphs_tab, "Graphs") +# # # self.tabs.addTab(self.metadata_tab, "Metadata") +# # # self.tabs.addTab(self.map_tab, "Map") + +# # # layout.addStretch() + +# # # def open_field_page(self, img_key): +# # # print("[AerialView] open_field_page received img_key:", img_key) + +# # # self.map_tab.load_latest_images() +# # # print("[AerialView] Images loaded:", len(self.map_tab.images)) + +# # # self.map_tab.focus_on_image_by_key(img_key) +# # # self.tabs.setCurrentWidget(self.map_tab) + + +# # from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QStackedWidget +# # from dashboard_api import DashboardApi + +# # from views.graphs_aerial_view import AerialGraphsView +# # from views.aerial_img_galery import FieldsGridView +# # from views.aerial_view import AerialImagesView + + +# # class AerialView(QWidget): +# # def __init__(self, api: DashboardApi, parent=None): +# # super().__init__(parent) +# # self.api = api + +# # layout = QVBoxLayout(self) + +# # # כותרת +# # title = QLabel("Aerial Image Dashboard") +# # title.setStyleSheet(""" +# # QLabel { +# # font-size: 18pt; +# # font-weight: 600; +# # color: #111827; +# # margin-bottom: 8px; +# # } +# # """) +# # layout.addWidget(title) + +# # # stack — במקום טאבים +# # self.stack = QStackedWidget() +# # layout.addWidget(self.stack, stretch=1) + +# # # ================ VIEWS in stack ================ +# # # 1) גלריה +# # self.gallery_view = FieldsGridView( +# # api=self.api, +# # open_field_callback=self.open_field_page, +# # ) + +# # self.image_view = AerialImagesView( +# # api=self.api, +# # parent=self, +# # on_close=self.return_to_gallery # ← הוספנו callback +# # ) + +# # # הוספה ל־Stack +# # self.stack.addWidget(self.gallery_view) # index 0 +# # self.stack.addWidget(self.image_view) # index 1 + +# # # מסך התחלתי = הגלריה +# # self.stack.setCurrentWidget(self.gallery_view) + +# # # ========================================================== +# # # מעבר מגלריה למסך תמונה +# # # ========================================================== +# # def open_field_page(self, img_key): +# # print("[AerialView] open_field_page received img_key:", img_key) + +# # # 1) טען את כל התמונות +# # self.image_view.load_latest_images() + +# # # 2) סמן את התמונה הנבחרת +# # self.image_view.focus_on_image_by_key(img_key) + +# # # 3) עבור למסך תמונה +# # self.stack.setCurrentWidget(self.image_view) + +# # # ========================================================== +# # # קריאה מכפתור ה־X שבמסך התמונות +# # # ========================================================== +# # def return_to_gallery(self): +# # self.stack.setCurrentWidget(self.gallery_view) + + +# from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QStackedWidget +# from PyQt6.QtCore import Qt +# from dashboard_api import DashboardApi + +# from views.graphs_aerial_view import AerialGraphsView +# from views.aerial_img_galery import FieldsGridView +# from views.aerial_view import AerialImagesView + + +# class AerialView(QWidget): +# def __init__(self, api: DashboardApi, parent=None): +# super().__init__(parent) +# self.api = api + +# layout = QVBoxLayout(self) +# layout.setContentsMargins(0, 0, 0, 0) +# layout.setSpacing(0) + +# self.gallery_view = FieldsGridView( +# api=self.api, +# open_field_callback=self.open_field_page, +# ) +# layout.addWidget(self.gallery_view) + +# self.image_view = AerialImagesView( +# api=self.api, +# parent=self, +# on_close=self.return_to_gallery +# ) +# self.image_view.hide() # מוסתר בהתחלה + +# self.image_view.setParent(self) +# self.image_view.setGeometry(self.rect()) + +# def resizeEvent(self, event): +# """וידוא ש-overlay תמיד מכסה את כל השטח""" +# super().resizeEvent(event) +# if hasattr(self, 'image_view'): +# self.image_view.setGeometry(self.rect()) + +# def open_field_page(self, img_key): +# """הצגת overlay מעל הגלריה""" +# print("[AerialView] open_field_page received img_key:", img_key) + +# # טען את כל התמונות +# self.image_view.load_latest_images() + +# # סמן את התמונה הנבחרת +# self.image_view.focus_on_image_by_key(img_key) + +# self.image_view.setGeometry(self.rect()) +# self.image_view.show() +# self.image_view.raise_() # העלה לחזית + +# def return_to_gallery(self): +# """הסתרת overlay וחזרה לגלריה""" +# self.image_view.hide() + + +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QStackedWidget +from PyQt6.QtCore import Qt +from dashboard_api import DashboardApi + +from views.aerial_img_galery import FieldsGridView +from views.aerial_view import AerialImagesView + + +class AerialView(QWidget): + def __init__(self, api: DashboardApi, parent=None): + super().__init__(parent) + self.api = api + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # --- גלריה --- + self.gallery_view = FieldsGridView( + api=self.api, + open_field_callback=self.open_field_page, + ) + layout.addWidget(self.gallery_view) + + # --- OVERLAY שחור/שקוף --- + self.overlay = QWidget(self) + self.overlay.setGeometry(self.rect()) + self.overlay.setStyleSheet(""" + background-color: rgba(0, 0, 0, 150); + """) + self.overlay.hide() + + # --- תצוגת תמונה --- + self.image_view = AerialImagesView( + api=self.api, + parent=self, + on_close=self.return_to_gallery + ) + + self.image_view.hide() + + # תמיד לוודא שהם מכסים הכל + self.overlay.raise_() + self.image_view.raise_() + + def resizeEvent(self, event): + """הגדלה/הקטנה דינמית של overlay ושל image_view""" + super().resizeEvent(event) + self.overlay.setGeometry(self.rect()) + self.image_view.setGeometry(self.rect()) + + def open_field_page(self, img_key): + """פתיחת תמונה — הצגת overlay + תצוגת תמונה""" + print("[AerialView] open_field_page received img_key:", img_key) + + # מאחורי התמונה יהיה overlay כהה + self.overlay.show() + self.overlay.raise_() + + # התמונה למעלה + self.image_view.setGeometry(self.rect()) + self.image_view.show() + self.image_view.raise_() + + # טען תמונות + self.image_view.load_latest_images() + self.image_view.focus_on_image_by_key(img_key) + + def return_to_gallery(self): + """סגירת התמונה והסרת ה־overlay""" + print("close") + self.overlay.hide() + self.image_view.hide() diff --git a/GUI/src/vast/views/aerial_view.py b/GUI/src/vast/views/aerial_view.py new file mode 100644 index 000000000..83e7e6c9a --- /dev/null +++ b/GUI/src/vast/views/aerial_view.py @@ -0,0 +1,963 @@ +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QScrollArea, QComboBox, QListWidget, QListWidgetItem, QFrame, QGraphicsDropShadowEffect +) +from PyQt6.QtGui import ( + QPixmap, QImage, QPainter, QColor, QPen, QWheelEvent, QPolygonF, QBrush, QIcon, QFont +) +from PyQt6.QtCore import Qt, QPoint, QPointF +from PIL import Image +import io, random, re +from shapely import wkb + + +import json +from shapely.geometry import Polygon + +PALETTE = { + (0, 0, 0): (0, "Other"), + (210, 180, 140): (1, "Bareland"), + (152, 251, 152): (2, "Rangeland"), + (128, 128, 128): (3, "Developed space"), + (255, 255, 255): (4, "Road"), + (0, 100, 0): (5, "Tree"), + (30, 144, 255): (6, "Water"), + (255, 215, 0): (7, "Agriculture"), + (178, 34, 34): (8, "Building"), +} + +class ZoomableImageLabel(QLabel): + def __init__(self): + super().__init__() + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._pixmap = None + self._scale = 1.0 + self._start_pos = None + self._offset = QPoint(0, 0) + self.drawing_mode = False + self.drawing_points = [] + + def setPixmap(self, pixmap: QPixmap): + self._pixmap = pixmap + self._scale = 1.0 + self._offset = QPoint(0, 0) + super().setPixmap(pixmap) + + def wheelEvent(self, event: QWheelEvent): + if self._pixmap is None or self.drawing_mode: + return + factor = 1.25 if event.angleDelta().y() > 0 else 0.8 + self._scale = max(0.2, min(5.0, self._scale * factor)) + self.update_display() + + def mousePressEvent(self, event): + if self.drawing_mode: + if event.button() == Qt.MouseButton.LeftButton: + pos = event.pos() + self.drawing_points.append((pos.x(), pos.y())) + self.update() + elif event.button() == Qt.MouseButton.RightButton: + self.finish_polygon() + return + if event.button() == Qt.MouseButton.LeftButton: + self._start_pos = event.pos() + + def mouseMoveEvent(self, event): + if self.drawing_mode: + return + if event.buttons() & Qt.MouseButton.LeftButton and self._pixmap: + delta = event.pos() - self._start_pos + self._offset += delta + self._start_pos = event.pos() + self.update_display() + + def update_display(self): + if not self._pixmap: + return + size = self.size() + scaled_pixmap = self._pixmap.scaled( + int(self._pixmap.width() * self._scale), + int(self._pixmap.height() * self._scale), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + canvas = QPixmap(size) + canvas.fill(Qt.GlobalColor.white) + painter = QPainter(canvas) + x = (size.width() - scaled_pixmap.width()) // 2 + self._offset.x() + y = (size.height() - scaled_pixmap.height()) // 2 + self._offset.y() + painter.drawPixmap(x, y, scaled_pixmap) + painter.end() + super().setPixmap(canvas) + + def toggle_drawing_mode(self, enabled: bool): + self.drawing_mode = enabled + self.drawing_points = [] + self.update() + + def paintEvent(self, event): + super().paintEvent(event) + if not self.drawing_mode or not self.drawing_points: + return + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + pen = QPen(QColor(255, 0, 0), 3) + painter.setPen(pen) + for x, y in self.drawing_points: + painter.drawEllipse(QPoint(x, y), 4, 4) + for i in range(len(self.drawing_points) - 1): + p1 = QPoint(*self.drawing_points[i]) + p2 = QPoint(*self.drawing_points[i + 1]) + painter.drawLine(p1, p2) + painter.end() + + def finish_polygon(self): + if not self.drawing_mode: + return None + pts = self.drawing_points.copy() + self.drawing_points.clear() + self.drawing_mode = False + self.update() + return pts + + def get_scale(self): + return self._scale + +class AerialImagesView(QWidget): + def __init__(self, api, parent=None, on_close=None): + super().__init__(parent) + self.api = api + self.images = [] + self.current_index = 0 + self.current_image = None + self.color_map = {} + self.mode = None + self.on_close = on_close + + self.setStyleSheet(""" + QWidget { + background-color: rgba(20, 20, 25, 0.75); + font-family: 'Segoe UI', Arial, sans-serif; + } + """) + + # === פריסה ראשית עם מרכוז === + outer_layout = QVBoxLayout(self) + outer_layout.setContentsMargins(30, 30, 30, 30) + outer_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + + modal_card = QFrame() + modal_card.setFixedSize(1300, 750) + modal_card.setStyleSheet(""" + QFrame { + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, + stop:0 rgba(255, 255, 255, 0.98), + stop:1 rgba(248, 252, 250, 0.98)); + border-radius: 25px; + + } + """) + + shadow = QGraphicsDropShadowEffect() + shadow.setBlurRadius(80) + shadow.setXOffset(0) + shadow.setYOffset(15) + shadow.setColor(QColor(31, 138, 112, 80)) + modal_card.setGraphicsEffect(shadow) + + # === פריסה פנימית של החלונית === + card_layout = QVBoxLayout(modal_card) + card_layout.setContentsMargins(0, 0, 0, 0) + card_layout.setSpacing(0) + + header = QFrame() + header.setFixedHeight(60) + header.setStyleSheet(""" + QFrame { + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 #1F8A70, stop:0.3 #1FA87D, stop:0.7 #22B08A, stop:1 #26D0A8); + border-top-left-radius: 25px; + border-top-right-radius: 25px; + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; + } + """) + header_layout = QHBoxLayout(header) + header_layout.setContentsMargins(25, 0, 25, 0) + + # כפתור סגירה + close_btn = QPushButton("✕") + close_btn.setFixedSize(38, 38) + close_btn.setStyleSheet(""" + QPushButton { + background-color: rgba(255, 255, 255, 0.25); + color: white; + border-radius: 19px; + font-size: 20px; + font-weight: bold; + border: 2px solid rgba(255, 255, 255, 0.4); + } + QPushButton:hover { + background-color: rgba(255, 90, 90, 0.85); + border: 2px solid rgba(255, 255, 255, 0.6); + } + QPushButton:pressed { + background-color: rgba(220, 50, 50, 0.95); + } + """) + close_btn.clicked.connect(lambda: self.on_close() if self.on_close else None) + + # כותרת + title_label = QLabel("📷 Aerial Image Viewer") + title_label.setStyleSheet(""" + QLabel { + color: white; + font-size: 20px; + font-weight: 700; + background: transparent; + letter-spacing: 0.8px; + text-shadow: 0px 2px 4px rgba(0, 0, 0, 0.2); + } + """) + + header_layout.addWidget(close_btn) + header_layout.addWidget(title_label) + header_layout.addStretch() + + card_layout.addWidget(header) + + # === תוכן ראשי (Content Area) === + content = QFrame() + content.setStyleSheet(""" + QFrame { + background-color: #FAFBFC; + border-top-left-radius: 0px; + border-top-right-radius: 0px; + border-bottom-left-radius: 25px; + border-bottom-right-radius: 25px; + } + """) + content_layout = QHBoxLayout(content) + content_layout.setContentsMargins(15, 20, 15, 20) + content_layout.setSpacing(15) + + # === סיידבאר משמאל === + sidebar = QFrame() + sidebar.setFixedWidth(260) + sidebar.setStyleSheet(""" + QFrame { + background-color: rgba(255, 255, 255, 0.95); + border-radius: 16px; + border: 2px solid rgba(31, 138, 112, 0.2); + } + """) + sidebar_layout = QVBoxLayout(sidebar) + sidebar_layout.setContentsMargins(16, 16, 16, 16) + sidebar_layout.setSpacing(14) + + # סגנון כללי לכפתורים ורכיבים + widget_style = """ + QPushButton { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #1F8A70, stop:1 #17997B); + color: white; + font-weight: 600; + font-size: 13px; + padding: 12px 16px; + border-radius: 10px; + border: none; + } + QPushButton:hover { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #17997B, stop:1 #117C66); + } + QPushButton:pressed { + background: #117C66; + } + QComboBox { + background-color: #F8FAFB; + border: 2px solid #E5E9EB; + border-radius: 10px; + padding: 10px 12px; + font-size: 13px; + font-weight: 500; + color: #2C3E50; + } + QComboBox:hover { + border: 2px solid #1F8A70; + } + QComboBox::drop-down { + border: none; + padding-right: 8px; + } + QListWidget { + background-color: #F8FAFB; + border: 2px solid #E5E9EB; + border-radius: 12px; + padding: 10px; + font-size: 12px; + } + QListWidget::item { + padding: 8px; + border-radius: 7px; + margin: 2px 0px; + } + QListWidget::item:hover { + background-color: rgba(31, 138, 112, 0.1); + } + QLabel { + color: #2C3E50; + font-weight: 600; + font-size: 12px; + background: transparent; + } + """ + + # תיבת בחירת תאריך + date_label = QLabel("📅 Select Date") + date_label.setStyleSheet("font-size: 14px; color: #1F8A70; font-weight: 700;") + self.date_selector = QComboBox() + self.date_selector.setStyleSheet(widget_style) + self.date_selector.currentIndexChanged.connect(self.change_date) + + sidebar_layout.addWidget(date_label) + sidebar_layout.addWidget(self.date_selector) + + # מפריד + separator = QFrame() + separator.setFrameShape(QFrame.Shape.HLine) + separator.setStyleSheet("background-color: rgba(31, 138, 112, 0.2); max-height: 2px;") + sidebar_layout.addWidget(separator) + + # Legend + legend_label = QLabel("🗺️ Legend") + legend_label.setStyleSheet("font-size: 14px; color: #1F8A70; font-weight: 700;") + self.legend = QListWidget() + self.legend.setStyleSheet(widget_style) + sidebar_layout.addWidget(legend_label) + sidebar_layout.addWidget(self.legend, stretch=1) + + sidebar_layout.addStretch() + + # כפתורי פעולה + self.btn_polygon = QPushButton("🔺 Show Polygon") + self.btn_objects = QPushButton("🎯 Show Objects") + self.btn_anomalies = QPushButton("⚠️ Show Anomalies") + self.btn_segments = QPushButton("🧩 Show Segments") + + for btn in [self.btn_polygon, self.btn_objects, self.btn_anomalies, self.btn_segments]: + btn.setStyleSheet(widget_style) + + self.btn_polygon.clicked.connect(lambda: self.toggle_mode("polygon")) + self.btn_objects.clicked.connect(lambda: self.toggle_mode("objects")) + self.btn_anomalies.clicked.connect(lambda: self.toggle_mode("anomalies")) + self.btn_segments.clicked.connect(lambda: self.toggle_mode("segments")) + + self.current_polygon = None + self.action_btn = QPushButton() + self.action_btn.setStyleSheet(widget_style) + self.action_btn.hide() + + sidebar_layout.addWidget(self.action_btn) + sidebar_layout.addWidget(self.btn_polygon) + sidebar_layout.addWidget(self.btn_objects) + sidebar_layout.addWidget(self.btn_anomalies) + sidebar_layout.addWidget(self.btn_segments) + + content_layout.addWidget(sidebar) + + image_container = QFrame() + image_container.setStyleSheet(""" + QFrame { + background-color: rgba(255, 255, 255, 0.95); + border-radius: 16px; + border: 2px solid rgba(31, 138, 112, 0.2); + } + """) + image_layout = QVBoxLayout(image_container) + image_layout.setContentsMargins(15, 15, 15, 15) + image_layout.setSpacing(0) + + self.scroll = QScrollArea() + self.scroll.setWidgetResizable(True) + self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.scroll.setStyleSheet(""" + QScrollArea { + border: none; + background-color: #F8FAFB; + border-radius: 12px; + } + QScrollBar:vertical, QScrollBar:horizontal { + background: rgba(245, 248, 250, 0.6); + border-radius: 5px; + width: 10px; + height: 10px; + margin: 2px; + } + QScrollBar::handle:vertical, QScrollBar::handle:horizontal { + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 rgba(31, 138, 112, 0.3), + stop:1 rgba(38, 208, 168, 0.3)); + border-radius: 5px; + min-height: 30px; + min-width: 30px; + } + QScrollBar::handle:hover { + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 rgba(31, 138, 112, 0.5), + stop:1 rgba(38, 208, 168, 0.5)); + } + QScrollBar::add-line, QScrollBar::sub-line { + height: 0px; + width: 0px; + } + """) + self.image_label = ZoomableImageLabel() + self.image_label.wheelEvent = self.custom_wheel_event + self.scroll.setWidget(self.image_label) + + image_layout.addWidget(self.scroll, stretch=1) + + content_layout.addWidget(image_container, stretch=1) + card_layout.addWidget(content, stretch=1) + + outer_layout.addWidget(modal_card) + + def custom_wheel_event(self, event: QWheelEvent): + # Call original wheel event + ZoomableImageLabel.wheelEvent(self.image_label, event) + + # Update scrollbar visibility based on zoom level + scale = self.image_label.get_scale() + if scale <= 1.0: + # No zoom - hide scrollbars + self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + else: + # Zoomed in - show scrollbars when needed + self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + + def load_latest_images(self): + print("[AerialImagesView] 🔄 load_latest_images called") + try: + data = self.api.list_latest_images_per_gis() + if not data: + self.image_label.setText("❌ No aerial images found.") + return + self.images = data + self.current_index = 0 + self.show_image(0) + self.update_date_selector() + except Exception as e: + self.image_label.setText(f"[ERROR] {e}") + + + + def show_image(self, index): + if not self.images: + return + index = index % len(self.images) + self.current_index = index + img_info = self.images[index] + key = img_info.get("img_key") + self.current_image = None + self.title = img_info.get('file_name', 'Image') + try: + img_bytes = self.api.get_image_bytes_from_minio(key) + if not img_bytes: + raise ValueError("Empty image data") + self.current_image = img_bytes + image = Image.open(io.BytesIO(img_bytes)).convert("RGB") + qimage = QImage(image.tobytes(), image.width, image.height, QImage.Format.Format_RGB888) + pixmap = QPixmap.fromImage(qimage) + self.image_label.setPixmap(pixmap) + self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + except Exception as e: + self.image_label.setText(f"❌ Failed to load image: {e}") + self.legend.hide() + + def update_date_selector(self): + self.date_selector.clear() + if not self.images: + return + gis = self.images[self.current_index].get("gis") + dates = self.api.list_dates_for_gis(gis) + for d in sorted(dates, reverse=True): + self.date_selector.addItem(d) + + def change_date(self): + selected = self.date_selector.currentText() + if not selected or not self.images: + return + gis = self.images[self.current_index].get("gis") + images = self.api.list_all_images_for_gis(gis) + match = next((img for img in images if selected in img.get("timestamp_utc", "")), None) + if match: + self.images[self.current_index] = match + self.show_image(self.current_index) + + def show_next_gis(self): + if not self.images: + return + self.action_btn.hide() + self.show_image((self.current_index + 1) % len(self.images)) + self.mode = None + + def show_prev_gis(self): + if not self.images: + return + self.action_btn.hide() + self.show_image((self.current_index - 1) % len(self.images)) + self.mode = None + + def toggle_mode(self, mode: str): + self.action_btn.hide() + self.current_polygon = None + self.legend.hide() + + if self.mode == mode: + self.mode = None + self.refresh_image() + return + + self.mode = mode + self.refresh_image() + + if mode == "objects": + self.show_objects() + elif mode == "anomalies": + self.show_anomalies() + elif mode == "segments": + self.show_segments() + elif mode == "polygon": + self.handle_polygon_button() + + def refresh_image(self): + if not self.current_image: + return + image = Image.open(io.BytesIO(self.current_image)).convert("RGB") + qimage = QImage(image.tobytes(), image.width, image.height, QImage.Format.Format_RGB888) + pixmap = QPixmap.fromImage(qimage) + self.image_label.setPixmap(pixmap) + + def show_anomalies(self): + key = self.images[self.current_index].get("img_key") + detections = [a for a in self.api.list_anomalies() if a.get("img_key") == key] + self.draw_boxes(detections, "anomaly") + + def show_objects(self): + key = self.images[self.current_index].get("img_key") + detections = [o for o in self.api.list_objects() if o.get("img_key") == key] + self.draw_boxes(detections, "object") + + def draw_boxes(self, detections, mode="object"): + if not self.current_image: + return + + self.color_map.clear() + self.refresh_image() + + image = Image.open(io.BytesIO(self.current_image)).convert("RGB") + w, h = image.size + qimg = QImage(image.tobytes(), w, h, QImage.Format.Format_RGB888) + pix = QPixmap.fromImage(qimg) + painter = QPainter(pix) + + for d in detections: + lbl = d.get("label", "Obj" if mode == "object" else "Anomaly") + if lbl not in self.color_map: + if mode == "object": + self.color_map[lbl] = QColor(random.randint(0, 200), random.randint(80, 255), random.randint(0, 200)) + else: + self.color_map[lbl] = QColor(random.randint(180, 255), random.randint(50, 100), random.randint(50, 100)) + pen = QPen(self.color_map[lbl], 3) + painter.setPen(pen) + x1, y1, x2, y2 = [d["bbox_x1"]*w, d["bbox_y1"]*h, d["bbox_x2"]*w, d["bbox_y2"]*h] + painter.drawRect(int(x1), int(y1), int(x2-x1), int(y2-y1)) + painter.drawText(int(x1)+5, int(y1)+15, lbl) + painter.end() + + self.image_label.setPixmap(pix) + self.update_legend() + + def update_legend(self): + self.legend.clear() + self.legend.show() + + for label, color in self.color_map.items(): + item = QListWidgetItem(f" {label}") + item.setForeground(QColor("black")) + + pixmap = QPixmap(16, 16) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setBrush(QBrush(color)) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawEllipse(2, 2, 12, 12) + painter.end() + + item.setIcon(QIcon(pixmap)) + self.legend.addItem(item) + + def _dbg_print_gis(self, label, g): + print(f"[DBG] {label} =", g, "type=", type(g)) + if isinstance(g, dict): + for k, v in g.items(): + print(f" └─ {k}: {v} (type={type(v)})") + + def handle_polygon_button(self): + self.refresh_image() + self.legend.hide() + self.action_btn.hide() + + img = self.images[self.current_index] + gis_origin = img.get("gis_origin") + + if not gis_origin: + print("[Polygon] No gis_origin in image metadata") + return + + def normalize_gis(g): + if not g: + return None + try: + return { + "latitude": float(g["latitude"]), + "longitude": float(g["longitude"]) + } + except: + return None + + gis_norm = normalize_gis(gis_origin) + + all_polygons = self.api.list_polygons() + polygons = [ + p for p in all_polygons + if normalize_gis(p.get("gis_origin")) == gis_norm + ] + + if polygons: + print("[DEBUG] FOUND polygon") + self.current_polygon = polygons[0] + self.current_polygon_id = self.current_polygon.get("id") + + px = self.current_polygon.get("pixel_points") + if px: + print("[DEBUG] drawing pixel polygon") + if isinstance(px, dict) and "points" in px: + px = px["points"] + self.draw_pixel_polygon(px) + else: + print("[DEBUG] polygon has NO pixel_points") + + self.action_btn.setText("Update Polygon") + try: + self.action_btn.clicked.disconnect() + except: + pass + self.action_btn.show() + self.action_btn.clicked.connect(lambda: self.start_polygon_drawing(update=True)) + + else: + print("[DEBUG] NO polygon found → new") + self.current_polygon = None + self.current_polygon_id = None + + self.action_btn.setText("Create Polygon") + try: + self.action_btn.clicked.disconnect() + except: + pass + self.action_btn.show() + self.action_btn.clicked.connect(lambda: self.start_polygon_drawing(update=False)) + + def draw_polygon_from_hex(self, polygon_hex: str): + try: + geom = wkb.loads(bytes.fromhex(polygon_hex)) + self.draw_polygon_from_wkt(geom.wkt) + except Exception as e: + print(f"[draw_polygon_from_hex] Error: {e}") + + def draw_polygon_from_wkt(self, polygon_wkt: str): + if not self.current_image or not polygon_wkt: + return + match = re.search(r"POLYGON\s*$$$$(.+)$$$$", polygon_wkt) + if not match: + return + coords_text = match.group(1).strip() + pairs = coords_text.split(",") + points = [] + for p in pairs: + try: + lon, lat = map(float, p.strip().split()) + points.append((lon, lat)) + except ValueError: + pass + if not points: + return + + image = Image.open(io.BytesIO(self.current_image)).convert("RGB") + w, h = image.size + qimg = QImage(image.tobytes(), w, h, QImage.Format.Format_RGB888) + pix = QPixmap.fromImage(qimg) + painter = QPainter(pix) + pen = QPen(QColor(255, 100, 100), 4) + painter.setPen(pen) + painter.setBrush(QBrush(QColor(255, 0, 0, 50))) + + lons = [p[0] for p in points] + lats = [p[1] for p in points] + min_lon, max_lon = min(lons), max(lons) + min_lat, max_lat = min(lats), max(lats) + + poly = QPolygonF() + for lon, lat in points: + x = (lon - min_lon) / (max_lon - min_lon) * w + y = h - (lat - min_lat) / (max_lat - min_lat) * h + poly.append(QPointF(x, y)) + + painter.drawPolygon(poly) + painter.end() + self.image_label.setPixmap(pix) + + def draw_segmentation_overlay(self, mask_bytes): + try: + base_img = Image.open(io.BytesIO(self.current_image)).convert("RGBA") + mask_img = Image.open(io.BytesIO(mask_bytes)).convert("RGBA") + mask_img = mask_img.resize(base_img.size, Image.Resampling.NEAREST) + + alpha = 120 + r, g, b, a = mask_img.split() + a = a.point(lambda i: alpha) + mask_img = Image.merge("RGBA", (r, g, b, a)) + + merged = Image.alpha_composite(base_img, mask_img) + + qimg = QImage(merged.tobytes(), merged.width, merged.height, QImage.Format.Format_RGBA8888) + pixmap = QPixmap.fromImage(qimg) + + self.image_label.setPixmap(pixmap) + + except Exception as e: + self.image_label.setText(f"Failed to draw segmentation: {e}") + + def show_segments(self): + if not self.current_image: + return + img_key = self.images[self.current_index].get("img_key") + print("DEBUG img_key from GUI:", img_key) + seg = self.api.get_segmentation_record(img_key) + + if not seg: + self.image_label.setText("No segmentation found for this image.") + return + + mask_path = seg.get("mask_path") + if not mask_path: + self.image_label.setText("Segmentation exists but mask_path is empty.") + return + + mask_bytes = self.api.get_mask_bytes_from_minio(mask_path) + + if not mask_bytes: + self.image_label.setText("Failed to load segmentation mask from storage.") + return + + self.draw_segmentation_overlay(mask_bytes) + self.update_legend_from_mask(mask_bytes) + + def update_legend_from_mask(self, mask_bytes): + import numpy as np + + mask_img = Image.open(io.BytesIO(mask_bytes)).convert("RGB") + arr = np.array(mask_img) + + unique_colors = set(tuple(c) for c in arr.reshape(-1, 3)) + + self.legend.clear() + self.legend.show() + + for rgb, (class_id, name) in PALETTE.items(): + if rgb not in unique_colors: + continue + + item = QListWidgetItem(f" {name}") + item.setForeground(QColor("black")) + + icon_pix = QPixmap(16, 16) + icon_pix.fill(Qt.GlobalColor.transparent) + + painter = QPainter(icon_pix) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setBrush(QBrush(QColor(*rgb))) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawEllipse(2, 2, 12, 12) + painter.end() + + item.setIcon(QIcon(icon_pix)) + self.legend.addItem(item) + + def update_polygon_button_state(self): + gis = self.images[self.current_index].get("gis") + polygons = [p for p in self.api.list_polygons() if p.get("gis") == gis] + + if polygons: + self.current_polygon = polygons[0] + self.btn_polygon.setText("Show Polygon") + else: + self.current_polygon = None + self.btn_polygon.setText("Create Polygon") + + def start_polygon_drawing(self, update=False): + self.refresh_image() + self.current_polygon = None + + self.image_label.toggle_drawing_mode(True) + self.action_btn.setText("Save Polygon") + self.action_btn.show() + + try: + self.action_btn.clicked.disconnect() + except TypeError: + pass + + self.action_btn.clicked.connect(lambda: self.save_polygon(update)) + + def save_polygon(self, update=False): + points = self.image_label.finish_polygon() + if not points: + return + + if points[0] != points[-1]: + points.append(points[0]) + + print("[DEBUG] save_polygon called") + print("[DEBUG] pixel points =", points) + + img = self.images[self.current_index] + gis_origin = img.get("gis_origin") + + lon_lat_pairs = [f"{x/100000:.6f} {y/100000:.6f}" for x, y in points] + polygon_str = ", ".join(lon_lat_pairs) + wkt_polygon = f"POLYGON(({polygon_str}))" + + payload = { + "gis_origin": gis_origin, + "boundary": wkt_polygon, + "pixel_points": { + "points": [{"x": x, "y": y} for x, y in points] + } + } + + print("[DEBUG] Final payload =", json.dumps(payload, indent=4)) + + if update and self.current_polygon_id: + ok = self.api.update_db_api("field_polygons", self.current_polygon_id, payload) + else: + ok = self.api.write_to_db_api("field_polygons", payload) + + print("[DEBUG] DB RESULT:", ok) + + if ok: + print("[Polygon] Saved OK") + + self.current_polygon = payload + self.draw_pixel_polygon(payload["pixel_points"]["points"]) + + self.action_btn.setText("Update Polygon") + self.action_btn.show() + + try: + self.action_btn.clicked.disconnect() + except: + pass + + self.action_btn.clicked.connect(lambda: self.start_polygon_drawing(update=True)) + else: + print("[Polygon] FAILED") + + def draw_pixel_polygon(self, pixel_points): + if not self.current_image: + return + + # Decode formats + if isinstance(pixel_points, dict) and "points" in pixel_points: + pixel_points = pixel_points["points"] + + if isinstance(pixel_points, str): + try: + pixel_points = json.loads(pixel_points).get("points", []) + except: + print("[Polygon] Failed to decode pixel_points string") + return + + if not isinstance(pixel_points, list) or not pixel_points: + print("[Polygon] No valid points") + return + + # Load image + image = Image.open(io.BytesIO(self.current_image)).convert("RGB") + w, h = image.size + qimg = QImage(image.tobytes(), w, h, QImage.Format.Format_RGB888) + pix = QPixmap.fromImage(qimg) + + painter = QPainter(pix) + pen = QPen(QColor(255, 0, 0), 4) + painter.setPen(pen) + painter.setBrush(QBrush(QColor(255, 0, 0, 70))) + + # --- HERE: OFFSET IN PERCENT --- + offset_x_percent = -25 # נגיד להזיז 20% שמאלה + offset_y_percent = 0 # אין הזזה ב-Y + + offset_x = w * (offset_x_percent / 100.0) + offset_y = h * (offset_y_percent / 100.0) + + # Build polygon + poly = QPolygonF() + for p in pixel_points: + try: + x = p["x"] + offset_x + y = p["y"] + offset_y + poly.append(QPointF(x, y)) + except Exception as e: + print("[Polygon] Bad point:", p, "error:", e) + + painter.drawPolygon(poly) + painter.end() + + self.image_label.setPixmap(pix) + + + def focus_on_image_by_key(self, img_key): + print("[AerialImagesView] Looking for img_key:", img_key) + + if not self.images: + print("[AerialImagesView] No images loaded!") + return + + for idx, img in enumerate(self.images): + print(f"[AerialImagesView] Checking index {idx} → {img.get('img_key')}") + + if img.get("img_key") == img_key: + print("[AerialImagesView] FOUND matching image at index:", idx) + + selected = self.images.pop(idx) + self.images.insert(0, selected) + + self.current_index = 0 + print("[AerialImagesView] Reordered images → displaying new index 0") + + self.show_image(0) + print("[AerialImagesView] show_image(0) completed") + + self.update_date_selector() + print("[AerialImagesView] Date selector updated") + return + + print("[AerialImagesView] WARNING: img_key not found in images!") + + diff --git a/GUI/src/vast/views/graphs_aerial_view.py b/GUI/src/vast/views/graphs_aerial_view.py new file mode 100644 index 000000000..0666f94ec --- /dev/null +++ b/GUI/src/vast/views/graphs_aerial_view.py @@ -0,0 +1,1114 @@ +from __future__ import annotations + +import datetime as dt +import json +from collections import Counter, defaultdict +from typing import Any, Dict, List, Optional + +import matplotlib.dates as mdates +import matplotlib.pyplot as plt +from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure +import numpy as np + +from PyQt6.QtCore import QUrl, Qt +from PyQt6.QtWebSockets import QWebSocket +from PyQt6.QtWidgets import ( + QComboBox, + QFrame, + QHBoxLayout, + QLabel, + QTabWidget, + QVBoxLayout, + QWidget, +) + + +class AerialGraphsView(QWidget): + """ + Aerial imagery dashboard with clean gallery-style design. + + Tabs: + - Metadata -> aerial_images_metadata + - Detections -> aerial_image_object_detections + - Connections -> image_new_aerial_connections + """ + + def __init__(self, api, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self.api = api + + self.setStyleSheet(""" + QWidget { + background-color: #F8F9FA; + color: #2d2d2d; + font-family: 'Segoe UI', 'Heebo', Arial, sans-serif; + } + """) + + # Cached metadata rows (from API - assumed paged client side) + self.metadata_rows: List[Dict[str, Any]] = [] + self._reload_metadata() + + # ===== Root layout ===== + root = QVBoxLayout(self) + root.setContentsMargins(30, 20, 30, 30) + root.setSpacing(0) + + header_layout = QHBoxLayout() + header_layout.setContentsMargins(10, 10, 10, 20) + + title = QLabel("📊 Aerial Imagery Dashboard") + title.setStyleSheet(""" + QLabel { + font-size: 28px; + font-weight: 700; + color: #00897B; + background: transparent; + letter-spacing: 0.5px; + } + """) + header_layout.addWidget(title) + header_layout.addStretch() + + root.addLayout(header_layout) + + separator = QFrame() + separator.setFrameShape(QFrame.Shape.HLine) + separator.setFrameShadow(QFrame.Shadow.Sunken) + separator.setFixedHeight(1) + separator.setStyleSheet("QFrame { background-color: #E0E0E0; border: none; margin: 0; }") + root.addWidget(separator) + + root.addSpacing(20) + + content_widget = QWidget() + content_widget.setStyleSheet("background: transparent;") + content_layout = QVBoxLayout(content_widget) + content_layout.setContentsMargins(0, 0, 0, 0) + content_layout.setSpacing(20) + + # Global filters (GIS / Drone / Date – מקורם במטא־דאטה) + self._init_filters(content_layout) + + self.tabs = QTabWidget() + self.tabs.setDocumentMode(True) + self.tabs.setStyleSheet(""" + QTabWidget::pane { + border: none; + background: transparent; + top: 0px; + } + QTabBar::tab { + background: transparent; + color: #6b7280; + padding: 12px 24px; + margin-right: 8px; + border: none; + border-bottom: 3px solid transparent; + font-size: 16px; + font-weight: 600; + } + QTabBar::tab:selected { + background: transparent; + color: #00897B; + border-bottom: 3px solid #00897B; + } + QTabBar::tab:hover { + color: #00695C; + border-bottom: 3px solid #B2DFDB; + } + """) + content_layout.addWidget(self.tabs) + + # Detection filters + self.image_filter = QComboBox() + self.image_filter.addItem("All images", None) + + self.label_filter = QComboBox() + self.label_filter.addItem("All labels", None) + + self.label_filter.setMinimumWidth(160) + self.image_filter.setMinimumWidth(220) + + self.label_filter.currentIndexChanged.connect(self.refresh_current_tab) + self.image_filter.currentIndexChanged.connect(self.refresh_current_tab) + + # Anomalies filters + self.ano_image_filter = QComboBox() + self.ano_image_filter.addItem("All images", None) + self.ano_image_filter.setMinimumWidth(220) + + self.ano_label_filter = QComboBox() + self.ano_label_filter.addItem("All labels", None) + self.ano_label_filter.setMinimumWidth(160) + + self.ano_image_filter.currentIndexChanged.connect(self.refresh_current_tab) + self.ano_label_filter.currentIndexChanged.connect(self.refresh_current_tab) + + + # Tabs + self.metadata_tab = self._create_tab("aerial_images_metadata") + self.detections_tab = self._create_detections_tab() + self.anomalies_tab = self._create_anomalies_tab() + self.connections_tab = self._create_tab("image_new_aerial_connections") + + self.tabs.addTab(self.metadata_tab, "Metadata") + self.tabs.addTab(self.detections_tab, "Detections") + self.tabs.addTab(self.anomalies_tab, "Anomalies") + self.tabs.addTab(self.connections_tab, "Connections") + + # Reactions to global filters + self.tabs.currentChanged.connect(lambda _i: self.refresh_current_tab()) + self.gis_filter.currentIndexChanged.connect(self.refresh_current_tab) + self.drone_filter.currentIndexChanged.connect(self.refresh_current_tab) + self.day_filter.currentIndexChanged.connect(self.refresh_current_tab) + + root.addWidget(content_widget) + + # WebSocket (live updates to metadata) + self.ws = QWebSocket() + self.ws.textMessageReceived.connect(self._on_ws_message) + self.ws.open(QUrl("ws://db_api_service:8001/ws/aerial-updates")) + + self.refresh_current_tab() + + def _create_shadow(self): + """Create drop shadow effect for modal""" + from PyQt6.QtWidgets import QGraphicsDropShadowEffect + from PyQt6.QtGui import QColor + + shadow = QGraphicsDropShadowEffect() + shadow.setBlurRadius(40) + shadow.setColor(QColor(0, 0, 0, 60)) + shadow.setOffset(0, 8) + return shadow + + def _extract_filename_from_img_key(self, key: str) -> Optional[str]: + if not key: + return None + return key.replace("\\", "/").split("/")[-1] + + + # ================================================================ + # METADATA LOAD + FILTER UI + # ================================================================ + def _reload_metadata(self) -> None: + """Load metadata from BOTH tables, normalize fields, and unify structure.""" + rows = [] + + # --- TABLE 1: aerial_images_metadata --- + try: + meta1 = self.api.list_aerial_metadata() + except: + meta1 = [] + + for r in meta1: + rows.append({ + "file_name": r.get("file_name"), + "drone_id": r.get("drone_id"), + "capture_time": r.get("capture_time"), + "gis_origin": r.get("gis_origin"), + }) + + # --- TABLE 2: aerial_images_complete_metadata --- + try: + meta2 = self.api.list_aerial_complete_metadata() + except: + meta2 = [] + + for r in meta2: + rows.append({ + "file_name": r.get("file_name"), + "drone_id": r.get("device_id"), # unification + "capture_time": r.get("timestamp_utc"), # unification + "gis_origin": r.get("gis_origin"), + }) + + # Normalize metadata rows + for r in rows: + r["_gis_key"] = self._extract_gis_label(r.get("gis_origin")) + r["_drone_id"] = str(r.get("drone_id")) if r.get("drone_id") else None + r["_file_key"] = self._extract_filename_from_img_key(r.get("file_name")) + + self.metadata_rows = rows + + + + + def _init_filters(self, root: QVBoxLayout) -> None: + filter_card = QFrame() + filter_card.setStyleSheet(""" + QFrame { + background-color: #FFFFFF; + border: 1px solid #E0E0E0; + border-radius: 15px; + padding: 20px; + } + """) + + card_layout = QHBoxLayout(filter_card) + card_layout.setContentsMargins(18, 14, 18, 14) + card_layout.setSpacing(16) + + filter_title = QLabel("🔍 Filters:") + filter_title.setStyleSheet(""" + QLabel { + font-size: 14px; + font-weight: 700; + color: #2C3E50; + background: transparent; + } + """) + card_layout.addWidget(filter_title) + + combo_style = """ + QComboBox { + background: white; + border: 1px solid #CED4DA; + border-radius: 10px; + padding: 10px 14px; + font-size: 14px; + color: #2C3E50; + font-weight: 500; + } + QComboBox:hover { + border-color: #00897B; + background: #FAFAFA; + } + QComboBox:focus { + border-color: #00897B; + border-width: 2px; + outline: none; + } + QComboBox::drop-down { + border: none; + padding-right: 10px; + width: 28px; + } + QComboBox::down-arrow { + image: none; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 6px solid #9ca3af; + margin-right: 8px; + } + QComboBox:hover::down-arrow { + border-top-color: #00897B; + } + QComboBox QAbstractItemView { + background: white; + border: 1px solid #E0E0E0; + border-radius: 10px; + selection-background-color: #E0F2F1; + selection-color: #004D40; + padding: 6px; + outline: none; + } + QComboBox QAbstractItemView::item { + padding: 10px 14px; + border-radius: 8px; + margin: 2px 4px; + } + QComboBox QAbstractItemView::item:hover { + background-color: #F1F8F7; + } + """ + + self.gis_filter = QComboBox() + self.gis_filter.setMinimumWidth(220) + self.gis_filter.setStyleSheet(combo_style) + card_layout.addWidget(self.gis_filter) + + self.drone_filter = QComboBox() + self.drone_filter.setMinimumWidth(140) + self.drone_filter.setStyleSheet(combo_style) + card_layout.addWidget(self.drone_filter) + + self.day_filter = QComboBox() + self.day_filter.setMinimumWidth(140) + self.day_filter.setStyleSheet(combo_style) + card_layout.addWidget(self.day_filter) + + card_layout.addStretch() + + root.addWidget(filter_card) + + self._populate_metadata_filters() + + def _populate_metadata_filters(self) -> None: + self.gis_filter.blockSignals(True) + self.drone_filter.blockSignals(True) + self.day_filter.blockSignals(True) + + self.gis_filter.clear() + self.drone_filter.clear() + self.day_filter.clear() + + self.gis_filter.addItem("All GIS Locations", None) + self.drone_filter.addItem("All Drones", None) + self.day_filter.addItem("All Days", None) + + # GIS options + gis_vals = sorted({r["_gis_key"] for r in self.metadata_rows if r["_gis_key"]}) + for g in gis_vals: + self.gis_filter.addItem(g, g) + + # Drone options + drone_vals = sorted({r["_drone_id"] for r in self.metadata_rows if r["_drone_id"]}) + for d in drone_vals: + self.drone_filter.addItem(d, d) + + # Days + days = set() + for r in self.metadata_rows: + ts = self._parse_ts(r["capture_time"]) + if ts: + days.add(ts.date()) + + for d in sorted(days): + self.day_filter.addItem(d.strftime("%Y-%m-%d"), d) + + self.gis_filter.blockSignals(False) + self.drone_filter.blockSignals(False) + self.day_filter.blockSignals(False) + + + # ================================================================ + # TABS CREATION + # ================================================================ + def _create_tab(self, table_name: str) -> QFrame: + frame = QFrame() + frame.setStyleSheet("background: transparent; border: none;") + layout = QVBoxLayout(frame) + layout.setContentsMargins(0, 20, 0, 0) + layout.setSpacing(12) + + canvas = FigureCanvas(Figure(figsize=(14, 7))) + layout.addWidget(canvas) + + frame.canvas = canvas + frame.fig = canvas.figure + frame.table_name = table_name + return frame + + def _create_detections_tab(self) -> QFrame: + frame = QFrame() + frame.setStyleSheet("background: transparent; border: none;") + layout = QVBoxLayout(frame) + layout.setContentsMargins(0, 20, 0, 0) + layout.setSpacing(16) + + top = QHBoxLayout() + top.setSpacing(12) + + combo_style = """ + QComboBox { + background: white; + border: 1px solid #CED4DA; + border-radius: 10px; + padding: 9px 13px; + font-size: 13px; + color: #2C3E50; + font-weight: 500; + } + QComboBox:hover { + border-color: #00897B; + background: #FAFAFA; + } + QComboBox::drop-down { + border: none; + padding-right: 8px; + } + QComboBox::down-arrow { + image: none; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid #9ca3af; + } + QComboBox:hover::down-arrow { + border-top-color: #00897B; + } + QComboBox QAbstractItemView { + background: white; + border: 1px solid #E0E0E0; + border-radius: 9px; + selection-background-color: #E0F2F1; + selection-color: #004D40; + padding: 4px; + } + QComboBox QAbstractItemView::item { + padding: 8px 12px; + border-radius: 7px; + margin: 2px; + } + """ + + label_style = """ + QLabel { + font-size: 13px; + font-weight: 600; + color: #6b7280; + background: transparent; + } + """ + + img_label = QLabel("Image:") + img_label.setStyleSheet(label_style) + top.addWidget(img_label) + + self.image_filter.setStyleSheet(combo_style) + top.addWidget(self.image_filter) + top.addSpacing(16) + + lbl_label = QLabel("Label:") + lbl_label.setStyleSheet(label_style) + top.addWidget(lbl_label) + + self.label_filter.setStyleSheet(combo_style) + top.addWidget(self.label_filter) + top.addStretch() + layout.addLayout(top) + + canvas = FigureCanvas(Figure(figsize=(14, 7))) + layout.addWidget(canvas) + + frame.canvas = canvas + frame.fig = canvas.figure + frame.table_name = "aerial_image_object_detections" + return frame + + def _create_anomalies_tab(self) -> QFrame: + frame = QFrame() + frame.setStyleSheet("background: transparent; border: none;") + layout = QVBoxLayout(frame) + layout.setContentsMargins(0, 20, 0, 0) + layout.setSpacing(16) + + top = QHBoxLayout() + top.setSpacing(12) + + combo_style = """ + QComboBox { + background: white; + border: 1px solid #CED4DA; + border-radius: 10px; + padding: 9px 13px; + font-size: 13px; + color: #2C3E50; + font-weight: 500; + } + QComboBox:hover { + border-color: #00897B; + background: #FAFAFA; + } + QComboBox::drop-down { + border: none; + padding-right: 8px; + } + QComboBox::down-arrow { + image: none; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid #9ca3af; + } + QComboBox:hover::down-arrow { + border-top-color: #00897B; + } + QComboBox QAbstractItemView { + background: white; + border: 1px solid #E0E0E0; + border-radius: 9px; + selection-background-color: #E0F2F1; + selection-color: #004D40; + padding: 4px; + } + QComboBox QAbstractItemView::item { + padding: 8px 12px; + border-radius: 7px; + margin: 2px; + } + """ + + label_style = """ + QLabel { + font-size: 13px; + font-weight: 600; + color: #6b7280; + background: transparent; + } + """ + + img_label = QLabel("Image:") + img_label.setStyleSheet(label_style) + top.addWidget(img_label) + + self.ano_image_filter.setStyleSheet(combo_style) + top.addWidget(self.ano_image_filter) + top.addSpacing(16) + + lbl_label = QLabel("Label:") + lbl_label.setStyleSheet(label_style) + top.addWidget(lbl_label) + + self.ano_label_filter.setStyleSheet(combo_style) + top.addWidget(self.ano_label_filter) + top.addStretch() + + layout.addLayout(top) + + canvas = FigureCanvas(Figure(figsize=(14, 7))) + layout.addWidget(canvas) + + frame.canvas = canvas + frame.fig = canvas.figure + frame.table_name = "aerial_image_anomaly_detections" + return frame + + + # ================================================================ + # REFRESH / WS + # ================================================================ + def refresh_current_tab(self): + tab = self.tabs.currentWidget() + if not tab: + return + + # 💡 בדיקת בטיחות קריטית: ודאי ש-fig ו-canvas קיימים על הטאב. + # אם ה-fig אינו קיים (לדוגמה, אם הטאב לא אִתְחֵל כראוי) או שהוא None, הקריסה נמנעת. + if not hasattr(tab, 'fig') or tab.fig is None or not hasattr(tab, 'canvas') or tab.canvas is None: + # אם אין לנו figure או canvas, אנחנו לא יכולים לצייר. + # ייתכן שזו הבעיה בטאבים שנופלים לאחר זמן. + print(f"Warning: Figure or Canvas object is missing on tab: {tab.table_name}") + return + + # ניקוי Figure: מוחק את כל הצירים (Axes) מה-Figure הקיים. + tab.fig.clf() + + # ציור הגרף הנכון על ה-Figure המנוקה + if tab.table_name == "aerial_images_metadata": + self._plot_metadata(tab.fig) + elif tab.table_name == "aerial_image_object_detections": + self._plot_detections(tab.fig) + elif tab.table_name == "aerial_image_anomaly_detections": + self._plot_anomalies(tab.fig) + elif tab.table_name == "image_new_aerial_connections": + self._plot_connections(tab.fig) + + # רענון התצוגה של הקנבס הקיים + tab.canvas.draw_idle() + + + + + def _on_ws_message(self, msg): + try: + data = json.loads(msg) + except: + return + + t = data.get("type") + + if t == "new_image_metadata": + self._reload_metadata() + self._populate_metadata_filters() + self.refresh_current_tab() + return + + if t == "new_connection": + self._reload_metadata() + self.refresh_current_tab() + return + + + # ================================================================ + # HELPERS + # ================================================================ + @staticmethod + def _parse_ts(value: Any) -> Optional[dt.datetime]: + if value is None: + return None + if isinstance(value, dt.datetime): + return value + s = str(value).strip() + try: + # handle "2025-11-13 16:40:30+02" + if "+" in s and " " in s: + parts = s.split(" ") + s = parts[0] + "T" + " ".join(parts[1:]) + return dt.datetime.fromisoformat(s) + except Exception: + return None + + @staticmethod + def _extract_gis_label(gis_origin: Any) -> Optional[str]: + if gis_origin is None: + return None + try: + data = gis_origin if isinstance(gis_origin, dict) else json.loads(gis_origin) + lat = float(data.get("latitude")) + lon = float(data.get("longitude")) + except Exception: + return None + return f"({lat:.4f}, {lon:.4f})" + + def _current_day_filter(self) -> Optional[dt.date]: + return self.day_filter.currentData() + + # ================================================================ + # METADATA PLOT – cumulative & pretty + # ================================================================ + def _plot_metadata(self, fig: Figure) -> None: + gis_val = self.gis_filter.currentData() + drone_val = self.drone_filter.currentData() + day_val = self._current_day_filter() + + rows = [] + + for r in self.metadata_rows: + if gis_val and r["_gis_key"] != gis_val: + continue + if drone_val and r["_drone_id"] != drone_val: + continue + + ts = self._parse_ts(r["capture_time"]) + if not ts: + continue + + if day_val and ts.date() != day_val: + continue + + r["_ts"] = ts + rows.append(r) + + ax = fig.add_subplot(111) + + if not rows: + ax.text(0.5, 0.5, "No metadata for selected filters", + ha="center", va="center", fontsize=15, color="#9ca3af", style="italic") + ax.axis("off") + return + + rows.sort(key=lambda x: x["_ts"]) + + xs = [r["_ts"] for r in rows] + ys = list(range(1, len(xs) + 1)) + + ax.plot(xs, ys, marker="o", markersize=9, linewidth=4, color="#00897B", + markerfacecolor="#00897B", markeredgecolor="white", markeredgewidth=2.5, alpha=0.95) + ax.fill_between(xs, ys, alpha=0.15, color="#00897B") + + locator = mdates.AutoDateLocator() + formatter = mdates.AutoDateFormatter(locator) + ax.xaxis.set_major_locator(locator) + ax.xaxis.set_major_formatter(formatter) + plt.setp(ax.get_xticklabels(), rotation=45, ha="right", fontsize=12, color="#4b5563") + plt.setp(ax.get_yticklabels(), fontsize=12, color="#4b5563") + + ax.set_title("Images Over Time (Cumulative)", fontsize=16, fontweight="bold", + color="#2C3E50", pad=12, family="sans-serif") + ax.set_xlabel("Time", fontsize=14, color="#6b7280", fontweight="600") + ax.set_ylabel("Count", fontsize=14, color="#6b7280", fontweight="600") + ax.grid(alpha=0.15, linestyle="--", linewidth=1, color="#d1d5db") + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["left"].set_color("#e5e7eb") + ax.spines["bottom"].set_color("#e5e7eb") + ax.set_facecolor("white") + fig.patch.set_facecolor("#F8F9FA") + fig.subplots_adjust(left=0.10, right=0.96, top=0.94, bottom=0.12) + + + # ================================================================ + # DETECTIONS PLOT – pie OR histogram with image/label filters + # ================================================================ + def _populate_image_filter(self, rows: List[Dict[str, Any]]) -> None: + prev = self.image_filter.currentData() + keys = sorted({str(r.get("img_key")) for r in rows if r.get("img_key")}) + + self.image_filter.blockSignals(True) + self.image_filter.clear() + self.image_filter.addItem("All images", None) + for k in keys: + self.image_filter.addItem(k, k) + if prev in keys: + idx = self.image_filter.findData(prev) + if idx >= 0: + self.image_filter.setCurrentIndex(idx) + self.image_filter.blockSignals(False) + + def _update_label_filter(self, labels: List[str]) -> None: + prev = self.label_filter.currentData() + + self.label_filter.blockSignals(True) + self.label_filter.clear() + self.label_filter.addItem("All labels", None) + for l in sorted(labels): + self.label_filter.addItem(l, l) + if prev in labels: + idx = self.label_filter.findData(prev) + if idx >= 0: + self.label_filter.setCurrentIndex(idx) + self.label_filter.blockSignals(False) + + def _plot_detections(self, fig: Figure) -> None: + try: + all_rows = self.api.list_object_detections() + except Exception: + all_rows = [] + + # Normalize img_key → filename + for r in all_rows: + r["_file_key"] = self._extract_filename_from_img_key(r.get("img_key")) + + # ---- GLOBAL FILTERS ---- + day_val = self._current_day_filter() + drone_val = self.drone_filter.currentData() + gis_val = self.gis_filter.currentData() + + rows = [] + for r in all_rows: + + # מציאת מטא־דאטה מתאים לפי שם קובץ + meta_match = next( + (m for m in self.metadata_rows if m["_file_key"] == r["_file_key"]), + None + ) + if not meta_match: + continue + + # פילטר Drone + if drone_val and meta_match["_drone_id"] != drone_val: + continue + + # פילטר GIS + if gis_val and meta_match["_gis_key"] != gis_val: + continue + + # ✔ שימוש בתאריך צילום אמיתי ולא detected_at + ts = self._parse_ts(meta_match["capture_time"]) + if not ts: + continue + if day_val and ts.date() != day_val: + continue + + rows.append(r) + + ax = fig.add_subplot(111) + + if not rows: + ax.text(0.5, 0.5, "No detections for selected filters", + ha="center", va="center", fontsize=15, color="#9ca3af", style="italic") + ax.axis("off") + self._populate_image_filter([]) + self._update_label_filter([]) + return + + # Image filter + self._populate_image_filter(rows) + selected_image = self.image_filter.currentData() + if selected_image: + rows = [r for r in rows if r["_file_key"] == selected_image] + + # Label filter + labels_all = [r["label"] for r in rows] + self._update_label_filter(sorted(set(labels_all))) + + selected_label = self.label_filter.currentData() + if selected_label: + rows = [r for r in rows if r["label"] == selected_label] + + if not rows: + ax.text(0.5, 0.5, "No detections for selection", + ha="center", va="center", fontsize=15, color="#9ca3af", style="italic") + ax.axis("off") + return + + if selected_label is None: + counts = Counter([r["label"] for r in rows]) + names = list(counts.keys()) + sizes = list(counts.values()) + + colors = ["#00897B", "#00796B", "#00695C", "#26A69A", "#4DB6AC", + "#80CBC4", "#009688", "#00766C", "#00897B", "#00695C"] + + wedges, texts, autotexts = ax.pie(sizes, labels=names, autopct="%1.1f%%", + startangle=90, colors=colors[:len(names)], + textprops={"fontsize": 13, "weight": "600", "color": "#2C3E50"}, + wedgeprops={"edgecolor": "white", "linewidth": 3.5}) + + for autotext in autotexts: + autotext.set_color("white") + autotext.set_fontsize(12) + autotext.set_weight("bold") + + ax.set_title("Labels Distribution", fontsize=16, fontweight="bold", + color="#2C3E50", pad=12, family="sans-serif") + fig.patch.set_facecolor("#F8F9FA") + fig.subplots_adjust(left=0.08, right=0.92, top=0.94, bottom=0.08) + + else: + confs = [float(r["confidence"]) for r in rows] + n, bins, patches = ax.hist(confs, bins=10, edgecolor="white", color="#00897B", + alpha=0.90, linewidth=2.5) + + # Gradient effect on bars with green theme + cm = plt.cm.get_cmap("Greens") + bin_centers = 0.5 * (bins[:-1] + bins[1:]) + col = bin_centers - min(bin_centers) + col /= max(col) + for c, p in zip(col, patches): + plt.setp(p, "facecolor", cm(c * 0.6 + 0.4)) + + ax.set_title(f"Confidence for '{selected_label}'", + fontsize=16, fontweight="bold", color="#2C3E50", pad=12) + ax.set_xlabel("Confidence", fontsize=14, color="#6b7280", fontweight="600") + ax.set_ylabel("Frequency", fontsize=14, color="#6b7280", fontweight="600") + ax.grid(alpha=0.15, linestyle="--", linewidth=1, axis="y", color="#d1d5db") + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["left"].set_color("#e5e7eb") + ax.spines["bottom"].set_color("#e5e7eb") + ax.set_facecolor("white") + plt.setp(ax.get_xticklabels(), fontsize=12, color="#4b5563") + plt.setp(ax.get_yticklabels(), fontsize=12, color="#4b5563") + fig.patch.set_facecolor("#F8F9FA") + fig.subplots_adjust(left=0.10, right=0.96, top=0.94, bottom=0.12) + + + # ================================================================ + # CONNECTIONS PLOT – bucketed over time + # ================================================================ + def _plot_connections(self, fig: Figure) -> None: + try: + all_rows = self.api.list_aerial_connections() + except Exception: + all_rows = [] + + day_val = self._current_day_filter() + + filtered: List[Dict[str, Any]] = [] + for r in all_rows: + ts = self._parse_ts(r.get("linked_time")) + if not ts: + continue + if day_val and ts.date() != day_val: + continue + r["_ts"] = ts + filtered.append(r) + + ax = fig.add_subplot(111) + + if not filtered: + ax.text(0.5, 0.5, "No connections for selected filters", + ha="center", va="center", fontsize=15, color="#9ca3af", style="italic") + ax.axis("off") + return + + bucket: Dict[dt.datetime, int] = defaultdict(int) + for r in filtered: + ts = r["_ts"] + if day_val: + # bucket by hour when a single day is selected + ts_key = ts.replace(minute=0, second=0, microsecond=0) + else: + # bucket by day otherwise + ts_key = dt.datetime(ts.year, ts.month, ts.day) + bucket[ts_key] += 1 + + xs = sorted(bucket.keys()) + ys = [bucket[t] for t in xs] + + ax.plot(xs, ys, marker="o", markersize=10, linewidth=4, color="#00897B", + markerfacecolor="#00897B", markeredgecolor="white", markeredgewidth=2.5, alpha=0.95) + ax.fill_between(xs, ys, alpha=0.15, color="#00897B") + + locator = mdates.AutoDateLocator() + formatter = mdates.AutoDateFormatter(locator) + ax.xaxis.set_major_locator(locator) + ax.xaxis.set_major_formatter(formatter) + plt.setp(ax.get_xticklabels(), rotation=45, ha="right", fontsize=12, color="#4b5563") + plt.setp(ax.get_yticklabels(), fontsize=12, color="#4b5563") + + ax.set_title("Connections Over Time", fontsize=16, fontweight="bold", + color="#2C3E50", pad=12, family="sans-serif") + ax.set_xlabel("Time", fontsize=14, color="#6b7280", fontweight="600") + ax.set_ylabel("Count", fontsize=14, color="#6b7280", fontweight="600") + ax.grid(alpha=0.15, linestyle="--", linewidth=1, color="#d1d5db") + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["left"].set_color("#e5e7eb") + ax.spines["bottom"].set_color("#e5e7eb") + ax.set_facecolor("white") + fig.patch.set_facecolor("#F8F9FA") + fig.subplots_adjust(left=0.10, right=0.96, top=0.94, bottom=0.12) + + + def _populate_ano_image_filter(self, rows): + # def _populate_ano_image_filter(self, rows: List[Dict[str, Any]]) -> None: + + if not self.ano_image_filter: + print("Warning: ano_image_filter was unexpectedly deleted. Skipping population.") + return + + try: + prev = self.ano_image_filter.currentData() + except RuntimeError: + print("Warning: ano_image_filter was deleted after initial check. Skipping population.") + return + + self.ano_image_filter.clear() + + + prev = self.ano_image_filter.currentData() + keys = sorted({str(r.get("img_key")) for r in rows if r.get("img_key")}) + + self.ano_image_filter.blockSignals(True) + self.ano_image_filter.clear() + self.ano_image_filter.addItem("All images", None) + for k in keys: + self.ano_image_filter.addItem(k, k) + if prev in keys: + idx = self.ano_image_filter.findData(prev) + if idx >= 0: + self.ano_image_filter.setCurrentIndex(idx) + self.ano_image_filter.blockSignals(False) + + def _update_ano_label_filter(self, labels): + prev = self.ano_label_filter.currentData() + + self.ano_label_filter.blockSignals(True) + self.ano_label_filter.clear() + self.ano_label_filter.addItem("All labels", None) + for l in sorted(labels): + self.ano_label_filter.addItem(l, l) + if prev in labels: + idx = self.ano_label_filter.findData(prev) + if idx >= 0: + self.ano_label_filter.setCurrentIndex(idx) + self.ano_label_filter.blockSignals(False) + + + def _plot_anomalies(self, fig: Figure) -> None: + try: + all_rows = self.api.list_anomaly_detections() + except Exception: + all_rows = [] + + # Normalize img_key → filename + for r in all_rows: + r["_file_key"] = self._extract_filename_from_img_key(r.get("img_key")) + + # ---- GLOBAL FILTERS ---- + day_val = self._current_day_filter() + drone_val = self.drone_filter.currentData() + gis_val = self.gis_filter.currentData() + + rows = [] + for r in all_rows: + + # מציאת רשומת מטא־דאטה + meta_match = next( + (m for m in self.metadata_rows if m["_file_key"] == r["_file_key"]), + None + ) + if not meta_match: + continue + + # פילטר Drone + if drone_val and meta_match["_drone_id"] != drone_val: + continue + + # פילטר GIS + if gis_val and meta_match["_gis_key"] != gis_val: + continue + + # ✔ שימוש בתאריך צילום אמיתי ולא detected_at + ts = self._parse_ts(meta_match["capture_time"]) + if not ts: + continue + if day_val and ts.date() != day_val: + continue + + rows.append(r) + + ax = fig.add_subplot(111) + + if not rows: + ax.text(0.5, 0.5, "No anomalies for selected filters", + ha="center", va="center", fontsize=15, color="#9ca3af", style="italic") + ax.axis("off") + self._populate_ano_image_filter([]) + self._update_ano_label_filter([]) + return + + # Image filter + self._populate_ano_image_filter(rows) + selected_image = self.ano_image_filter.currentData() + if selected_image: + rows = [r for r in rows if r["_file_key"] == selected_image] + + # Label filter + labels_all = [r["label"] for r in rows] + self._update_ano_label_filter(sorted(set(labels_all))) + + selected_label = self.ano_label_filter.currentData() + if selected_label: + rows = [r for r in rows if r["label"] == selected_label] + + if not rows: + ax.text(0.5, 0.5, "No anomalies for selection", + ha="center", va="center", fontsize=15, color="#9ca3af", style="italic") + ax.axis("off") + return + + if selected_label is None: + counts = Counter([r["label"] for r in rows]) + names = list(counts.keys()) + sizes = list(counts.values()) + + colors = ["#00897B", "#00796B", "#00695C", "#26A69A", "#4DB6AC", + "#80CBC4", "#009688", "#00766C", "#00897B", "#00695C"] + + wedges, texts, autotexts = ax.pie(sizes, labels=names, autopct="%1.1f%%", + startangle=90, colors=colors[:len(names)], + textprops={"fontsize": 13, "weight": "600", "color": "#2C3E50"}, + wedgeprops={"edgecolor": "white", "linewidth": 3.5}) + + for autotext in autotexts: + autotext.set_color("white") + autotext.set_fontsize(12) + autotext.set_weight("bold") + + ax.set_title("Anomalies Distribution", fontsize=16, fontweight="bold", + color="#2C3E50", pad=12, family="sans-serif") + fig.patch.set_facecolor("#F8F9FA") + fig.subplots_adjust(left=0.08, right=0.92, top=0.94, bottom=0.08) + + else: + confs = [float(r["confidence"]) for r in rows] + n, bins, patches = ax.hist(confs, bins=10, edgecolor="white", color="#00897B", + alpha=0.90, linewidth=2.5) + + # Gradient effect with green theme + cm = plt.cm.get_cmap("Greens") + bin_centers = 0.5 * (bins[:-1] + bins[1:]) + col = bin_centers - min(bin_centers) + col /= max(col) + for c, p in zip(col, patches): + plt.setp(p, "facecolor", cm(c * 0.6 + 0.4)) + + ax.set_title(f"Confidence for '{selected_label}'", + fontsize=16, fontweight="bold", color="#2C3E50", pad=12) + ax.set_xlabel("Confidence", fontsize=14, color="#6b7280", fontweight="600") + ax.set_ylabel("Frequency", fontsize=14, color="#6b7280", fontweight="600") + ax.grid(alpha=0.15, linestyle="--", linewidth=1, axis="y", color="#d1d5db") + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["left"].set_color("#e5e7eb") + ax.spines["bottom"].set_color("#e5e7eb") + ax.set_facecolor("white") + plt.setp(ax.get_xticklabels(), fontsize=12, color="#4b5563") + plt.setp(ax.get_yticklabels(), fontsize=12, color="#4b5563") + fig.patch.set_facecolor("#F8F9FA") + fig.subplots_adjust(left=0.10, right=0.96, top=0.94, bottom=0.12) diff --git a/GUI/src/vast/views/ground_view.py b/GUI/src/vast/views/ground_view.py index 09db68187..f5868aad2 100644 --- a/GUI/src/vast/views/ground_view.py +++ b/GUI/src/vast/views/ground_view.py @@ -583,7 +583,7 @@ def refresh_phi_current(self) -> None: self._refresh_phi_for_key(key) else: self._render_phi_none() - self._warn("No image selected yet. Click 'Reload list' or 'Next'.") + # self._warn("No image selected yet. Click 'Reload list' or 'Next'.") except Exception as e: self._render_phi_none() self._warn(f"Show PHI failed: {e}") diff --git a/GUI/src/vast/views/sensors_status_summary.py b/GUI/src/vast/views/sensors_status_summary.py index 79d20a46f..bea034aa6 100644 --- a/GUI/src/vast/views/sensors_status_summary.py +++ b/GUI/src/vast/views/sensors_status_summary.py @@ -1,521 +1,424 @@ +from math import ceil +from datetime import datetime, timezone from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame, QTableWidget, - QTableWidgetItem, QHeaderView, QPushButton + QTableWidgetItem, QHeaderView ) from PyQt6.QtCore import Qt, QTimer -from PyQt6.QtGui import QColor -from datetime import datetime, timedelta +from PyQt6.QtGui import QFont, QColor, QPainter, QPen +# ============================================================ +# 🔵 RING INDICATOR +# ============================================================ +class RingIndicator(QWidget): + def __init__(self, label, value, color="#10B981", max_value=100, size=200): + super().__init__() + self.label = label + self.value = value + self.color = color + self.max_value = max_value + self.size = size + self.setFixedSize(size, size) + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = self.rect() + + radius = int(min(rect.width(), rect.height()) / 2 - 15) + center = rect.center() + + # רקע טבעת אפור בהיר + bg_pen = QPen(QColor("#E5E7EB"), 16) + painter.setPen(bg_pen) + painter.drawEllipse(center, radius, radius) + + # טבעת צבעונית + pen = QPen(QColor(self.color), 16) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + angle_span = int(360 * (self.value / self.max_value)) + painter.drawArc(rect.adjusted(15, 15, -15, -15), 90 * 16, -angle_span * 16) + + # טקסט במרכז + painter.setPen(QColor("#1E293B")) + font = QFont("Segoe UI", 30, QFont.Weight.Bold) + painter.setFont(font) + painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, f"{ceil(self.value)}%") + painter.end() + + +# ============================================================ +# 🌿 MAIN DASHBOARD +# ============================================================ class SensorsStatusSummary(QWidget): def __init__(self, api, parent=None): super().__init__(parent) self.api = api - - # Cache for performance optimization - self._last_sensors_fetch = None - self._sensors_cache = [] - self._last_events_check = None self._events_cache = [] - self._last_event_id = 0 # Track last processed event ID - - # Cache duration (5 minutes for sensors, 1 minute for events) - self._sensors_cache_duration = timedelta(minutes=5) - self._events_cache_duration = timedelta(minutes=1) - + self._last_event_id = 0 + self._first_load = True self._build_ui() - self.load_data() - - # Auto-refresh timer for events only (every 30 seconds) - self._refresh_timer = QTimer() - self._refresh_timer.timeout.connect(self._refresh_events_only) - self._refresh_timer.start(30000) # 30 seconds + self.refresh_data() + # Auto-refresh every 30 seconds + self.timer = QTimer(self) + self.timer.timeout.connect(self.refresh_data) + self.timer.start(30000) + + # ============================================================ + # 🧱 BUILD UI + # ============================================================ def _build_ui(self): main_layout = QVBoxLayout(self) - main_layout.setContentsMargins(30, 30, 30, 30) - main_layout.setSpacing(25) + main_layout.setContentsMargins(40, 40, 40, 40) + main_layout.setSpacing(30) - # -------- MODERN HEADER -------- - header_layout = QVBoxLayout() + # HEADER + header_row = QHBoxLayout() + header_row.setSpacing(16) - title = QLabel("🌾 Sensors Status Dashboard") + # Title with subtle styling + title = QLabel("Sensors Health Overview") + title.setFont(QFont("Segoe UI", 24, QFont.Weight.Bold)) title.setStyleSheet(""" QLabel { - font-family: 'Segoe UI', 'Roboto', 'Inter', sans-serif; - font-size: 32px; - font-weight: 800; - color: #1a1a1a; - margin-bottom: 8px; + color: #0F172A; letter-spacing: -0.5px; } """) - - subtitle = QLabel("Real-time monitoring of agricultural sensors") - subtitle.setStyleSheet(""" + + # Status badge with last update time + self.status_badge = QLabel("Live • Updated just now") + self.status_badge.setFont(QFont("Segoe UI", 11, QFont.Weight.Medium)) + self.status_badge.setStyleSheet(""" QLabel { - font-family: 'Segoe UI', 'Roboto', 'Inter', sans-serif; - font-size: 16px; - font-weight: 400; - color: #6B7280; - margin-bottom: 15px; + color: #047857; + background-color: #D1FAE5; + border: 1px solid #A7F3D0; + border-radius: 12px; + padding: 8px 16px; } """) - - header_layout.addWidget(title, alignment=Qt.AlignmentFlag.AlignLeft) - header_layout.addWidget(subtitle, alignment=Qt.AlignmentFlag.AlignLeft) - main_layout.addLayout(header_layout) - # -------- MODERN STATUS CARDS -------- + header_row.addWidget(title) + header_row.addStretch() + header_row.addWidget(self.status_badge) + main_layout.addLayout(header_row) + + # CARDS ROW cards_row = QHBoxLayout() cards_row.setSpacing(20) - self.active_card = self._create_status_card("Active Sensors", "●", "#10B981", "#F0FDF4") - self.inactive_card = self._create_status_card("Inactive Sensors", "●", "#EF4444", "#FEF2F2") - cards_row.addWidget(self.active_card) - cards_row.addWidget(self.inactive_card) + self.active_card = self._create_status_card("Active Sensors", "0", "#22C55E") + self.inactive_card = self._create_status_card("Inactive (No KeepAlive)", "0", "#F97316") + self.outofrange_card = self._create_status_card("Out of Range", "0", "#EF4444") + self.corrupted_card = self._create_status_card("Corrupted", "0", "#8B5CF6") + + for c in [self.active_card, self.inactive_card, self.outofrange_card, self.corrupted_card]: + cards_row.addWidget(c, 1) # Equal stretch factor for all cards main_layout.addLayout(cards_row) - # -------- MODERN TABLE -------- - self.table = QTableWidget(0, 5) - self.table.setHorizontalHeaderLabels(["ID", "Sensor Type", "Plant", "Plant ID", "Status"]) - self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + # LOWER SECTION + lower_row = QHBoxLayout() + lower_row.setSpacing(24) + + # LEFT: Ring + left_frame = QFrame() + left_frame.setStyleSheet(""" + QFrame { + background-color: white; + border-radius: 16px; + padding: 4px; + } + """) + left_layout = QVBoxLayout(left_frame) + left_layout.setContentsMargins(30, 30, 30, 30) + left_layout.setSpacing(10) + + ring_title = QLabel("System Health") + ring_title.setFont(QFont("Segoe UI", 16)) + ring_title.setStyleSheet("color: #1E293B;") + left_layout.addWidget(ring_title) + + self.health_ring = RingIndicator("Health", 0, "#10B981", 100, 220) + left_layout.addWidget(self.health_ring, alignment=Qt.AlignmentFlag.AlignCenter) + + self.ring_bottom = QLabel("Loading...") + self.ring_bottom.setFont(QFont("Segoe UI", 11)) + self.ring_bottom.setStyleSheet("color: #94A3B8; margin-top: 6px;") + self.ring_bottom.setAlignment(Qt.AlignmentFlag.AlignCenter) + left_layout.addWidget(self.ring_bottom) + lower_row.addWidget(left_frame, 1) + + # RIGHT: Table + right_frame = QFrame() + right_frame.setStyleSheet(""" + QFrame { + background-color: white; + border-radius: 16px; + padding: 4px; + } + """) + right_layout = QVBoxLayout(right_frame) + right_layout.setContentsMargins(30, 30, 30, 30) + + table_title = QLabel("Inactive Sensors (Missing KeepAlive)") + table_title.setFont(QFont("Segoe UI", 16)) + table_title.setStyleSheet("color: #1E293B; margin-bottom: 10px;") + right_layout.addWidget(table_title) + + self.table = QTableWidget(0, 3) + self.table.setHorizontalHeaderLabels(["ID", "Sensor Type", "Last Seen"]) + + # Set column widths + header = self.table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) # ID column + header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) # Sensor Type + header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) # Last Seen + self.table.setColumnWidth(0, 60) + self.table.verticalHeader().setVisible(False) - self.table.setAlternatingRowColors(True) self.table.setStyleSheet(""" QTableWidget { - background-color: #ffffff; - alternate-background-color: #F9FAFB; - font-family: 'Segoe UI', 'Roboto', 'Inter', sans-serif; - font-size: 14px; - border: 2px solid #E5E7EB; + background-color: white; + font-family: 'Segoe UI'; + font-size: 13px; + border: none; border-radius: 12px; - gridline-color: #F3F4F6; - selection-background-color: #EEF2FF; + gridline-color: #E5E7EB; } QHeaderView::section { - background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, - stop: 0 #F8FAFC, stop: 1 #F1F5F9); - font-family: 'Segoe UI', 'Roboto', 'Inter', sans-serif; - font-weight: 700; - font-size: 15px; - color: #1F2937; - border: none; - border-bottom: 2px solid #E5E7EB; - padding: 12px 8px; - text-align: left; - } - QTableWidget::item { - padding: 12px 8px; - border-bottom: 1px solid #F3F4F6; - } - QTableWidget::item:selected { - background-color: #EEF2FF; - color: #1E40AF; - } - QTableWidget::item:hover { background-color: #F8FAFC; - } - """) - main_layout.addWidget(self.table) - - # -------- MODERN REFRESH BUTTON -------- - button_layout = QHBoxLayout() - - refresh_btn = QPushButton("↻ Refresh Data") - refresh_btn.setFixedWidth(150) - refresh_btn.setFixedHeight(45) - refresh_btn.clicked.connect(self.refresh_all) - refresh_btn.setStyleSheet(""" - QPushButton { - background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, - stop: 0 #3B82F6, stop: 1 #1D4ED8); - color: white; - border-radius: 12px; - font-family: 'Segoe UI', 'Roboto', 'Inter', sans-serif; - font-size: 15px; + color: #64748B; font-weight: 600; - padding: 0px 16px; + padding: 10px; border: none; - letter-spacing: 0.3px; + border-top-left-radius: 12px; + border-top-right-radius: 12px; } - QPushButton:hover { - background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, - stop: 0 #2563EB, stop: 1 #1E40AF); - } - QPushButton:pressed { - background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, - stop: 0 #1D4ED8, stop: 1 #1E3A8A); + QTableWidget::item { + padding: 10px; } """) - - button_layout.addStretch() - button_layout.addWidget(refresh_btn) - main_layout.addLayout(button_layout) + right_layout.addWidget(self.table) + lower_row.addWidget(right_frame, 2) + main_layout.addLayout(lower_row) + self.setStyleSheet("background-color: #F8FAFC;") self.setLayout(main_layout) - self.setStyleSheet(""" - QWidget { - background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, - stop: 0 #F8FAFC, stop: 1 #F1F5F9); - font-family: 'Segoe UI', 'Roboto', 'Inter', sans-serif; - } - """) - # -------- MODERN CARD CREATOR -------- - def _create_status_card(self, title_text, icon, accent_color, bg_color): + # ============================================================ + # CREATE CARD + # ============================================================ + def _create_status_card(self, title, value, color): frame = QFrame() - frame.setFixedHeight(120) - frame.setStyleSheet(f""" - QFrame {{ - background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, - stop: 0 {bg_color}, stop: 1 #ffffff); + frame.setStyleSheet(""" + QFrame { + background-color: white; border-radius: 16px; - border: none; - padding: 0px; - }} + padding: 4px; + } """) + layout = QVBoxLayout(frame) + layout.setContentsMargins(20, 20, 20, 16) + layout.setSpacing(6) - layout = QHBoxLayout(frame) - layout.setContentsMargins(24, 20, 24, 20) - layout.setSpacing(18) - - # Icon section - icon_label = QLabel(icon) - icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - icon_label.setFixedSize(50, 50) - icon_label.setStyleSheet(f""" + # Colored circle at the top + circle = QLabel() + circle.setFixedSize(48, 48) + circle.setStyleSheet(f""" QLabel {{ - color: {accent_color}; - font-size: 36px; - font-weight: 900; - background: transparent; - border-radius: 25px; - font-family: 'Segoe UI Symbol', 'Arial'; + background-color: {color}; + border-radius: 24px; }} """) - layout.addWidget(icon_label) + layout.addWidget(circle, alignment=Qt.AlignmentFlag.AlignCenter) - # Text section - text_layout = QVBoxLayout() - text_layout.setSpacing(5) - - title = QLabel(title_text) - title.setStyleSheet(f""" - QLabel {{ - font-family: 'Segoe UI', 'Roboto', 'Inter', sans-serif; - font-size: 16px; - font-weight: 600; - color: #374151; - letter-spacing: 0.2px; - }} - """) - - count = QLabel("0") - count.setObjectName(title_text.lower().replace(" ", "_")) - count.setStyleSheet(f""" - QLabel {{ - font-family: 'Segoe UI', 'Roboto', 'Inter', sans-serif; - font-size: 32px; - font-weight: 800; - color: {accent_color}; - letter-spacing: -1px; - }} - """) - - text_layout.addWidget(title) - text_layout.addWidget(count) - layout.addLayout(text_layout) + # Value + val_label = QLabel(value) + val_label.setFont(QFont("Segoe UI", 28, QFont.Weight.Bold)) + val_label.setStyleSheet("color: #1E293B;") + val_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(val_label) + frame.value_label = val_label + # Title + name = QLabel(title) + name.setFont(QFont("Segoe UI", 11)) + name.setStyleSheet("color: #64748B;") + name.setAlignment(Qt.AlignmentFlag.AlignCenter) + name.setWordWrap(True) + layout.addWidget(name) + return frame - # -------- LOAD DATA (OPTIMIZED) -------- - def load_data(self, force_sensors_refresh=False): - """Load sensors and events data with caching optimization.""" + # ============================================================ + # 🔄 REFRESH DATA + # ============================================================ + def refresh_data(self): try: - # Load sensors (with caching) - sensors = self._get_sensors_cached(force_sensors_refresh) - - # Load recent keepalive events only (last 2 hours for performance) - events = self._get_recent_keepalive_events() - - except Exception as e: - print("[SensorsStatusSummary] Error loading data:", e) - return - - # identify inactive sensors by looking at the LATEST record for each device - inactive_ids = set() - - # Group events by device_id and issue_type - device_latest = {} - - for e in events: - if (e.get("issue_type") in ["missing_keepalive", "prolonged_silence"] - and str(e.get("device_id", "")).isdigit()): - device_id = str(e["device_id"]) - issue_type = e.get("issue_type") - key = f"{device_id}_{issue_type}" - - # Keep only the latest record for each device+issue_type combination - if key not in device_latest: - device_latest[key] = e - else: - # Compare by ID (higher ID = more recent) since start_ts can be identical - current_id = device_latest[key].get("id", 0) - new_id = e.get("id", 0) - if new_id > current_id: - device_latest[key] = e - - # Now check which devices have open issues based on latest records - for key, latest_event in device_latest.items(): - if latest_event.get("end_ts") is None: # Latest record is still open - device_id = str(latest_event["device_id"]) - inactive_ids.add(device_id) - - print(f"[SensorsStatusSummary] Found {len(inactive_ids)} sensors with active keepalive issues (based on latest records)") + print("\n[SensorsStatusSummary] ===== REFRESH DATA START =====") - active = [s for s in sensors if s["id"] not in inactive_ids] - inactive = [s for s in sensors if s["id"] in inactive_ids] + # === Load sensors === + res_sensors = self.api.http.get(f"{self.api.base}/api/tables/devices_sensor") + sensors = res_sensors.json().get("rows", []) + print(f"[SensorsStatusSummary] Loaded {len(sensors)} sensors") - print(f"[SensorsStatusSummary] Status: {len(active)} active, {len(inactive)} inactive sensors") - print(f"[SensorsStatusSummary] DEBUG: inactive_ids = {sorted(inactive_ids)}") - print(f"[SensorsStatusSummary] DEBUG: sensor IDs = {[s['id'] for s in sensors]}") - print(f"[SensorsStatusSummary] DEBUG: active sensor IDs = {[s['id'] for s in active]}") - print(f"[SensorsStatusSummary] DEBUG: inactive sensor IDs = {[s['id'] for s in inactive]}") - - # Debug: show which sensors are inactive - if inactive_ids: - print(f"[SensorsStatusSummary] Inactive sensor IDs: {sorted(inactive_ids)}") + # === Load events incrementally === + if self._first_load: + print("[SensorsStatusSummary] Initial load - fetching events...") + # API limit is max 500 per request + res_events = self.api.http.get( + f"{self.api.base}/api/tables/event_logs_sensors?limit=500&order_by=id&order_dir=desc" + ) + self._events_cache = res_events.json().get("rows", []) + if self._events_cache: + self._last_event_id = max(e.get("id", 0) for e in self._events_cache) + self._first_load = False + else: + print(f"[SensorsStatusSummary] Fetching new events after ID={self._last_event_id}") + res_events = self.api.http.get( + f"{self.api.base}/api/tables/event_logs_sensors?limit=500&order_by=id&order_dir=asc" + ) + new_events = [ + e for e in res_events.json().get("rows", []) + if e.get("id", 0) > self._last_event_id + ] + if new_events: + print(f"[SensorsStatusSummary] Found {len(new_events)} new events") + self._events_cache.extend(new_events) + self._last_event_id = max(self._last_event_id, max(e.get("id", 0) for e in new_events)) + self._events_cache = self._events_cache[-5000:] + else: + print("[SensorsStatusSummary] No new events") - self._update_cards(active, inactive) - self._update_table(active, inactive) + events = self._events_cache + print(f"[SensorsStatusSummary] Total cached events: {len(events)}") - def _get_sensors_cached(self, force_refresh=False): - """Get sensors list with caching (sensors don't change often).""" - now = datetime.now() - - if (not force_refresh and - self._last_sensors_fetch and - self._sensors_cache and - (now - self._last_sensors_fetch) < self._sensors_cache_duration): - return self._sensors_cache + # === Analyze === + latest_issues = self._get_latest_open_issues(events) - # Fetch fresh sensors data - res_sensors = self.api.http.get(f"{self.api.base}/api/tables/devices_sensor") - self._sensors_cache = res_sensors.json().get("rows", []) - self._last_sensors_fetch = now - print(f"[SensorsStatusSummary] Refreshed sensors cache: {len(self._sensors_cache)} sensors") - return self._sensors_cache - - def _get_recent_keepalive_events(self): - """Get events with smart caching - only fetch new events since last check.""" - now = datetime.now() - - # Check if cache is still valid - if (self._last_events_check and - self._events_cache and - (now - self._last_events_check) < self._events_cache_duration): - return self._events_cache - - try: - # Strategy: Get only NEW events since last check (incremental loading) - if self._last_event_id > 0: - # Get only events with ID > last processed ID - url = f"{self.api.base}/api/tables/event_logs_sensors?limit=200&order_by=id&order_dir=desc" - res_events = self.api.http.get(url) - new_events = res_events.json().get("rows", []) - - # Filter only truly new events - really_new = [e for e in new_events if e.get("id", 0) > self._last_event_id] - - if really_new: - print(f"[SensorsStatusSummary] Found {len(really_new)} new events") - # Update cache with new events - self._update_events_cache(really_new) - else: - print("[SensorsStatusSummary] No new events since last check") - else: - # First load - get recent events - print("[SensorsStatusSummary] First load - fetching initial events") - url = f"{self.api.base}/api/tables/event_logs_sensors?limit=500&order_by=id&order_dir=desc" - res_events = self.api.http.get(url) - all_events = res_events.json().get("rows", []) - self._initialize_events_cache(all_events) + # Categorize sensors by their LATEST issue type + inactive = [e for e in latest_issues if e.get("issue_type") == "missing_keepalive"] + outofrange = [e for e in latest_issues if e.get("issue_type") == "out_of_range"] + corrupted = [e for e in latest_issues if e.get("issue_type") in ["corrupted", "stuck_sensor"]] + + print(f"[SensorsStatusSummary] Categorized by latest issue:") + print(f" - Inactive (missing_keepalive): {len(inactive)}") + print(f" - Out of range: {len(outofrange)}") + print(f" - Corrupted/Stuck: {len(corrupted)}") - self._last_events_check = now - return self._events_cache + # Get unique sensor IDs with issues + all_problematic_ids = {str(e.get("device_id")) for e in latest_issues if e.get("device_id")} + print(f" - Total unique sensors with issues: {len(all_problematic_ids)}") + + total = len(sensors) + active = [s for s in sensors if str(s.get("id")) not in all_problematic_ids] + health = int((len(active) / total * 100)) if total else 0 + print(f"[SensorsStatusSummary] Summary:") + print(f" - Total sensors: {total}") + print(f" - Active (no issues): {len(active)}") + print(f" - Health: {health}%") + print(f" - Verification: {len(active)} + {len(all_problematic_ids)} = {len(active) + len(all_problematic_ids)} (should equal {total})") + + # === Update UI === + self.active_card.value_label.setText(str(len(active))) + self.inactive_card.value_label.setText(str(len(inactive))) + self.outofrange_card.value_label.setText(str(len(outofrange))) + self.corrupted_card.value_label.setText(str(len(corrupted))) + + self.health_ring.value = health + self.health_ring.update() + self.ring_bottom.setText(f"{len(active)} active sensors of {total}") + + # Update last refresh time + now = datetime.now(timezone.utc) + time_str = now.strftime("%H:%M:%S") + self.status_badge.setText(f"Live • Updated at {time_str}") + + self._update_inactive_table(inactive, sensors) + print("[SensorsStatusSummary] ===== REFRESH DATA END =====\n") + except Exception as e: - print(f"[SensorsStatusSummary] Error loading events: {e}") - return self._events_cache or [] + print(f"[SensorsStatusSummary] ❌ ERROR refreshing data: {e}") + import traceback + traceback.print_exc() - def _initialize_events_cache(self, all_events): - """Initialize cache on first load.""" - # Filter relevant events and store in cache - two_hours_ago = datetime.now() - timedelta(hours=2) + # ============================================================ + # 🔍 GET LATEST OPEN ISSUES + # ============================================================ + def _get_latest_open_issues(self, events): + """ + Get the MOST RECENT open issue per sensor (only ONE issue per sensor). + If a sensor has multiple open issues, only the latest (highest ID) is returned. + """ + latest_per_sensor = {} - filtered_events = [] - max_id = 0 - - for event in all_events: - event_id = event.get("id", 0) - if event_id > max_id: - max_id = event_id - - # Check issue type - if event.get("issue_type") not in ["missing_keepalive", "prolonged_silence"]: - continue - - # Keep both open events AND recently closed events (for cache invalidation) - is_open = event.get("end_ts") is None - is_recently_closed = False - - if not is_open: - # Check if closed recently (last 5 minutes) - end_ts_str = event.get("end_ts") - if end_ts_str: - try: - end_ts = datetime.fromisoformat(end_ts_str.replace('Z', '+00:00')) - five_min_ago = datetime.now() - timedelta(minutes=5) - is_recently_closed = end_ts.replace(tzinfo=None) >= five_min_ago - except (ValueError, AttributeError): - pass - - # Keep only open events or recently closed ones - if not (is_open or is_recently_closed): + for e in events: + dev_id = e.get("device_id") + if dev_id is None or e.get("end_ts") is not None: continue - # Check if recent (within last 2 hours) - start_ts_str = event.get("start_ts") - if start_ts_str: - try: - start_ts = datetime.fromisoformat(start_ts_str.replace('Z', '+00:00')) - if start_ts.replace(tzinfo=None) < two_hours_ago: - continue # Too old - except (ValueError, AttributeError): - continue # Invalid timestamp + dev_id_str = str(dev_id) + event_id = e.get("id", 0) - filtered_events.append(event) - - self._events_cache = filtered_events - self._last_event_id = max_id - print(f"[SensorsStatusSummary] Initialized cache with {len(filtered_events)} relevant events") - - def _update_events_cache(self, new_events): - """Update cache with new events (incremental).""" - two_hours_ago = datetime.now() - timedelta(hours=2) - - # Process new events - new_relevant = [] - max_id = self._last_event_id - - for event in new_events: - event_id = event.get("id", 0) - if event_id > max_id: - max_id = event_id - - # Apply same filtering (include recent closures) - if event.get("issue_type") in ["missing_keepalive", "prolonged_silence"]: - is_open = event.get("end_ts") is None - is_recently_closed = False - - if not is_open: - end_ts_str = event.get("end_ts") - if end_ts_str: - try: - end_ts = datetime.fromisoformat(end_ts_str.replace('Z', '+00:00')) - five_min_ago = datetime.now() - timedelta(minutes=5) - is_recently_closed = end_ts.replace(tzinfo=None) >= five_min_ago - except (ValueError, AttributeError): - pass - - if is_open or is_recently_closed: - # Check if recent - start_ts_str = event.get("start_ts") - if start_ts_str: - try: - start_ts = datetime.fromisoformat(start_ts_str.replace('Z', '+00:00')) - if start_ts.replace(tzinfo=None) >= two_hours_ago: - new_relevant.append(event) - except (ValueError, AttributeError): - pass - - # Update cache: add new events and remove old ones - self._events_cache.extend(new_relevant) + # Keep only the event with the highest ID for each sensor + if dev_id_str not in latest_per_sensor or event_id > latest_per_sensor[dev_id_str].get("id", 0): + latest_per_sensor[dev_id_str] = e - # Clean old events from cache (older than 2 hours) - self._events_cache = [ - e for e in self._events_cache - if self._is_event_recent(e, two_hours_ago) - ] + result = list(latest_per_sensor.values()) + print(f"[_get_latest_open_issues] Processed {len(events)} events") + print(f"[_get_latest_open_issues] Found {len(result)} sensors with open issues (1 issue per sensor)") - self._last_event_id = max_id - print(f"[SensorsStatusSummary] Added {len(new_relevant)} new events, cache now has {len(self._events_cache)} events") - - def _is_event_recent(self, event, threshold): - """Check if event is recent enough to keep in cache.""" - start_ts_str = event.get("start_ts") - if not start_ts_str: - return True # Keep if no timestamp + # Debug: show distribution + if result: + types_count = {} + for issue in result: + itype = issue.get("issue_type", "unknown") + types_count[itype] = types_count.get(itype, 0) + 1 + print(f"[_get_latest_open_issues] Distribution: {types_count}") - try: - start_ts = datetime.fromisoformat(start_ts_str.replace('Z', '+00:00')) - return start_ts.replace(tzinfo=None) >= threshold - except (ValueError, AttributeError): - return True # Keep if invalid timestamp + return result - def _refresh_events_only(self): - """Auto-refresh only events data (called by timer).""" - try: - self.load_data(force_sensors_refresh=False) - except Exception as e: - print(f"[SensorsStatusSummary] Auto-refresh error: {e}") + # ============================================================ + # 📋 UPDATE TABLE + # ============================================================ + def _update_inactive_table(self, inactive_events, sensors): + self.table.setRowCount(0) + now = datetime.now(timezone.utc) + sensor_map = {str(s.get("id")): s for s in sensors} - def refresh_all(self): - """Force refresh all data (sensors + events) - clear all caches.""" - # Clear all caches - self._events_cache = [] - self._last_event_id = 0 - self._last_events_check = None - self._sensors_cache = [] - self._last_sensors_fetch = None - - print("[SensorsStatusSummary] Cleared all caches - doing full refresh") - self.load_data(force_sensors_refresh=True) - - def _update_cards(self, active, inactive): - self.active_card.findChild(QLabel, "active_sensors").setText(str(len(active))) - self.inactive_card.findChild(QLabel, "inactive_sensors").setText(str(len(inactive))) - - def _update_table(self, active, inactive): - all_data = [(s, "Active") for s in active] + [(s, "Inactive") for s in inactive] - self.table.setRowCount(len(all_data)) - - for r, (sensor, status) in enumerate(all_data): - sid = QTableWidgetItem(str(sensor.get("id", ""))) - typ = QTableWidgetItem(sensor.get("sensor_type", "")) - # devices_sensor table doesn't have owner_name, use plant_id instead - plant_id = QTableWidgetItem(f"Plant {sensor.get('plant_id', '—')}") - # devices_sensor table doesn't have location, show plant_id info instead - location = QTableWidgetItem(f"Plant ID: {sensor.get('plant_id', 'N/A')}") - # Modern status with colored badges - if status == "Active": - stat = QTableWidgetItem("● ONLINE") - stat.setForeground(Qt.GlobalColor.darkGreen) - else: - stat = QTableWidgetItem("● OFFLINE") - stat.setForeground(Qt.GlobalColor.darkRed) - - # Style inactive rows with subtle background - if status == "Inactive": - gray_bg = QColor(248, 250, 252) - gray_text = QColor(107, 114, 128) - - for item in (sid, typ, plant_id, location): - item.setBackground(gray_bg) - item.setForeground(gray_text) - - self.table.setItem(r, 0, sid) - self.table.setItem(r, 1, typ) - self.table.setItem(r, 2, plant_id) - self.table.setItem(r, 3, location) - self.table.setItem(r, 4, stat) + # Sort by device_id + sorted_events = sorted(inactive_events, key=lambda e: int(e.get("device_id", 0))) + + for ev in sorted_events: + dev_id = str(ev.get("device_id")) + sensor = sensor_map.get(dev_id) + if not sensor: + continue + + row = self.table.rowCount() + self.table.insertRow(row) + + last_seen = sensor.get("last_seen") + # Format to show only time (HH:MM:SS) + try: + dt = datetime.fromisoformat(last_seen.replace("Z", "+00:00")) + time_str = dt.strftime("%H:%M:%S") + except Exception: + time_str = "—" + + # ID column + id_item = QTableWidgetItem(dev_id) + self.table.setItem(row, 0, id_item) + + # Sensor Type + self.table.setItem(row, 1, QTableWidgetItem(sensor.get("sensor_type", "Unknown"))) + + # Last Seen - only time, highlighted + last_seen_item = QTableWidgetItem(time_str) + last_seen_item.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold)) + last_seen_item.setForeground(QColor("#DC2626")) # Red color for emphasis + self.table.setItem(row, 2, last_seen_item) diff --git a/GUI/src/vast/views/sound/sound_view.py b/GUI/src/vast/views/sound/sound_view.py index 0d3d59d2d..e4e5db7c9 100644 --- a/GUI/src/vast/views/sound/sound_view.py +++ b/GUI/src/vast/views/sound/sound_view.py @@ -8,6 +8,7 @@ from PyQt6.QtCore import Qt, QDate, QUrl, QTimer, pyqtSignal, QSize from PyQt6.QtGui import QPixmap, QColor, QCursor, QPainter, QFont from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput +from PyQt6.QtWebEngineWidgets import QWebEngineView from dashboard_api import DashboardApi import matplotlib.pyplot as plt from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas @@ -18,6 +19,19 @@ import requests import os import math +import tempfile + +MINIO_BASE = os.getenv("MINIO_PUBLIC_BASE", "http://minio-hot:9000") + +def normalize_minio_url(url: str) -> str: + if not url: + return "" + if url.startswith("http://") or url.startswith("https://"): + return url + url = url.lstrip("/") + if url.startswith("sounds/"): + url = "sound/" + url + return f"{MINIO_BASE.rstrip('/')}/{url}" # ========================================================== @@ -38,9 +52,8 @@ def __init__(self, parent=None): self.setStyleSheet(""" background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #0f0c29, stop:0.5 #302b63, stop:1 #24243e); + border: none; border-radius: 12px; - border: 3px solid qlineargradient(x1:0, y1:0, x2:1, y2:0, - stop:0 #667eea, stop:1 #764ba2); """) self.default_text = "🎵 Press Play to Visualize Audio 🎵" @@ -144,7 +157,7 @@ def __init__(self, mic_id: str, mic_name: str, mic_type: str, parent=None): if mic_type == "audio": self.setFixedSize(70, 70) self.shape_style = "border-radius: 35px;" - else: # ultrasound + else: self.setFixedSize(70, 70) self.shape_style = "border-radius: 8px;" @@ -156,7 +169,6 @@ def __init__(self, mic_id: str, mic_name: str, mic_type: str, parent=None): self.disabled_color = "#888888" self.update_style() - self.setText(f"{mic_id.upper()}") self.setToolTip(f"{mic_name}
Type: {mic_type}
Click to select") @@ -199,9 +211,9 @@ def set_disabled_state(self, disabled: bool): # Interactive Map with Image # ========================================================== class ImageMapView(QWidget): - def __init__(self, parent=None): + def __init__(self, parent=None, api=None): super().__init__(parent) - + self.api = api self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(20, 20, 20, 20) self.main_layout.setSpacing(15) @@ -212,13 +224,11 @@ def __init__(self, parent=None): self.stacked_widget = QStackedWidget() - # Map view page self.map_page = QWidget() layout = QVBoxLayout(self.map_page) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(15) - # Control panel control_panel = QFrame() control_panel.setStyleSheet(""" QFrame { @@ -294,13 +304,8 @@ def __init__(self, parent=None): self.background_label.setGeometry(0, 0, 800, 600) self.background_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - # Load map image self._load_map_image() - # Microphone definitions - # Map to actual device_ids from docker-compose: - # MIC-01 = environment sounds (sounds/) - # MIC-02 = ultrasound plants (plants/) self.microphones = [ {"id": "MIC-01", "name": "Environment Mic", "type": "audio", "position": (200, 150)}, {"id": "MIC-02", "name": "Plant Ultrasound", "type": "ultrasound", "position": (500, 300)}, @@ -336,7 +341,6 @@ def __init__(self, parent=None): self.main_layout.addWidget(self.stacked_widget) def _load_map_image(self): - """Load the map background image""" possible_paths = [ "map_background.png", "./map_background.png", @@ -430,7 +434,6 @@ def view_selected_recordings(self): recordings_layout.setContentsMargins(0, 0, 0, 0) recordings_layout.setSpacing(0) - # Header header_container = QWidget() color = "#4A90E2" if self.selected_type == "audio" else "#50C878" header_container.setStyleSheet(f"background-color: {color};") @@ -477,10 +480,11 @@ def view_selected_recordings(self): sound_tab = RecordingsTab( mic_ids=mic_ids_list, recording_type=self.selected_type, - parent=self + parent=self, + api=self.api, ) recordings_layout.addWidget(sound_tab) - + self.stacked_widget.addWidget(self.recordings_page) self.stacked_widget.setCurrentWidget(self.recordings_page) @@ -491,7 +495,6 @@ def show_map(self): # ========================================================== # Recordings Tab # ========================================================== - class RecordingsTab(QWidget): def __init__(self, mic_ids=None, recording_type="audio", parent=None, api=None): super().__init__(parent) @@ -499,8 +502,6 @@ def __init__(self, mic_ids=None, recording_type="audio", parent=None, api=None): self.recording_type = recording_type self.api = api - - # API endpoints if recording_type == "ultrasound": self.api_url = "http://db_api_service:8001/api/files/plant-predictions/" else: @@ -510,19 +511,15 @@ def __init__(self, mic_ids=None, recording_type="audio", parent=None, api=None): layout.setContentsMargins(20, 20, 20, 20) layout.setSpacing(15) - # Filters Frame filter_frame = self._create_filter_frame() list_label = QLabel("Available Recordings") list_label.setStyleSheet("font-size: 16px; font-weight: bold; color: #333; padding: 5px;") - # Table setup self.file_table = self._create_table() - # Waveform waveform_container = self._create_waveform_container() - # Status self.status_label = QLabel("Ready") self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.status_label.setStyleSheet(""" @@ -530,16 +527,22 @@ def __init__(self, mic_ids=None, recording_type="audio", parent=None, api=None): background-color: #f6f8fa; border-radius: 6px; border: 1px solid #d1d5da; """) - # Media player self.player = QMediaPlayer() self.audio_output = QAudioOutput() + self.audio_output.setVolume(1.0) self.player.setAudioOutput(self.audio_output) self.player.playbackStateChanged.connect(self.on_playback_state_changed) + self._current_temp_file = None + self._current_play_btn = None + self._current_stop_btn = None layout.addWidget(filter_frame) layout.addWidget(list_label) layout.addWidget(self.file_table, 1) - layout.addWidget(waveform_container) + + if self.recording_type == "audio": + layout.addWidget(waveform_container) + layout.addWidget(self.status_label) self.refresh_button.clicked.connect(self.update_list) @@ -548,96 +551,125 @@ def __init__(self, mic_ids=None, recording_type="audio", parent=None, api=None): def _create_filter_frame(self): filter_frame = QFrame() filter_frame.setStyleSheet(""" - QFrame { background-color: #ffffff; border: 2px solid #e1e4e8; - border-radius: 12px; padding: 15px; } + QFrame { background-color: #ffffff; border-radius: 12px; padding: 15px; } """) filters_layout = QVBoxLayout(filter_frame) filters_layout.setSpacing(12) - # Row 1 - row1 = QHBoxLayout() - row1.setSpacing(10) + filter_row = QHBoxLayout() + filter_row.setSpacing(8) + filter_row.setContentsMargins(0, 0, 0, 0) - type_label = QLabel("Filter by Type:") - type_label.setStyleSheet("font-weight: bold; color: #333;") + type_label = QLabel("Type:") + type_label.setStyleSheet("font-weight: bold; color: #333; font-size: 11px;") + filter_row.addWidget(type_label) self.noise_filter = QComboBox() + self.noise_filter.setMaximumWidth(180) self.noise_filter.setStyleSheet(""" - QComboBox { padding: 8px; border: 2px solid #d1d5da; border-radius: 6px; - background: white; min-width: 150px; } - QComboBox:hover { border: 2px solid #4A90E2; } + QComboBox { + padding: 6px 10px; + border: 1px solid #d1d5da; + border-radius: 4px; + background: white; + font-size: 12px; + } + QComboBox:hover { border: 1px solid #4A90E2; } """) if self.recording_type == "ultrasound": self.noise_filter.addItems([ - "All signals", "Empty Pot", "Greenhouse Noises", - "Tobacco Cut", "Tobacco Dry", "Tomato Cut", "Tomato Dry" + "All signals", "Drought-stressed plant", + "Empty Pot", "Greenhouse Noises" ]) else: self.noise_filter.addItems([ "All types", "predatory_animals", "non_predatory_animals", - "birds", "fire", "footsteps", "insects", - "screaming", "shotgun", "stormy_weather", - "streaming_water", "vehicle", "Other" + "birds", "fire", "footsteps", "insects", "screaming", + "shotgun", "stormy_weather", "streaming_water", "vehicle", "Other" ]) - date_label = QLabel("Date Range:") - date_label.setStyleSheet("font-weight: bold; color: #333;") + filter_row.addWidget(self.noise_filter) + + date_label = QLabel(" From:") + date_label.setStyleSheet("font-weight: bold; color: #333; font-size: 11px;") + filter_row.addWidget(date_label) + + today = QDate.currentDate() + first_day = QDate(today.year(), today.month(), 1) self.date_from = QDateEdit() self.date_from.setCalendarPopup(True) - self.date_from.setDate(QDate.currentDate().addDays(-7)) + self.date_from.setDate(first_day) + self.date_from.setMaximumWidth(120) self.date_from.setStyleSheet(""" - QDateEdit { padding: 8px; border: 2px solid #d1d5da; - border-radius: 6px; background: white; } + QDateEdit { + padding: 6px 8px; + border: 1px solid #d1d5da; + border-radius: 4px; + background: white; + font-size: 12px; + } """) + filter_row.addWidget(self.date_from) + + filter_row.addWidget(QLabel("→")) self.date_to = QDateEdit() self.date_to.setCalendarPopup(True) - self.date_to.setDate(QDate.currentDate()) + self.date_to.setDate(today) + self.date_to.setMaximumWidth(120) self.date_to.setStyleSheet(self.date_from.styleSheet()) + filter_row.addWidget(self.date_to) - row1.addWidget(type_label) - row1.addWidget(self.noise_filter) - row1.addWidget(date_label) - row1.addWidget(self.date_from) - row1.addWidget(QLabel("→")) - row1.addWidget(self.date_to) - - # Row 2 - row2 = QHBoxLayout() - row2.setSpacing(10) + self.search_box = QLineEdit() + self.search_box.setPlaceholderText("Search filename...") + self.search_box.setMaximumWidth(200) + self.search_box.setStyleSheet(""" + QLineEdit { + padding: 6px 10px; + border: 1px solid #d1d5da; + border-radius: 4px; + background: white; + font-size: 12px; + } + QLineEdit:focus { border: 1px solid #4A90E2; } + """) + filter_row.addWidget(self.search_box) - sort_label = QLabel("Sort by:") - sort_label.setStyleSheet("font-weight: bold; color: #333;") + filter_row.addStretch() + filter_row.addWidget(QLabel("sort by:")) self.sort_by = QComboBox() - self.sort_by.addItems(["Date (Newest)", "Date (Oldest)", "Length", "Device"]) - self.sort_by.setStyleSheet(self.noise_filter.styleSheet()) - - self.search_box = QLineEdit() - self.search_box.setPlaceholderText("Search by filename...") - self.search_box.setStyleSheet(""" - QLineEdit { padding: 8px; border: 2px solid #d1d5da; border-radius: 6px; - background: white; font-size: 14px; } - QLineEdit:focus { border: 2px solid #4A90E2; } + self.sort_by.addItems(["date", "name", "device"]) + self.sort_by.setMaximumWidth(130) + self.sort_by.setStyleSheet(""" + QComboBox { + padding: 6px 10px; + border: 1px solid #d1d5da; + border-radius: 4px; + background: white; + font-size: 12px; + } """) + filter_row.addWidget(self.sort_by) self.refresh_button = QPushButton("🔄 Refresh") self.refresh_button.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) self.refresh_button.setStyleSheet(""" - QPushButton { background-color: #4A90E2; color: white; border-radius: 8px; - padding: 10px 20px; font-weight: bold; font-size: 14px; } + QPushButton { + background-color: #4A90E2; + color: white; + border-radius: 6px; + padding: 8px 16px; + font-weight: bold; + font-size: 12px; + } QPushButton:hover { background-color: #357ABD; } """) + filter_row.addWidget(self.refresh_button) - row2.addWidget(sort_label) - row2.addWidget(self.sort_by) - row2.addWidget(self.search_box, 2) - row2.addWidget(self.refresh_button) - - filters_layout.addLayout(row1) - filters_layout.addLayout(row2) + filters_layout.addLayout(filter_row) return filter_frame @@ -647,12 +679,12 @@ def _create_table(self): if self.recording_type == "ultrasound": table.setColumnCount(6) table.setHorizontalHeaderLabels([ - "File", "Device", "Predicted Class", "Confidence", "Watering Status", "Actions" + "File", "Device", "Predicted Label", "Confidence", "Watering Status", "Format" ]) else: table.setColumnCount(6) table.setHorizontalHeaderLabels([ - "Filename", "Device", "Predicted Label", "Probability", "Format", "Actions" + "File", "Device", "Predicted Label", "Probability", "Format", "Actions" ]) header = table.horizontalHeader() @@ -690,34 +722,43 @@ def _create_table(self): def _create_waveform_container(self): waveform_container = QFrame() waveform_container.setStyleSheet(""" - QFrame { background-color: #ffffff; border: 2px solid #4A90E2; - border-radius: 10px; padding: 10px; } + QFrame { + background-color: transparent; + border: none; + padding: 0px; + } """) + waveform_layout = QVBoxLayout(waveform_container) waveform_layout.setSpacing(5) - waveform_label = QLabel("🎵 Audio Visualizer") - waveform_label.setStyleSheet(""" - font-size: 16px; font-weight: bold; color: #4A90E2; padding: 5px; - """) - waveform_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.waveform = AudioWaveform() - self.waveform.setMinimumHeight(150) + self.waveform.setMinimumHeight(100) + self.waveform.setMaximumHeight(120) - waveform_layout.addWidget(waveform_label) waveform_layout.addWidget(self.waveform) return waveform_container def on_playback_state_changed(self, state): - print("[DEBUG] Playback state:", state) if state == QMediaPlayer.PlaybackState.PlayingState: self.waveform.start_animation() elif state == QMediaPlayer.PlaybackState.StoppedState: self.waveform.stop_animation() if self.status_label.text().startswith("Playing:"): self.status_label.setText("Finished") + if hasattr(self, '_current_play_btn') and self._current_play_btn: + self._reset_button_pair(self._current_play_btn, self._current_stop_btn) + self._current_play_btn = None + self._current_stop_btn = None + + def _map_ultrasound_label(self, raw: str) -> str: + if not raw: + return "Unknown" + lower = raw.lower() + if "tomato" in lower or "tobacco" in lower: + return "Drought-stressed plant" + return raw def update_list(self): self.file_table.setRowCount(0) @@ -732,47 +773,53 @@ def update_list(self): "limit": 100 } - # Add type filter filter_value = self.noise_filter.currentText() if self.recording_type == "ultrasound": - if filter_value not in ("All signals", "All types"): + if filter_value in ("Empty Pot", "Greenhouse Noises"): params["predicted_class"] = filter_value else: if filter_value not in ("All types", "All signals"): params["type"] = filter_value - # Add device IDs if provided if self.mic_ids: params["device_ids"] = ",".join(self.mic_ids) try: - # response = requests.get(self.api_url, params=params, timeout=10) + # Check if API is available and authenticated + if not self.api or not hasattr(self.api, 'http'): + self.status_label.setText("⚠ API connection not available") + QMessageBox.warning( + self, + "Authentication Required", + "Please login first to access recordings." + ) + return + + # Use the authenticated session response = self.api.http.get(self.api_url, params=params, timeout=10) response.raise_for_status() data = response.json() - - print(f"[DEBUG] Fetched {len(data)} records from {self.api_url}") - - # Populate table + + print(f"[DEBUG] Successfully fetched {len(data)} records from {self.api_url}") for f in data: row = self.file_table.rowCount() self.file_table.insertRow(row) self.file_table.setRowHeight(row, 60) - # Check if compressed + filename = f.get("filename") or f.get("file", "") is_compressed = f.get("is_compressed", False) - # Set text color based on compression text_color = QColor("#888888") if is_compressed else QColor("#000000") if self.recording_type == "ultrasound": - # Ultrasonic plant predictions - filename = f.get("file", "N/A") device_id = f.get("device_id", "N/A") - pred_class = f.get("predicted_class", "Unknown") + pred_class_raw = f.get("predicted_class", "Unknown") + pred_class = self._map_ultrasound_label(pred_class_raw) confidence = f.get("confidence", 0) watering_status = f.get("watering_status", "N/A") - url = f.get("url", "") + url = normalize_minio_url(f.get("url", "")) + + format_str = "OPUS (Compressed)" if is_compressed else "WAV (Original)" item0 = QTableWidgetItem(filename) item0.setForeground(text_color) @@ -793,13 +840,16 @@ def update_list(self): item4 = QTableWidgetItem(watering_status) item4.setForeground(text_color) self.file_table.setItem(row, 4, item4) + + item5 = QTableWidgetItem(format_str) + item5.setForeground(text_color) + self.file_table.setItem(row, 5, item5) else: - # Audio aggregates - filename = f.get("filename", "N/A") device_id = f.get("device_id", "N/A") label = f.get("predicted_label", "Unknown") prob = f.get("probability", 0) - url = f.get("url", "") + url = normalize_minio_url(f.get("url", "")) + format_str = "OPUS (Compressed)" if is_compressed else "WAV (Original)" item0 = QTableWidgetItem(filename) @@ -822,74 +872,71 @@ def update_list(self): item4.setForeground(text_color) self.file_table.setItem(row, 4, item4) - # Actions column with Play/Stop buttons - control_widget = QWidget() - control_layout = QHBoxLayout(control_widget) - control_layout.setContentsMargins(2, 2, 2, 2) - control_layout.setSpacing(6) + if self.recording_type == "audio": + control_widget = QWidget() + control_layout = QHBoxLayout(control_widget) + control_layout.setContentsMargins(2, 2, 2, 2) + control_layout.setSpacing(6) - play_btn = QPushButton("▶") - play_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) - play_btn.setFixedSize(35, 30) + play_btn = QPushButton("▶") + play_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) + play_btn.setFixedSize(35, 30) - # Adjust button style for compressed files - if is_compressed: - play_btn.setStyleSheet(""" - QPushButton { - background-color: #888888; - color: white; - border-radius: 4px; - font-weight: bold; - } - QPushButton:hover:enabled { background-color: #666666; } - QPushButton:disabled { background-color: #cccccc; color: #888888; } - """) - play_btn.setToolTip("Compressed OPUS file - may have compatibility issues") - else: - play_btn.setStyleSheet(""" + if is_compressed: + play_btn.setStyleSheet(""" + QPushButton { + background-color: #888888; + color: white; + border-radius: 4px; + font-weight: bold; + } + QPushButton:hover:enabled { background-color: #666666; } + QPushButton:disabled { background-color: #cccccc; color: #888888; } + """) + play_btn.setToolTip("Compressed OPUS file - may have compatibility issues") + else: + play_btn.setStyleSheet(""" + QPushButton { + background-color: #0078d4; + color: white; + border-radius: 4px; + font-weight: bold; + } + QPushButton:hover:enabled { background-color: #005fa3; } + QPushButton:disabled { background-color: #cccccc; color: #888888; } + """) + + stop_btn = QPushButton("⏹") + stop_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) + stop_btn.setFixedSize(35, 30) + stop_btn.setEnabled(False) + stop_btn.setStyleSheet(""" QPushButton { - background-color: #0078d4; + background-color: #6c757d; color: white; border-radius: 4px; font-weight: bold; } - QPushButton:hover:enabled { background-color: #005fa3; } - QPushButton:disabled { background-color: #cccccc; color: #888888; } + QPushButton:disabled { background-color: #b0b0b0; } + QPushButton:hover:enabled { background-color: #c82333; } """) - stop_btn = QPushButton("⏹") - stop_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) - stop_btn.setFixedSize(35, 30) - stop_btn.setEnabled(False) - stop_btn.setStyleSheet(""" - QPushButton { - background-color: #6c757d; - color: white; - border-radius: 4px; - font-weight: bold; - } - QPushButton:disabled { background-color: #b0b0b0; } - QPushButton:hover:enabled { background-color: #c82333; } - """) - - # Store current row buttons for state management - play_btn.setProperty("row", row) - stop_btn.setProperty("row", row) - - play_btn.clicked.connect( - lambda checked=False, u=url, fname=filename, pb=play_btn, sb=stop_btn, compressed=is_compressed: - self.play_row_audio(u, fname, pb, sb, compressed) - ) - stop_btn.clicked.connect( - lambda checked=False, pb=play_btn, sb=stop_btn: - self.stop_row_audio(pb, sb) - ) + play_btn.setProperty("row", row) + stop_btn.setProperty("row", row) - control_layout.addWidget(play_btn) - control_layout.addWidget(stop_btn) + play_btn.clicked.connect( + lambda checked=False, u=url, fname=filename, pb=play_btn, sb=stop_btn, compressed=is_compressed: + self.play_row_audio(u, fname, pb, sb, compressed) + ) + stop_btn.clicked.connect( + lambda checked=False, pb=play_btn, sb=stop_btn: + self.stop_row_audio(pb, sb) + ) - # Set correct column index for Actions (column 5 for both types now) - self.file_table.setCellWidget(row, 5, control_widget) + control_layout.addWidget(play_btn) + control_layout.addWidget(stop_btn) + + self.file_table.setCellWidget(row, 5, control_widget) if self.file_table.rowCount() == 0: self.file_table.insertRow(0) @@ -900,155 +947,179 @@ def update_list(self): self.status_label.setText(f"✓ Loaded {len(data)} recordings") + except requests.exceptions.HTTPError as e: + if e.response.status_code == 401: + self.status_label.setText("⚠ Authentication required") + QMessageBox.warning(self, "Authentication Error", + "API requires authentication. Please check your credentials.") + else: + self.status_label.setText(f"⚠ HTTP Error {e.response.status_code}") + QMessageBox.warning(self, "HTTP Error", + f"Server returned error {e.response.status_code}:\n{str(e)}") except requests.exceptions.Timeout: self.status_label.setText("⚠ Request timeout") QMessageBox.warning(self, "Timeout", "Request timed out. Please try again.") except requests.exceptions.ConnectionError: self.status_label.setText("⚠ Connection error") QMessageBox.warning(self, "Connection Error", - "Could not connect to server. Check your connection.") + "Could not connect to server. Check your connection.") except Exception as e: self.status_label.setText("⚠ Error loading data") QMessageBox.warning(self, "Error", f"Failed to load recordings:\n{str(e)}") - + def play_row_audio(self, url, filename, play_btn, stop_btn, is_compressed=False): - """Play audio from MinIO URL""" if not url: QMessageBox.warning(self, "No URL", "Audio file URL not available") return - - # Stop any currently playing audio first + self.player.stop() + self.waveform.stop_animation() - print(f"[DEBUG] Attempting to play: {url}") + try: + if self._current_temp_file: + if os.path.exists(self._current_temp_file): + os.remove(self._current_temp_file) + except Exception: + pass + self._current_temp_file = None + + playback_url = url + if url.startswith("http://localhost") or url.startswith("http://127.0.0.1"): + parts = url.split("/", 3) + if len(parts) > 3: + path = parts[3] + playback_url = f"http://minio-hot:9000/{path}" - # Warn about compressed files if is_compressed: reply = QMessageBox.question( - self, - "Compressed File", + self, + "Compressed File", "This is a compressed OPUS file. Playback may not work properly.\n\nContinue anyway?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.No: return - - # Test MinIO connectivity + try: - print(f"[DEBUG] Testing URL accessibility: {url}") - check = requests.head(url, timeout=3) - print(f"[DEBUG] MinIO response: {check.status_code}") - - if check.status_code == 403: - QMessageBox.warning(self, "Access Denied", - f"MinIO returned 403 Forbidden.\n\nThe bucket may not be public.\n\nURL: {url}") - return - elif check.status_code == 404: - QMessageBox.warning(self, "File Not Found", - f"MinIO returned 404 Not Found.\n\nThe file may have been deleted.\n\nURL: {url}") - return - elif check.status_code != 200: - QMessageBox.warning(self, "MinIO Error", - f"MinIO returned status {check.status_code}\n\nURL: {url}") - return - - except requests.exceptions.ConnectionError as e: - QMessageBox.warning(self, "Connection Error", - f"Cannot connect to MinIO server.\n\nMake sure MinIO is running.\n\nError: {str(e)[:200]}") - return - except requests.exceptions.Timeout: - QMessageBox.warning(self, "Timeout", - "MinIO request timed out.\n\nThe server may be slow or unreachable.") + session = self.api.http if (self.api and getattr(self.api, "http", None)) else requests + resp = session.get(playback_url, timeout=15) + resp.raise_for_status() + suffix = ".ogg" if is_compressed else ".wav" + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) + tmp.write(resp.content) + tmp.flush() + tmp_path = tmp.name + tmp.close() + self._current_temp_file = tmp_path + except requests.exceptions.RequestException as e: + self.status_label.setText("⚠ Unable to download file") + QMessageBox.warning(self, "Download Error", f"Could not download audio file:\n{e}") return except Exception as e: - QMessageBox.warning(self, "Network Error", - f"Failed to reach MinIO:\n\n{str(e)[:200]}") + self.status_label.setText("⚠ Error downloading file") + QMessageBox.warning(self, "Error", f"Failed to download audio file:\n{e}") return - - # Update UI - self.waveform.start_animation() - status_text = f"▶ Playing: {filename}" - if is_compressed: - status_text += " (Compressed)" - self.status_label.setText(status_text) - - # Disable all play buttons, enable this stop button - self._disable_all_play_buttons() - play_btn.setEnabled(False) - stop_btn.setEnabled(True) - stop_btn.setStyleSheet(""" - QPushButton { - background-color: #dc3545; - color: white; - border-radius: 4px; - font-weight: bold; - } - QPushButton:hover { background-color: #c82333; } - """) - + try: - # Set audio source and play - qurl = QUrl(url) - print(f"[DEBUG] QUrl created: {qurl.toString()}") - self.player.setSource(qurl) + self._reset_all_buttons() + + play_btn.setEnabled(False) + play_btn.setStyleSheet(""" + QPushButton { + background-color: #888888; + color: white; + border-radius: 4px; + font-weight: bold; + } + """) + + stop_btn.setEnabled(True) + stop_btn.setStyleSheet(""" + QPushButton { + background-color: #dc3545; + color: white; + border-radius: 4px; + font-weight: bold; + } + QPushButton:hover { background-color: #c82333; } + """) + + self.player.setSource(QUrl.fromLocalFile(self._current_temp_file)) self.player.play() - print(f"[DEBUG] Player state after play(): {self.player.playbackState()}") + self.waveform.start_animation() + self.status_label.setText(f"Playing: {filename}") + + self._current_play_btn = play_btn + self._current_stop_btn = stop_btn except Exception as e: - print(f"[ERROR] Playback failed: {e}") - self.waveform.stop_animation() - self.status_label.setText("⚠ Playback failed") - QMessageBox.warning(self, "Playback Error", f"Failed to play audio:\n{str(e)}") - self._enable_all_play_buttons() - stop_btn.setEnabled(False) + self.status_label.setText("⚠ Playback error") + QMessageBox.warning(self, "Playback Error", f"Playback failed:\n{e}") + self._reset_all_buttons() def stop_row_audio(self, play_btn, stop_btn): - """Stop currently playing audio""" self.player.stop() self.waveform.stop_animation() self.status_label.setText("⏹ Stopped") + self._reset_button_pair(play_btn, stop_btn) + + def _reset_button_pair(self, play_btn, stop_btn): + if play_btn.toolTip() and "Compressed" in play_btn.toolTip(): + play_btn.setStyleSheet(""" + QPushButton { + background-color: #888888; + color: white; + border-radius: 4px; + font-weight: bold; + } + QPushButton:hover:enabled { background-color: #666666; } + QPushButton:disabled { background-color: #cccccc; color: #888888; } + """) + else: + play_btn.setStyleSheet(""" + QPushButton { + background-color: #0078d4; + color: white; + border-radius: 4px; + font-weight: bold; + } + QPushButton:hover:enabled { background-color: #005fa3; } + QPushButton:disabled { background-color: #cccccc; color: #888888; } + """) + play_btn.setEnabled(True) + + stop_btn.setEnabled(False) + stop_btn.setStyleSheet(""" + QPushButton { + background-color: #6c757d; + color: white; + border-radius: 4px; + font-weight: bold; + } + QPushButton:disabled { background-color: #b0b0b0; } + QPushButton:hover:enabled { background-color: #c82333; } + """) - # Re-enable all play buttons - self._enable_all_play_buttons() - - def _disable_all_play_buttons(self): - """Disable all play buttons in the table""" - for row in range(self.file_table.rowCount()): - widget = self.file_table.cellWidget(row, 6) - if widget: - layout = widget.layout() - if layout and layout.count() >= 1: - play_btn = layout.itemAt(0).widget() - if play_btn: - play_btn.setEnabled(False) - - def _enable_all_play_buttons(self): - """Enable all play buttons and disable all stop buttons""" + def _reset_all_buttons(self): + if self.recording_type != "audio": + return + + actions_col = 5 for row in range(self.file_table.rowCount()): - widget = self.file_table.cellWidget(row, 5) + widget = self.file_table.cellWidget(row, actions_col) if widget: layout = widget.layout() if layout and layout.count() >= 2: play_btn = layout.itemAt(0).widget() stop_btn = layout.itemAt(1).widget() - if play_btn: - play_btn.setEnabled(True) - if stop_btn: - stop_btn.setEnabled(False) - stop_btn.setStyleSheet(""" - QPushButton { - background-color: #6c757d; - color: white; - border-radius: 4px; - font-weight: bold; - } - QPushButton:disabled { background-color: #b0b0b0; } - """) + if play_btn and stop_btn and isinstance(play_btn, QPushButton): + self._reset_button_pair(play_btn, stop_btn) + # ========================================================== -# Analytics Dashboard Tab +# Sound Analytics View - NEW TAB from first document # ========================================================== -class AnalyticsDashboard(QWidget): +class SoundAnalyticsView(QWidget): """Sound detection dashboard with filtering by time range and sound type""" SOUND_TYPES = [ @@ -1065,28 +1136,22 @@ class AnalyticsDashboard(QWidget): "vehicle" ] - # 11 shades of green palette - GREEN_PALETTE = [ - '#004d00', # Dark green - '#006600', - '#008000', # Green - '#1a9900', - '#33b300', - '#4dcc00', - '#66e600', - '#80ff00', - '#99ff33', - '#b3ff66', - '#ccff99' # Light green + CYAN_PALETTE = [ + '#003366', '#004d99', '#0066cc', '#1a80e5', + '#3399ff', '#53A0E5', '#66b3ff', '#80ccff', + '#99e6ff', '#b3f0ff', '#ccf7ff' ] + PRIMARY_CYAN = '#53A0E5' + ACCENT_CYAN = '#3399ff' + LIGHT_THEME = { 'bg': '#f8f9fa', 'card': '#ffffff', 'text': '#333333', 'border': '#e0e0e0', - 'primary': '#1976D2', - 'accent': '#4dcc00' + 'primary': PRIMARY_CYAN, + 'accent': ACCENT_CYAN } DARK_THEME = { @@ -1095,35 +1160,42 @@ class AnalyticsDashboard(QWidget): 'text': '#e0e0e0', 'border': '#444444', 'primary': '#64B5F6', - 'accent': '#66e600' + 'accent': ACCENT_CYAN } def __init__(self, api: DashboardApi, parent=None): super().__init__(parent) self.api = api + + # בדיקה ראשונית + print(f"[INIT] API object: {self.api}", flush=True) + print(f"[INIT] API has http: {hasattr(self.api, 'http')}", flush=True) + + # נסה לבדוק connection + try: + test_query = "SELECT 1 as test" + result = self.api.run_query(test_query) + print(f"[INIT] DB test result: {result}", flush=True) + except Exception as e: + print(f"[INIT] DB connection error: {e}", flush=True) + self.current_time_range = 'day' - self.current_sound_types = [] # Multi-select list + self.current_sound_types = [] self.is_dark_theme = False self.current_theme = self.LIGHT_THEME.copy() + self.setWindowTitle("Sound Detection Analytics") self.setMinimumSize(QSize(1350, 1000)) - # Main layout main_layout = QVBoxLayout() main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) - # Toolbar - toolbar = self._create_control_panel() - main_layout.addWidget(toolbar) - - # Content frame content_frame = QFrame() content_layout = QVBoxLayout() content_layout.setContentsMargins(12, 12, 12, 12) content_layout.setSpacing(12) - # Filter frame filter_frame = QFrame() filter_frame.setStyleSheet(""" QFrame { @@ -1137,7 +1209,6 @@ def __init__(self, api: DashboardApi, parent=None): filter_layout.setContentsMargins(12, 10, 12, 10) filter_layout.setSpacing(15) - # Time range row time_row = QHBoxLayout() time_label = QLabel("Time Range:") time_label.setFont(QFont("Arial", 10, QFont.Weight.Bold)) @@ -1151,14 +1222,13 @@ def __init__(self, api: DashboardApi, parent=None): time_row.addStretch() filter_layout.addLayout(time_row) - # Sound types header sound_header_row = QHBoxLayout() sound_label = QLabel("Sound Types (select multiple):") sound_label.setFont(QFont("Arial", 10, QFont.Weight.Bold)) sound_header_row.addWidget(sound_label) self.selection_label = QLabel("All sounds selected") - self.selection_label.setStyleSheet("color: #1976D2; font-weight: bold;") + self.selection_label.setStyleSheet(f"color: {self.PRIMARY_CYAN}; font-weight: bold;") sound_header_row.addWidget(self.selection_label) sound_header_row.addStretch() @@ -1169,30 +1239,29 @@ def __init__(self, api: DashboardApi, parent=None): apply_btn = QPushButton("Apply Filter") apply_btn.setMaximumWidth(100) - apply_btn.setStyleSheet(""" - QPushButton { - background-color: #1976D2; + apply_btn.setStyleSheet(f""" + QPushButton {{ + background-color: {self.PRIMARY_CYAN}; color: white; font-weight: bold; - } - QPushButton:hover { - background-color: #1565C0; - } + }} + QPushButton:hover {{ + background-color: {self.CYAN_PALETTE[2]}; + }} """) apply_btn.clicked.connect(self._refresh_data) sound_header_row.addWidget(apply_btn) filter_layout.addLayout(sound_header_row) - # Checkbox container checkbox_container = QFrame() checkbox_container.setObjectName("checkboxContainer") - checkbox_container.setStyleSheet(""" - QFrame#checkboxContainer { + checkbox_container.setStyleSheet(f""" + QFrame#checkboxContainer {{ background-color: white; - border: 2px solid #1976D2; + border: 2px solid {self.PRIMARY_CYAN}; border-radius: 6px; max-height: 350px; - } + }} """) checkbox_layout = QGridLayout() checkbox_layout.setSpacing(5) @@ -1212,11 +1281,9 @@ def __init__(self, api: DashboardApi, parent=None): filter_frame.setLayout(filter_layout) content_layout.addWidget(filter_frame) - # Activity calendar calendar_frame = self._create_activity_calendar() content_layout.addWidget(calendar_frame) - # Chart grid grid = QGridLayout() grid.setSpacing(12) grid.setRowStretch(0, 1) @@ -1225,7 +1292,6 @@ def __init__(self, api: DashboardApi, parent=None): grid.setColumnStretch(0, 1) grid.setColumnStretch(1, 1) - # Helper function for uniform frames def make_chart_frame(title, canvas): frame = self._create_chart_frame(title, canvas) frame.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) @@ -1233,21 +1299,18 @@ def make_chart_frame(title, canvas): frame.setMaximumHeight(320) return frame - # Row 1 self.dist_canvas = self._create_canvas(figsize=(6, 5)) grid.addWidget(make_chart_frame("Sound Distribution (Count)", self.dist_canvas), 0, 0) self.timeline_canvas = self._create_canvas(figsize=(6, 5)) grid.addWidget(make_chart_frame("Detection Timeline", self.timeline_canvas), 0, 1) - # Row 2 self.heatmap_canvas = self._create_canvas(figsize=(6, 5)) grid.addWidget(make_chart_frame("Sound Heatmap - Activity Patterns", self.heatmap_canvas), 1, 0) self.correlation_canvas = self._create_canvas(figsize=(6, 5)) grid.addWidget(make_chart_frame("Correlation Explorer", self.correlation_canvas), 1, 1) - # Row 3 self.confidence_canvas = self._create_canvas(figsize=(6, 5)) grid.addWidget(make_chart_frame("Model Health Monitor", self.confidence_canvas), 2, 0) @@ -1257,7 +1320,6 @@ def make_chart_frame(title, canvas): stats_frame.setMaximumHeight(320) grid.addWidget(stats_frame, 2, 1) - # Add grid to content content_layout.addLayout(grid, stretch=10) content_frame.setLayout(content_layout) @@ -1270,42 +1332,13 @@ def make_chart_frame(title, canvas): main_layout.addWidget(scroll_area) self.setLayout(main_layout) - # Timer self.refresh_timer = QTimer() self.refresh_timer.timeout.connect(self._refresh_data) - self.refresh_timer.start(30000) # 30 seconds + self.refresh_timer.start(30000) - # Initial load self._refresh_data() - def _create_control_panel(self) -> QToolBar: - """Create toolbar with control buttons: Refresh, Export CSV, Toggle Theme""" - toolbar = QToolBar("Control Panel") - toolbar.setMovable(False) - toolbar.setIconSize(QSize(16, 16)) - toolbar.setStyleSheet(""" - QToolBar { - background-color: #f0f0f0; - border-bottom: 1px solid #ddd; - spacing: 10px; - padding: 8px; - } - QToolButton { - padding: 6px 12px; - font-weight: bold; - } - """) - - toolbar.addAction("🔄 Refresh", self._refresh_data) - toolbar.addSeparator() - toolbar.addAction("💾 Export CSV", self._export_csv) - toolbar.addSeparator() - toolbar.addAction("🌓 Toggle Theme", self._toggle_theme) - - return toolbar - def _create_activity_calendar(self) -> QFrame: - """Create a calendar showing detection activity per day""" frame = QFrame() frame.setStyleSheet(""" QFrame { @@ -1324,7 +1357,6 @@ def _create_activity_calendar(self) -> QFrame: title.setFont(QFont("Arial", 10, QFont.Weight.Bold)) layout.addWidget(title) - # Create grid for calendar calendar_grid = QHBoxLayout() calendar_grid.setSpacing(2) @@ -1335,7 +1367,6 @@ def _create_activity_calendar(self) -> QFrame: day_box.setMinimumSize(QSize(20, 20)) day_box.setMaximumSize(QSize(20, 20)) - # Random intensity for demo (replace with real data from API) intensity = np.random.rand() color = self._get_intensity_color(intensity) @@ -1363,117 +1394,23 @@ def _create_activity_calendar(self) -> QFrame: return frame def _get_intensity_color(self, intensity: float) -> str: - """Map intensity (0-1) to green color from palette""" if intensity < 0.2: - return self.GREEN_PALETTE[0] + return self.CYAN_PALETTE[0] elif intensity < 0.4: - return self.GREEN_PALETTE[2] + return self.CYAN_PALETTE[2] elif intensity < 0.6: - return self.GREEN_PALETTE[4] + return self.CYAN_PALETTE[4] elif intensity < 0.8: - return self.GREEN_PALETTE[7] + return self.CYAN_PALETTE[7] else: - return self.GREEN_PALETTE[10] - - def _toggle_theme(self): - """Toggle between light and dark theme""" - self.is_dark_theme = not self.is_dark_theme - self.current_theme = self.DARK_THEME.copy() if self.is_dark_theme else self.LIGHT_THEME.copy() - self._apply_theme() - self._refresh_data() - - def _apply_theme(self): - """Apply current theme to all widgets""" - theme = self.current_theme - - self.setStyleSheet(f""" - AnalyticsDashboard {{ - background-color: {theme['bg']}; - }} - QComboBox {{ - padding: 8px 12px; - border: 2px solid {theme['border']}; - border-radius: 6px; - background-color: {theme['card']}; - color: {theme['text']}; - min-width: 200px; - font-size: 10pt; - font-weight: 500; - }} - QComboBox:hover {{ - border: 2px solid {theme['primary']}; - }} - QCheckBox {{ - padding: 6px 12px; - font-size: 10pt; - color: {theme['text']}; - }} - QCheckBox:hover {{ - background-color: {theme['primary']}; - }} - QLabel {{ - color: {theme['text']}; - }} - QPushButton {{ - padding: 6px 12px; - background-color: {theme['card']}; - border: 1px solid {theme['border']}; - border-radius: 4px; - font-weight: bold; - color: {theme['text']}; - }} - QPushButton:hover {{ - background-color: {theme['primary']}; - color: white; - }} - QFrame {{ - background-color: {theme['card']}; - border: 1px solid {theme['border']}; - border-radius: 8px; - }} - QToolBar {{ - background-color: {theme['card']}; - border-bottom: 1px solid {theme['border']}; - }} - """) - - def _export_csv(self): - """Export current data to CSV""" - try: - import csv - - filename = f"sound_detection_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" - - sound_filter = self.current_sound_types if self.current_sound_types else None - data = self.api.get_audio_distribution( - self.current_time_range, - limit=100, - sound_types=sound_filter - ) - - with open(filename, 'w', newline='') as f: - writer = csv.DictWriter(f, fieldnames=['sound_type', 'count']) - writer.writeheader() - for row in data: - writer.writerow({ - 'sound_type': row.get('head_pred_label'), - 'count': row.get('count') - }) - - QMessageBox.information(self, "Success", f"Data exported to {filename}") - print(f"[AnalyticsDashboard] Data exported to {filename}", flush=True) - except Exception as e: - QMessageBox.warning(self, "Error", f"Export failed: {str(e)}") - print(f"[AnalyticsDashboard] Export error: {e}", flush=True) + return self.CYAN_PALETTE[10] def _create_canvas(self, figsize=(5.5, 4.5)): - """Create matplotlib canvas for chart""" canvas = FigureCanvas(Figure(figsize=figsize, dpi=90)) canvas.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) return canvas def _create_chart_frame(self, title: str, widget: QWidget) -> QFrame: - """Create a framed chart container""" frame = QFrame() frame.setStyleSheet(""" QFrame { @@ -1489,7 +1426,7 @@ def _create_chart_frame(self, title: str, widget: QWidget) -> QFrame: title_label = QLabel(title) title_label.setFont(QFont("Arial", 11, QFont.Weight.Bold)) - title_label.setStyleSheet("color: #1976D2; margin-bottom: 4px;") + title_label.setStyleSheet(f"color: {self.PRIMARY_CYAN}; margin-bottom: 4px;") layout.addWidget(title_label) layout.addWidget(widget, 1) @@ -1497,7 +1434,6 @@ def _create_chart_frame(self, title: str, widget: QWidget) -> QFrame: return frame def _create_stats_frame(self) -> QFrame: - """Create frame with 4 stat boxes in 2x2 grid""" frame = QFrame() frame.setStyleSheet(""" QFrame { @@ -1513,7 +1449,7 @@ def _create_stats_frame(self) -> QFrame: title_label = QLabel("Statistics") title_label.setFont(QFont("Arial", 11, QFont.Weight.Bold)) - title_label.setStyleSheet("color: #1976D2; margin-bottom: 6px;") + title_label.setStyleSheet(f"color: {self.PRIMARY_CYAN}; margin-bottom: 6px;") layout.addWidget(title_label) stats_grid = QGridLayout() @@ -1523,7 +1459,6 @@ def _create_stats_frame(self) -> QFrame: stats_grid.setColumnStretch(0, 1) stats_grid.setColumnStretch(1, 1) - # Store references for updating self.stat_boxes = {} stat_names = [ @@ -1545,7 +1480,6 @@ def _create_stats_frame(self) -> QFrame: return frame def _create_stat_box(self, label: str) -> QFrame: - """Create a single statistic box""" box = QFrame() box.setStyleSheet(""" QFrame { @@ -1568,7 +1502,7 @@ def _create_stat_box(self, label: str) -> QFrame: value_widget = QLabel("--") value_widget.setFont(QFont("Arial", 22, QFont.Weight.Bold)) - value_widget.setStyleSheet("color: #1976D2;") + value_widget.setStyleSheet(f"color: {self.PRIMARY_CYAN};") value_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(value_widget) @@ -1578,7 +1512,6 @@ def _create_stat_box(self, label: str) -> QFrame: return box def _on_sound_checkbox_changed(self): - """Update selection label when checkboxes change""" selected = [] for sound_name, checkbox in self.sound_checkboxes.items(): if checkbox.isChecked(): @@ -1586,18 +1519,14 @@ def _on_sound_checkbox_changed(self): self.current_sound_types = selected - # Update label if not selected: self.selection_label.setText("All sounds selected") elif len(selected) == 1: self.selection_label.setText(f"1 sound type selected: {selected[0]}") else: self.selection_label.setText(f"{len(selected)} sound types selected") - - print(f"[AnalyticsDashboard] Selection changed: {self.current_sound_types or 'All'}", flush=True) def _clear_sound_selection(self): - """Clear all sound type selections""" for checkbox in self.sound_checkboxes.values(): checkbox.setChecked(False) @@ -1605,23 +1534,16 @@ def _clear_sound_selection(self): self.selection_label.setText("All sounds selected") self.time_filter.setCurrentText('1 Day') self.current_time_range = 'day' - - print(f"[AnalyticsDashboard] Filters cleared", flush=True) self._refresh_data() def _on_filter_changed(self): - """Handle time filter changes""" time_map = {'1 Day': 'day', '1 Week': 'week', '1 Month': 'month'} self.current_time_range = time_map.get(self.time_filter.currentText(), 'day') - - print(f"[AnalyticsDashboard] Time range changed to: {self.current_time_range}", flush=True) self._refresh_data() def _refresh_data(self): - """Refresh all charts with current filters""" try: sound_filter = self.current_sound_types if self.current_sound_types else None - print(f"[AnalyticsDashboard] Refreshing - Time: {self.current_time_range}, Sounds: {sound_filter or 'All'}", flush=True) self._clear_canvas(self.dist_canvas) self._clear_canvas(self.timeline_canvas) @@ -1634,12 +1556,9 @@ def _refresh_data(self): self._update_confidence_chart() self._update_stats_boxes() except Exception as e: - print(f"[AnalyticsDashboard] Refresh error: {e}", flush=True) - import traceback - traceback.print_exc() + print(f"[SoundAnalyticsView] Refresh error: {e}", flush=True) def _update_distribution_chart(self): - """Update bar chart - distribution of sound types by COUNT""" try: sound_filter = self.current_sound_types if self.current_sound_types else None data = self.api.get_audio_distribution( @@ -1648,6 +1567,8 @@ def _update_distribution_chart(self): sound_types=sound_filter ) + print(f"[DEBUG] Distribution data: {len(data) if data else 0} items", flush=True) + if not data: self._show_no_data(self.dist_canvas) return @@ -1655,10 +1576,11 @@ def _update_distribution_chart(self): labels = [d['head_pred_label'] for d in data] counts = [d['count'] for d in data] + # נקה את הקנבס + self.dist_canvas.figure.clear() ax = self.dist_canvas.figure.add_subplot(111) - # Use green palette - colors = [self.GREEN_PALETTE[i % len(self.GREEN_PALETTE)] for i in range(len(labels))] + colors = [self.CYAN_PALETTE[i % len(self.CYAN_PALETTE)] for i in range(len(labels))] bars = ax.bar(range(len(labels)), counts, color=colors, edgecolor='black', linewidth=0.5) ax.set_xticks(range(len(labels))) @@ -1666,21 +1588,23 @@ def _update_distribution_chart(self): ax.set_ylabel('Count', fontsize=9, fontweight='bold') ax.grid(True, alpha=0.3, linestyle='--', axis='y') - # Add value labels on bars for bar in bars: height = bar.get_height() ax.text(bar.get_x() + bar.get_width()/2., height, - f'{int(height)}', - ha='center', va='bottom', fontsize=8, fontweight='bold') + f'{int(height)}', + ha='center', va='bottom', fontsize=8, fontweight='bold') self.dist_canvas.figure.tight_layout() self.dist_canvas.draw() + print("[DEBUG] Distribution chart drawn successfully", flush=True) + except Exception as e: - print(f"[AnalyticsDashboard] Distribution chart error: {e}", flush=True) + print(f"[ERROR] Distribution chart error: {e}", flush=True) + import traceback + traceback.print_exc() self._show_no_data(self.dist_canvas) - + def _update_timeline_chart(self): - """Update line chart - timeline of detections""" try: sound_filter = self.current_sound_types if self.current_sound_types else None data = self.api.get_audio_timeline( @@ -1688,11 +1612,12 @@ def _update_timeline_chart(self): sound_types=sound_filter ) + print(f"[DEBUG] Timeline data: {len(data) if data else 0} items", flush=True) + if not data: self._show_no_data(self.timeline_canvas) return - # Group by time_bucket and sum counts timeline_dict = {} for row in data: time_bucket = row['time_bucket'] @@ -1705,10 +1630,12 @@ def _update_timeline_chart(self): times = [str(t)[:16] for t in sorted_times] counts = [timeline_dict[t] for t in sorted_times] + # נקה את הקנבס + self.timeline_canvas.figure.clear() ax = self.timeline_canvas.figure.add_subplot(111) - ax.plot(times, counts, marker='o', linewidth=2, markersize=5, color='#1a9900') - ax.fill_between(range(len(times)), counts, alpha=0.2, color='#4dcc00') + ax.plot(times, counts, marker='o', linewidth=2, markersize=5, color=self.ACCENT_CYAN) + ax.fill_between(range(len(times)), counts, alpha=0.2, color=self.PRIMARY_CYAN) ax.set_xlabel('Time', fontsize=9, fontweight='bold') ax.set_ylabel('Detections', fontsize=9, fontweight='bold') ax.grid(True, alpha=0.3, linestyle='--') @@ -1717,12 +1644,15 @@ def _update_timeline_chart(self): self.timeline_canvas.figure.autofmt_xdate(rotation=45, ha='right') self.timeline_canvas.figure.tight_layout() self.timeline_canvas.draw() + print("[DEBUG] Timeline chart drawn successfully", flush=True) + except Exception as e: - print(f"[AnalyticsDashboard] Timeline chart error: {e}", flush=True) + print(f"[ERROR] Timeline chart error: {e}", flush=True) + import traceback + traceback.print_exc() self._show_no_data(self.timeline_canvas) - + def _update_confidence_chart(self): - """Update Model Health Monitor chart - Avg Confidence % & Processing Time""" try: sound_filter = self.current_sound_types if self.current_sound_types else None data = self.api.get_model_health_metrics( @@ -1742,21 +1672,20 @@ def _update_confidence_chart(self): fig.clear() ax1 = fig.add_subplot(111) - ax1.set_title("Model Performance Trends", fontsize=10, fontweight="bold", color="#1976D2") - ax1.plot(times, avg_conf, color="#33b300", marker="o", linewidth=2, label="Avg Confidence %") - ax1.fill_between(range(len(avg_conf)), avg_conf, alpha=0.15, color="#66e600") + ax1.set_title("Model Performance Trends", fontsize=10, fontweight="bold", color=self.PRIMARY_CYAN) + ax1.plot(times, avg_conf, color=self.ACCENT_CYAN, marker="o", linewidth=2, label="Avg Confidence %") + ax1.fill_between(range(len(avg_conf)), avg_conf, alpha=0.15, color=self.PRIMARY_CYAN) ax1.set_ylabel("Confidence (%)", fontsize=9, fontweight="bold") ax1.set_ylim(0, 100) ax1.tick_params(axis='x', rotation=45, labelsize=8) ax1.grid(True, alpha=0.3, linestyle="--") - # Processing time on secondary y-axis ax2 = ax1.twinx() - ax2.plot(times, avg_proc, color="#99ff33", marker="^", linestyle="--", linewidth=2, label="Avg Processing (ms)") - ax2.set_ylabel("Processing Time (ms)", fontsize=9, fontweight="bold", color="#66e600") - ax2.tick_params(axis='y', labelcolor="#66e600") + proc_color = self.CYAN_PALETTE[7] + ax2.plot(times, avg_proc, color=proc_color, marker="^", linestyle="--", linewidth=2, label="Avg Processing (ms)") + ax2.set_ylabel("Processing Time (ms)", fontsize=9, fontweight="bold", color=proc_color) + ax2.tick_params(axis='y', labelcolor=proc_color) - # Combined legend lines, labels = ax1.get_legend_handles_labels() lines2, labels2 = ax2.get_legend_handles_labels() ax1.legend(lines + lines2, labels + labels2, loc="upper left", fontsize=8) @@ -1765,11 +1694,10 @@ def _update_confidence_chart(self): self.confidence_canvas.draw() except Exception as e: - print(f"[AnalyticsDashboard] Model Health Monitor chart error: {e}", flush=True) + print(f"[SoundAnalyticsView] Model Health Monitor chart error: {e}", flush=True) self._show_no_data(self.confidence_canvas) def _update_stats_boxes(self): - """Update statistics boxes""" try: sound_filter = self.current_sound_types if self.current_sound_types else None stats = self.api.get_audio_stats( @@ -1777,8 +1705,6 @@ def _update_stats_boxes(self): sound_types=sound_filter ) - print(f"[AnalyticsDashboard] Stats received: {stats}", flush=True) - if stats: total = stats.get('total_files', 0) or 0 self.stat_boxes['total_files']._value.setText(str(total)) @@ -1801,16 +1727,12 @@ def _update_stats_boxes(self): for key in self.stat_boxes: self.stat_boxes[key]._value.setText("--") except Exception as e: - print(f"[AnalyticsDashboard] Stats update error: {e}", flush=True) - import traceback - traceback.print_exc() + print(f"[SoundAnalyticsView] Stats update error: {e}", flush=True) def _clear_canvas(self, canvas): - """Clear a matplotlib canvas completely""" canvas.figure.clear() def _show_no_data(self, canvas): - """Show 'No Data Available' message on canvas""" ax = canvas.figure.add_subplot(111) ax.text(0.5, 0.5, 'No Data Available', ha='center', va='center', fontsize=14, fontweight='bold', @@ -1821,12 +1743,10 @@ def _show_no_data(self, canvas): canvas.draw() def closeEvent(self, event): - """Clean up timer when closing""" self.refresh_timer.stop() super().closeEvent(event) def _update_heatmap_chart(self): - """Update heatmap chart - activity by hour of day and day of week""" try: sound_filter = self.current_sound_types if self.current_sound_types else None data = self.api.get_audio_heatmap( @@ -1834,26 +1754,25 @@ def _update_heatmap_chart(self): sound_types=sound_filter ) + print(f"[DEBUG] Heatmap data: {len(data) if data else 0} items", flush=True) + if not data: self._show_no_data(self.heatmap_canvas) return - # Create 24 hours x 7 days matrix heatmap_data = np.zeros((24, 7)) - # Fill matrix with counts for row in data: hour = int(row['hour_of_day']) day = int(row['day_of_week']) count = row['count'] heatmap_data[hour, day] += count + self.heatmap_canvas.figure.clear() ax = self.heatmap_canvas.figure.add_subplot(111) - # Create heatmap using imshow with green colormap - im = ax.imshow(heatmap_data, cmap='Greens', aspect='auto', interpolation='nearest') + im = ax.imshow(heatmap_data, cmap='GnBu', aspect='auto', interpolation='nearest') - # Set ticks ax.set_xticks(range(7)) ax.set_xticklabels(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], fontsize=8) ax.set_yticks(range(0, 24, 2)) @@ -1862,29 +1781,28 @@ def _update_heatmap_chart(self): ax.set_xlabel('Day of Week', fontsize=9, fontweight='bold') ax.set_ylabel('Hour of Day', fontsize=9, fontweight='bold') - # Add colorbar cbar = self.heatmap_canvas.figure.colorbar(im, ax=ax, pad=0.02) cbar.set_label('Detections', fontsize=8) cbar.ax.tick_params(labelsize=7) - # Add text annotations for non-zero values for i in range(24): for j in range(7): if heatmap_data[i, j] > 0: text_color = 'white' if heatmap_data[i, j] > heatmap_data.max() * 0.5 else 'black' ax.text(j, i, int(heatmap_data[i, j]), - ha="center", va="center", color=text_color, fontsize=6, fontweight='bold') + ha="center", va="center", color=text_color, fontsize=6, fontweight='bold') self.heatmap_canvas.figure.tight_layout() self.heatmap_canvas.draw() + print("[DEBUG] Heatmap chart drawn successfully", flush=True) + except Exception as e: - print(f"[AnalyticsDashboard] Heatmap chart error: {e}", flush=True) + print(f"[ERROR] Heatmap chart error: {e}", flush=True) import traceback traceback.print_exc() self._show_no_data(self.heatmap_canvas) def _update_correlation_chart(self): - """Update correlation heatmap - shows relationships between sound types""" try: sound_filter = self.current_sound_types if self.current_sound_types else None data = self.api.get_audio_correlations( @@ -1892,34 +1810,23 @@ def _update_correlation_chart(self): sound_types=sound_filter ) - print(f"[AnalyticsDashboard] Correlation data received: {len(data) if data else 0} rows", flush=True) - if data and len(data) > 0: - print(f"[AnalyticsDashboard] Sample data: {data[:3]}", flush=True) + print(f"[DEBUG] Correlation data: {len(data) if data else 0} items", flush=True) if not data or len(data) < 1: - print("[AnalyticsDashboard] No correlation data - showing 'No Data'", flush=True) self._show_no_data(self.correlation_canvas) return - # Build data structure without pandas - # Find all time buckets and sound types time_buckets = sorted(list(set(row['time_bucket'] for row in data))) sound_types = sorted(list(set(row['sound_type'] for row in data))) - print(f"[AnalyticsDashboard] Found {len(time_buckets)} time buckets, {len(sound_types)} sound types", flush=True) - print(f"[AnalyticsDashboard] Sound types: {sound_types}", flush=True) - if len(time_buckets) < 2 or len(sound_types) < 2: - print(f"[AnalyticsDashboard] Not enough data for correlation: {len(time_buckets)} buckets, {len(sound_types)} types", flush=True) self._show_no_data(self.correlation_canvas) return - # Create matrix: rows=time_buckets, cols=sound_types n_times = len(time_buckets) n_sounds = len(sound_types) data_matrix = np.zeros((n_times, n_sounds)) - # Fill the matrix time_idx = {t: i for i, t in enumerate(time_buckets)} sound_idx = {s: i for i, s in enumerate(sound_types)} @@ -1928,39 +1835,27 @@ def _update_correlation_chart(self): s_idx = sound_idx[row['sound_type']] data_matrix[t_idx, s_idx] = row['detection_count'] - print(f"[AnalyticsDashboard] Data matrix shape: {data_matrix.shape}", flush=True) - - # Calculate correlation matrix using numpy corr_matrix = np.corrcoef(data_matrix.T) - - # Handle NaN values (if columns have zero standard deviation) corr_matrix = np.nan_to_num(corr_matrix, nan=0.0) - print(f"[AnalyticsDashboard] Correlation matrix shape: {corr_matrix.shape}", flush=True) - - # Create the plot + self.correlation_canvas.figure.clear() ax = self.correlation_canvas.figure.add_subplot(111) - # Create heatmap with green colormap only - im = ax.imshow(corr_matrix, cmap='Greens', aspect='auto', vmin=-1, vmax=1) + im = ax.imshow(corr_matrix, cmap='Blues', aspect='auto', vmin=-1, vmax=1) - # Set labels ax.set_xticks(range(len(sound_types))) ax.set_yticks(range(len(sound_types))) ax.set_xticklabels(sound_types, rotation=45, ha='right', fontsize=7) ax.set_yticklabels(sound_types, fontsize=7) - # Add correlation values inside cells for i in range(len(sound_types)): for j in range(len(sound_types)): value = corr_matrix[i, j] - # Use white text for dark backgrounds (high correlation) text_color = 'white' if value > 0.5 else 'black' ax.text(j, i, f'{value:.2f}', - ha='center', va='center', - color=text_color, fontsize=6, fontweight='bold') + ha='center', va='center', + color=text_color, fontsize=6, fontweight='bold') - # Add colorbar cbar = self.correlation_canvas.figure.colorbar(im, ax=ax, fraction=0.046, pad=0.04) cbar.set_label('Correlation Strength', rotation=270, labelpad=15, fontsize=8) @@ -1968,15 +1863,93 @@ def _update_correlation_chart(self): fontsize=9, fontweight='bold', pad=10) self.correlation_canvas.figure.tight_layout() - self.correlation_canvas.draw() - - print("[AnalyticsDashboard] Correlation chart updated successfully", flush=True) + self.correlation_canvas.draw() + print("[DEBUG] Correlation chart drawn successfully", flush=True) except Exception as e: - print(f"[AnalyticsDashboard] Correlation chart error: {e}", flush=True) + print(f"[ERROR] Correlation chart error: {e}", flush=True) import traceback traceback.print_exc() self._show_no_data(self.correlation_canvas) + +# ========================================================== +# Sound2 View - Displays Grafana dashboard +# ========================================================== +class Sound2View(QWidget): + def __init__(self, api: DashboardApi, parent=None): + super().__init__(parent) + self.api = api + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + header = QHBoxLayout() + + title = QLabel("🌿 Ultrasonic Plant Predictions Dashboard") + title.setStyleSheet(""" + font-size: 18px; + font-weight: bold; + padding: 10px; + color: #2C3E50; + """) + header.addWidget(title) + + header.addStretch() + + refresh_btn = QPushButton("🔄 Refresh") + refresh_btn.setStyleSheet(""" + QPushButton { + background-color: #3498DB; + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + font-weight: bold; + } + QPushButton:hover { + background-color: #2980B9; + } + """) + refresh_btn.clicked.connect(self.refresh_dashboard) + header.addWidget(refresh_btn) + + layout.addLayout(header) + + self.web_view = QWebEngineView() + + grafana_url = ( + "http://grafana:3000/d/ultrasonic-predictions/" + "ultrasonic-plant-predictions" + "?orgId=1&refresh=5s&kiosk=tv&theme=light" + ) + + self.web_view.setUrl(QUrl(grafana_url)) + layout.addWidget(self.web_view) + + self.status_label = QLabel("📊 Loading dashboard...") + self.status_label.setStyleSheet(""" + height: 24px; + padding: 5px; + color: #7F8C8D; + font-size: 12px; + """) + layout.addWidget(self.status_label) + + self.web_view.loadFinished.connect(self.on_load_finished) + + def refresh_dashboard(self): + self.status_label.setText("🔄 Refreshing dashboard...") + self.web_view.reload() + + def on_load_finished(self, success: bool): + if success: + self.status_label.setText("✓ Dashboard loaded successfully | Refreshes every 5s") + else: + self.status_label.setText( + "⚠ Failed to load dashboard. Please check Grafana server." + ) # ========================================================== @@ -2013,18 +1986,16 @@ def __init__(self, api=None, parent=None): QTabBar::tab:hover { background: #e1e4e8; } """) - self.map_tab = ImageMapView() + self.map_tab = ImageMapView(api=self.api) self.env_tab = RecordingsTab(recording_type="audio", api=self.api) self.plant_tab = RecordingsTab(recording_type="ultrasound", api=self.api) - + self.dashboard_tab = Sound2View(api=self.api) + self.analytics_tab = SoundAnalyticsView(api=self.api) - # Add Analytics Dashboard tab if API is provided - if self.api: - self.analytics_tab = AnalyticsDashboard(self.api) - self.tabs.addTab(self.analytics_tab, "📊 Analytics Dashboard") - self.tabs.addTab(self.map_tab, "🗺️ Interactive Map") self.tabs.addTab(self.env_tab, "🎵 Environment Sounds") self.tabs.addTab(self.plant_tab, "🌿 Plant Ultrasounds") + self.tabs.addTab(self.dashboard_tab, "📊 Ultrasonic Dashboard") + self.tabs.addTab(self.analytics_tab, "📈 Sound Analytics") - layout.addWidget(self.tabs) \ No newline at end of file + layout.addWidget(self.tabs) diff --git a/RelDB/build_tables/loader.sql b/RelDB/build_tables/loader.sql index 4eaab5914..5d2b954d5 100644 --- a/RelDB/build_tables/loader.sql +++ b/RelDB/build_tables/loader.sql @@ -1,24 +1,49 @@ -- Extended synthetic data loader for schema_extended_v2.sql -- Insert devices -INSERT INTO devices (device_id, model, owner, active) VALUES - ('dev-a','drone-x','TeamA',true), - ('dev-b','drone-x','TeamA',true), - ('dev-c','rover-y','TeamB',true), - ('dev-d','rover-y','TeamB',true), - ('dev-e','sensor-z','TeamC',true), - ('dev-f','sensor-z','TeamC',true), +INSERT INTO devices (device_id, model, owner, active, location_lat, location_lon) VALUES + ('dev-a','drone-x','TeamA',true,NULL,NULL), + ('dev-b','drone-x','TeamA',true,NULL,NULL), + ('dev-c','rover-y','TeamB',true,NULL,NULL), + ('dev-d','rover-y','TeamB',true,NULL,NULL), + ('dev-e','sensor-z','TeamC',true,NULL,NULL), + ('dev-f','sensor-z','TeamC',true,NULL,NULL), ('dev-g','ground-l','TeamD',true,NULL,NULL), ('dev-h','ground-l','TeamD',true,NULL,NULL), ('dev-i','ground-l','TeamD',true,NULL,NULL), ('dev-j','ground-l','TeamD',true,NULL,NULL), - ('dev-k','ground-l','TeamD',true,NULL,NULL) - ('mic-1','sound-a','TeamD',true), - ('mic-2','sound-a','TeamD',true), - ('mic-33','sound-a','TeamD',true), - ('mic-u-2','sound-ul','TeamD',true) + ('dev-k','ground-l','TeamD',true,NULL,NULL), + ('mic-1','sound-a','TeamD',true,NULL,NULL), + ('mic-2','sound-a','TeamD',true,NULL,NULL), + ('mic-33','sound-a','TeamD',true,NULL,NULL), + ('mic-u-2','sound-ul','TeamD',true,NULL,NULL) ON CONFLICT DO NOTHING; +-- Seed data for devices_sensor table +-- This file is automatically executed during database initialization + +INSERT INTO devices_sensor (id, plant_id, sensor_type, last_seen) VALUES + ('1', 101, 'temperature', NOW()), + ('2', 101, 'humidity', NOW()), + ('3', 101, 'soil_moisture', NOW()), + ('4', 102, 'co2', NOW()), + ('5', 102, 'light_intensity', NOW()), + ('6', 103, 'rainfall', NOW()), + ('7', 103, 'ph', NOW()), + ('8', 104, 'temperature', NOW()), + ('9', 104, 'humidity', NOW()), + ('10', 105, 'soil_moisture', NOW()), + ('11', 105, 'co2', NOW()), + ('12', 106, 'light_intensity', NOW()), + ('13', 106, 'wind_speed', NOW()), + ('14', 107, 'ph', NOW()), + ('15', 107, 'temperature', NOW()), + ('16', 107, 'ph', NOW()), + ('17', 107, 'temperature', NOW()) +ON CONFLICT (id) DO UPDATE SET + plant_id = EXCLUDED.plant_id, + sensor_type = EXCLUDED.sensor_type, + last_seen = NOW(); -- Insert some regions INSERT INTO regions (name, geom) diff --git a/RelDB/build_tables/schema.sql b/RelDB/build_tables/schema.sql index a39306dda..a6f9892ed 100644 --- a/RelDB/build_tables/schema.sql +++ b/RelDB/build_tables/schema.sql @@ -573,13 +573,32 @@ CREATE TABLE fruit_detections ( CREATE INDEX idx_fruit_original_key ON fruit_detections(original_key); CREATE INDEX idx_fruit_device_ts ON fruit_detections(device_id, timestamp); + CREATE TABLE IF NOT EXISTS public.field_polygons ( id SERIAL PRIMARY KEY, - gis geometry(Point, 4326) NOT NULL, + + gis_origin JSONB, + + gis geometry(Point, 4326) + GENERATED ALWAYS AS ( + ST_SetSRID( + ST_MakePoint( + (gis_origin ->> 'longitude')::DOUBLE PRECISION, + (gis_origin ->> 'latitude')::DOUBLE PRECISION + ), + 4326 + ) + ) STORED, + boundary geometry(Polygon, 4326) NOT NULL, - area_sq_m DOUBLE PRECISION GENERATED ALWAYS AS ( - ST_Area(geography(boundary)) - ) STORED, + + area_sq_m DOUBLE PRECISION + GENERATED ALWAYS AS ( + ST_Area(geography(boundary)) + ) STORED, + + pixel_points JSONB, + created_at TIMESTAMP DEFAULT NOW() ); @@ -732,10 +751,6 @@ CREATE INDEX IF NOT EXISTS ix_sensor_zone_stats_zone_window CREATE INDEX IF NOT EXISTS ix_sensor_zone_stats_anomalies ON public.sensor_zone_stats (anomalies); -CREATE INDEX IF NOT EXISTS ix_sensors_name ON sensors (sensor_name); -CREATE INDEX IF NOT EXISTS ix_sensors_type ON sensors (sensor_type); -CREATE INDEX IF NOT EXISTS ix_sensors_status ON sensors (status); -CREATE INDEX IF NOT EXISTS ix_sensors_location ON sensors (location_lat, location_lon); -- Spatial CREATE INDEX IF NOT EXISTS ix_missions_area_geom_gist ON missions USING GIST (area_geom); @@ -872,6 +887,11 @@ CREATE TABLE IF NOT EXISTS public.sensors ( water_usage_efficiency DOUBLE PRECISION ); +CREATE INDEX IF NOT EXISTS ix_sensors_name ON sensors (sensor_name); +CREATE INDEX IF NOT EXISTS ix_sensors_type ON sensors (sensor_type); +CREATE INDEX IF NOT EXISTS ix_sensors_status ON sensors (status); +CREATE INDEX IF NOT EXISTS ix_sensors_location ON sensors (location_lat, location_lon); + CREATE TABLE IF NOT EXISTS public.sensors_anomalies_modal ( id BIGSERIAL PRIMARY KEY, sensor_id INT NOT NULL REFERENCES sensors(sensor_id) ON DELETE CASCADE, diff --git a/RelDB/graphs/postgres-queries.yml b/RelDB/graphs/postgres-queries.yml index 4d3b58440..37143e0d4 100644 --- a/RelDB/graphs/postgres-queries.yml +++ b/RelDB/graphs/postgres-queries.yml @@ -281,3 +281,356 @@ leaf_disease_severity_by_device: - sick_percentage: usage: "GAUGE" description: "Percentage of sick reports (0-100)" + + +# ============================================ +# ULTRASONIC PLANT PREDICTIONS METRICS +# S3 Path format: s3://sound/plants/microphone-MIC-02/2025-11-13/1763018877231/MIC-U-01_20251113T073457Z.wav +# ============================================ + +ultrasonic_predictions_total: + query: "SELECT COUNT(*)::float as total FROM public.ultrasonic_plant_predictions" + master: true + metrics: + - total: + usage: "GAUGE" + description: "Total number of ultrasonic predictions" + +ultrasonic_predictions_by_class: + query: | + SELECT + CASE predicted_class + WHEN 'Healthy_Tomato' THEN 'Healthy_Plant' + WHEN 'Healthy_Tobacco' THEN 'Healthy_Plant' + + WHEN 'Drought_Tomato' THEN 'Drought_Plant' + WHEN 'Drought_Tobacco' THEN 'Drought_Plant' + + WHEN 'Pest_Tomato' THEN 'Pest_Plant' + WHEN 'Pest_Tobacco' THEN 'Pest_Plant' + + ELSE COALESCE(predicted_class, 'unknown') + END as predicted_class, + COUNT(*)::float as count + FROM public.ultrasonic_plant_predictions + GROUP BY 1 + master: true + metrics: + - predicted_class: + usage: "LABEL" + description: "Predicted class (Drought_Plant, Healthy_Plant, Pest_Plant, Control, etc.)" + - count: + usage: "GAUGE" + description: "Number of predictions per class" + +ultrasonic_predictions_by_watering: + query: | + SELECT + COALESCE(watering_status, 'unknown') as watering_status, + COUNT(*)::float as count + FROM public.ultrasonic_plant_predictions + GROUP BY watering_status + master: true + metrics: + - watering_status: + usage: "LABEL" + description: "Watering status (Yes, No, etc.)" + - count: + usage: "GAUGE" + description: "Number of predictions per watering status" + +ultrasonic_predictions_by_status: + query: | + SELECT + COALESCE(status, 'unknown') as status, + COUNT(*)::float as count + FROM public.ultrasonic_plant_predictions + GROUP BY status + master: true + metrics: + - status: + usage: "LABEL" + description: "Record status (Success, Error, etc.)" + - count: + usage: "GAUGE" + description: "Number of predictions per status" + +ultrasonic_predictions_avg_confidence: + query: | + SELECT + AVG(confidence)::float as avg_confidence + FROM public.ultrasonic_plant_predictions + WHERE confidence IS NOT NULL + AND TO_TIMESTAMP( + SUBSTRING( + REGEXP_REPLACE(file, '^.*/([^/]+)$', '\1') + FROM 'MIC-U-\d+_(\d{8}T\d{6})Z' + ), + 'YYYYMMDD"T"HH24MISS' + ) > NOW() - INTERVAL '7 days' + master: true + metrics: + - avg_confidence: + usage: "GAUGE" + description: "Average confidence score of predictions (Last 7 days)" + +ultrasonic_predictions_recent: + query: | + SELECT + COUNT(*)::float as recent_count + FROM ( + SELECT 1 + FROM public.ultrasonic_plant_predictions + ORDER BY prediction_time DESC + LIMIT 100 + ) sub + master: true + metrics: + - recent_count: + usage: "GAUGE" + description: "Count of recent predictions (last 100)" + +ultrasonic_confidence_by_sensor: + query: | + SELECT + SUBSTRING(REGEXP_REPLACE(file, '^.*/([^/]+)$', '\1') FROM '^(MIC-U-\d+)_') as sensor_id, + AVG(confidence)::float as avg_confidence, + COUNT(*)::float as sample_count, + MIN(confidence)::float as min_confidence, + MAX(confidence)::float as max_confidence + FROM public.ultrasonic_plant_predictions + WHERE status = 'Success' + AND confidence IS NOT NULL + AND file ~ 'MIC-U-\d+_\d{8}T\d{6}Z\.wav$' + GROUP BY SUBSTRING(REGEXP_REPLACE(file, '^.*/([^/]+)$', '\1') FROM '^(MIC-U-\d+)_') + ORDER BY avg_confidence DESC + master: true + metrics: + - sensor_id: + usage: "LABEL" + description: "Sensor ID extracted from file (e.g., MIC-U-01, MIC-U-02)" + - avg_confidence: + usage: "GAUGE" + description: "Average confidence level per sensor" + - sample_count: + usage: "GAUGE" + description: "Number of successful samples per sensor" + - min_confidence: + usage: "GAUGE" + description: "Minimum confidence per sensor" + - max_confidence: + usage: "GAUGE" + description: "Maximum confidence per sensor" + +ultrasonic_daily_stress_count: + query: | + SELECT + DATE_TRUNC('hour', + TO_TIMESTAMP( + SUBSTRING( + REGEXP_REPLACE(file, '^.*/([^/]+)$', '\1') + FROM 'MIC-U-\d+_(\d{8}T\d{6})Z' + ), + 'YYYYMMDD"T"HH24MISS' + ) + ) as event_time, + EXTRACT(EPOCH FROM DATE_TRUNC('hour', + TO_TIMESTAMP( + SUBSTRING( + REGEXP_REPLACE(file, '^.*/([^/]+)$', '\1') + FROM 'MIC-U-\d+_(\d{8}T\d{6})Z' + ), + 'YYYYMMDD"T"HH24MISS' + ) + ))::float as date_timestamp, + COUNT(*)::float as event_count + FROM public.ultrasonic_plant_predictions + WHERE predicted_class IN ('Drought_Tomato', 'Pest_Tomato') + AND status = 'Success' + AND TO_TIMESTAMP( + SUBSTRING( + REGEXP_REPLACE(file, '^.*/([^/]+)$', '\1') + FROM 'MIC-U-\d+_(\d{8}T\d{6})Z' + ), + 'YYYYMMDD"T"HH24MISS' + ) >= DATE_TRUNC('day', NOW()) + GROUP BY DATE_TRUNC('hour', + TO_TIMESTAMP( + SUBSTRING( + REGEXP_REPLACE(file, '^.*/([^/]+)$', '\1') + FROM 'MIC-U-\d+_(\d{8}T\d{6})Z' + ), + 'YYYYMMDD"T"HH24MISS' + ) + ) + ORDER BY date_timestamp DESC + master: true + metrics: + - event_time: + usage: "LABEL" + description: "Hour of stress event extracted from file name" + - date_timestamp: + usage: "GAUGE" + description: "Hour timestamp (Unix epoch)" + - event_count: + usage: "GAUGE" + description: "Number of stress events per hour (Today)" + +ultrasonic_success_rate_by_sensor: + query: | + SELECT + SUBSTRING(REGEXP_REPLACE(file, '^.*/([^/]+)$', '\1') FROM '^(MIC-U-\d+)_') as sensor_id, + SUM(CASE WHEN status = 'Success' THEN 1.0 ELSE 0.0 END) / + NULLIF(COUNT(*), 0) * 100.0 as success_rate + FROM public.ultrasonic_plant_predictions + WHERE file ~ 'MIC-U-\d+_\d{8}T\d{6}Z\.wav$' + GROUP BY SUBSTRING(REGEXP_REPLACE(file, '^.*/([^/]+)$', '\1') FROM '^(MIC-U-\d+)_') + master: true + metrics: + - sensor_id: + usage: "LABEL" + description: "Sensor ID" + - success_rate: + usage: "GAUGE" + description: "Success rate percentage per sensor" + +ultrasonic_class_distribution_healthy: + query: | + SELECT + COUNT(*) as healthy_count, + COUNT(*) * 100.0 / + NULLIF((SELECT COUNT(*) FROM public.ultrasonic_plant_predictions WHERE status = 'Success'), 0) + as healthy_percentage + FROM public.ultrasonic_plant_predictions + WHERE predicted_class IN ('Healthy_Tomato', 'Healthy_Tobacco', 'Control_Greenhouse', 'Control_Empty') AND status = 'Success' + master: true + metrics: + - healthy_count: + usage: "GAUGE" + description: "Count of healthy plant predictions" + - healthy_percentage: + usage: "GAUGE" + description: "Percentage of healthy plant predictions" + +ultrasonic_class_distribution_stress: + query: | + SELECT + COUNT(*) as stress_count, + COUNT(*) * 100.0 / + NULLIF((SELECT COUNT(*) FROM public.ultrasonic_plant_predictions WHERE status = 'Success'), 0) + as stress_percentage + FROM public.ultrasonic_plant_predictions + WHERE predicted_class IN ('Drought_Tomato', 'Pest_Tomato') AND status = 'Success' + master: true + metrics: + - stress_count: + usage: "GAUGE" + description: "Count of stress plant predictions" + - stress_percentage: + usage: "GAUGE" + description: "Percentage of stress plant predictions" + +ultrasonic_confidence_distribution: + query: | + SELECT + CASE + WHEN confidence >= 0.9 THEN '90-100% (High)' + WHEN confidence >= 0.75 THEN '75-90% (Good)' + WHEN confidence >= 0.6 THEN '60-75% (Medium)' + ELSE '< 60% (Low)' + END as confidence_range, + COUNT(*)::float as count + FROM public.ultrasonic_plant_predictions + WHERE status = 'Success' + GROUP BY confidence_range + ORDER BY confidence_range DESC + master: true + metrics: + - confidence_range: + usage: "LABEL" + description: "Confidence level range" + - count: + usage: "GAUGE" + description: "Number of predictions in confidence range" + +ultrasonic_sensor_activity_timeline: + query: | + SELECT + SUBSTRING(REGEXP_REPLACE(file, '^.*/([^/]+)$', '\1') FROM '^(MIC-U-\d+)_') as sensor_id, + TO_CHAR(DATE_TRUNC('day', + TO_TIMESTAMP( + SUBSTRING( + REGEXP_REPLACE(file, '^.*/([^/]+)$', '\1') + FROM 'MIC-U-\d+_(\d{8}T\d{6})Z' + ), + 'YYYYMMDD"T"HH24MISS' + ) + ), 'YYYY-MM-DD') as activity_date, + COUNT(*)::float as daily_readings + FROM public.ultrasonic_plant_predictions + WHERE status = 'Success' + AND file ~ 'MIC-U-\d+_\d{8}T\d{6}Z\.wav$' + GROUP BY + SUBSTRING(REGEXP_REPLACE(file, '^.*/([^/]+)$', '\1') FROM '^(MIC-U-\d+)_'), + DATE_TRUNC('day', + TO_TIMESTAMP( + SUBSTRING( + REGEXP_REPLACE(file, '^.*/([^/]+)$', '\1') + FROM 'MIC-U-\d+_(\d{8}T\d{6})Z' + ), + 'YYYYMMDD"T"HH24MISS' + ) + ) + ORDER BY activity_date DESC, sensor_id + master: true + metrics: + - sensor_id: + usage: "LABEL" + description: "Sensor ID" + - activity_date: + usage: "LABEL" + description: "Activity date extracted from file name" + - daily_readings: + usage: "GAUGE" + description: "Daily reading count per sensor" + +ultrasonic_predictions_per_hour: + query: | + SELECT + EXTRACT(EPOCH FROM DATE_TRUNC('hour', + TO_TIMESTAMP( + SUBSTRING( + REGEXP_REPLACE(file, '^.*/([^/]+)$', '\1') + FROM 'MIC-U-\d+_(\d{8}T\d{6})Z' + ), + 'YYYYMMDD"T"HH24MISS' + ) + ))::float as hour_timestamp, + COUNT(*)::float as count + FROM public.ultrasonic_plant_predictions + WHERE TO_TIMESTAMP( + SUBSTRING( + REGEXP_REPLACE(file, '^.*/([^/]+)$', '\1') + FROM 'MIC-U-\d+_(\d{8}T\d{6})Z' + ), + 'YYYYMMDD"T"HH24MISS' + ) >= DATE_TRUNC('day', NOW()) + AND file ~ 'MIC-U-\d+_\d{8}T\d{6}Z\.wav$' + GROUP BY DATE_TRUNC('hour', + TO_TIMESTAMP( + SUBSTRING( + REGEXP_REPLACE(file, '^.*/([^/]+)$', '\1') + FROM 'MIC-U-\d+_(\d{8}T\d{6})Z' + ), + 'YYYYMMDD"T"HH24MISS' + ) + ) + ORDER BY hour_timestamp DESC + master: true + metrics: + - hour_timestamp: + usage: "LABEL" + description: "Hour timestamp (Unix epoch) extracted from file name" + - count: + usage: "GAUGE" + description: "Predictions count per hour (Today's Events)" diff --git a/docker-compose.yml b/docker-compose.yml index a46c35bda..cbd29fa83 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -871,7 +871,7 @@ services: - MINIO_ENDPOINT=minio-hot:9000 - ACCESS_KEY=minioadmin - SECRET_KEY=minioadmin123 - - BUCKET_NAME=imagery + - BUCKET_NAME=sound depends_on: minio-hot: condition: service_healthy @@ -1420,7 +1420,7 @@ services: build: context: ./services/sensorAnomalyPro dockerfile: Dockerfile.flink - container_name: jobmanager + container_name: sensoers_jobmanager command: > bash -c " /docker-entrypoint.sh jobmanager & @@ -1432,13 +1432,13 @@ services: sleep 5; done && echo '✅ Reports ready, submitting Flink job...' && - flink run -m localhost:8091 -py /opt/app/sensorAnomalyPro/app.py && + flink run -m localhost:8081 -py /opt/app/sensorAnomalyPro/app.py && tail -f /dev/null" depends_on: - sensor_anomaly_pro - kafka ports: - - "8091:8091" + - "8900:8081" environment: - JOB_MANAGER_RPC_ADDRESS=jobmanager - KAFKA_BROKERS=kafka:9092 @@ -1455,7 +1455,7 @@ services: build: context: ./services/sensorAnomalyPro dockerfile: Dockerfile.flink - container_name: taskmanager + container_name: sensors_taskmanager command: taskmanager -D taskmanager.numberOfTaskSlots=4 depends_on: - jobmanager @@ -1472,7 +1472,6 @@ services: networks: - ag_cloud - vector_service: build: ./services/vector_service @@ -1560,7 +1559,7 @@ services: air-jobmanager: build: - context: . + context: ./services/air dockerfile: Dockerfile.flink container_name: air-jobmanager command: jobmanager @@ -1926,3 +1925,61 @@ services: networks: - ag_cloud restart: unless-stopped + + + jobmanager-img: + build: + context: ./services/flink_parts_img + dockerfile: Dockerfile.flink + container_name: flink-jobmanager-img + command: ["jobmanager"] + ports: + - "8055:8081" + environment: + - JOB_MANAGER_RPC_ADDRESS=jobmanager-img + - KAFKA_BROKERS=kafka:9092 + volumes: + - ./secrets:/app/secrets:ro + networks: + - ag_cloud + + taskmanager-img: + build: + context: ./services/flink_parts_img + dockerfile: Dockerfile.flink + container_name: flink-taskmanager-img + command: ["taskmanager", "-D", "taskmanager.numberOfTaskSlots=4"] + depends_on: + - jobmanager-img + environment: + - JOB_MANAGER_RPC_ADDRESS=jobmanager-img + - KAFKA_BROKERS=kafka:9092 + - MINIO_ENDPOINT=minio-hot:9000 + - MINIO_ACCESS_KEY=minioadmin + - MINIO_SECRET_KEY=minioadmin123 + - MINIO_BUCKET=imagery + volumes: + - ./secrets:/app/secrets:ro + networks: + - ag_cloud + + + jobsubmit-img: + build: + context: ./services/flink_parts_img + dockerfile: Dockerfile.flink + container_name: flink-jobsubmit-img + depends_on: + - jobmanager-img + command: > + bash -c " + echo 'Waiting for JobManager...'; + sleep 12; + echo 'Submitting job...'; + /opt/flink/bin/flink run -m jobmanager-img:8081 -py /opt/app/job_with_stitch.py + " + # volumes: + # - ./usrlib:/opt/flink/usrlib:ro + # - ./secrets:/app/secrets:ro + networks: + - ag_cloud diff --git a/mqtt_and_kafka/Sensor_edge_device/Dockerfile.edge b/mqtt_and_kafka/Sensor_edge_device/Dockerfile.edge index 71eebac1f..7db016068 100644 --- a/mqtt_and_kafka/Sensor_edge_device/Dockerfile.edge +++ b/mqtt_and_kafka/Sensor_edge_device/Dockerfile.edge @@ -14,11 +14,15 @@ ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \ PIP_DISABLE_PIP_VERSION_CHECK=1 \ PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 -# ---------- Copy simulation scripts ---------- -COPY ./run_sim.py ./fill_system_with_fake_data.py ./Crop_recommendationV2.csv ./place.csv ./ # ---------- Install dependencies ---------- -RUN pip install --no-cache-dir paho-mqtt pandas +RUN pip install --no-cache-dir \ + --trusted-host pypi.org \ + --trusted-host files.pythonhosted.org \ + paho-mqtt pandas + +# ---------- Copy simulation scripts ---------- +COPY ./run_sim.py ./fill_system_with_fake_data.py ./Crop_recommendationV2.csv ./place.csv ./ # ---------- Default command ---------- CMD ["python", "run_sim.py"] diff --git a/mqtt_and_kafka/Sensor_edge_device/fill_system_with_fake_data.py b/mqtt_and_kafka/Sensor_edge_device/fill_system_with_fake_data.py index 2297eba38..4237bfc9f 100644 --- a/mqtt_and_kafka/Sensor_edge_device/fill_system_with_fake_data.py +++ b/mqtt_and_kafka/Sensor_edge_device/fill_system_with_fake_data.py @@ -18,7 +18,7 @@ def generate_sensor_data(): data = { "sid": f"sensor-{sensor_id}", - "id": sensor_id, + "sensor_id": sensor_id, "timestamp": datetime.now(timezone.utc).isoformat(), "msg_type": "telemetry", "value": value, diff --git a/mqtt_and_kafka/Sensor_edge_device/run_sim.py b/mqtt_and_kafka/Sensor_edge_device/run_sim.py index f272d8e61..74e4bb8b7 100644 --- a/mqtt_and_kafka/Sensor_edge_device/run_sim.py +++ b/mqtt_and_kafka/Sensor_edge_device/run_sim.py @@ -6,7 +6,7 @@ BROKER = "mosquitto" PORT = 1883 -TOPIC = "sensors" +TOPIC = "mqtt/sensors" def main(): print("🚀 Simulation started... publishing sensor data to MQTT") diff --git a/mqtt_and_kafka/mqtt-router/Dockerfile b/mqtt_and_kafka/mqtt-router/Dockerfile index 3ec5173ee..55297f563 100644 --- a/mqtt_and_kafka/mqtt-router/Dockerfile +++ b/mqtt_and_kafka/mqtt-router/Dockerfile @@ -28,7 +28,7 @@ ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \ # ---- Install Python deps ---- COPY requirements.txt . -# When behind NetFree, trusted-host can help even אם אין צורך זה לא מזיק: +# When behind NetFree, trusted-host can help even RUN python -m pip install --no-cache-dir \ --trusted-host pypi.org --trusted-host files.pythonhosted.org \ -r requirements.txt diff --git a/services/Cross-Sensor System-Level Anomalies/Dockerfile.flink b/services/Cross-Sensor System-Level Anomalies/Dockerfile.flink index f836a9c79..f599dbce7 100644 --- a/services/Cross-Sensor System-Level Anomalies/Dockerfile.flink +++ b/services/Cross-Sensor System-Level Anomalies/Dockerfile.flink @@ -4,19 +4,26 @@ FROM flink:1.19.3-scala_2.12-java11 USER root -RUN apt-get update \ - && apt-get install -y --no-install-recommends python3 python3-venv python3-pip ca-certificates curl libgomp1 \ - && rm -rf /var/lib/apt/lists/* - +COPY certs/*.crt /app/certs/ +RUN if [ -d ./certs ] && [ "$(ls ./certs/*.crt 2>/dev/null)" ]; then \ + echo "Configuring NetFree certificates..."; \ + cp ./certs/*.crt /usr/local/share/ca-certificates/; \ + update-ca-certificates; \ + fi +ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt +ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt +ENV PIP_CERT=/etc/ssl/certs/ca-certificates.crt + +RUN apt-get update && apt-get install -y python3 python3-venv python3-pip RUN curl -fSL https://repo1.maven.org/maven2/org/apache/kafka/kafka-clients/3.7.0/kafka-clients-3.7.0.jar \ -o /opt/flink/lib/kafka-clients-3.7.0.jar - RUN python3 -m venv /opt/venv ENV PATH="/opt/venv/bin:${PATH}" +RUN printf "[global]\ntrusted-host = pypi.org\n files.pythonhosted.org\ncert = /etc/ssl/certs/ca-certificates.crt\n" > /etc/pip.conf RUN python -m pip install --no-cache-dir --upgrade pip setuptools wheel RUN apt-get update && apt-get install -y \ diff --git a/services/Cross-Sensor System-Level Anomalies/dockerfile b/services/Cross-Sensor System-Level Anomalies/dockerfile index 9626ca2b5..6c15f7432 100644 --- a/services/Cross-Sensor System-Level Anomalies/dockerfile +++ b/services/Cross-Sensor System-Level Anomalies/dockerfile @@ -1,6 +1,15 @@ # Dockerfile FROM python:3.11-slim +COPY certs/*.crt /app/certs/ +RUN if [ -d ./certs ] && [ "$(ls ./certs/*.crt 2>/dev/null)" ]; then \ + echo "Configuring NetFree certificates..."; \ + cp ./certs/*.crt /usr/local/share/ca-certificates/; \ + update-ca-certificates; \ + fi +ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt +ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt +ENV PIP_CERT=/etc/ssl/certs/ca-certificates.crt ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ diff --git a/services/Cross-Sensor System-Level Anomalies/requirements.txt b/services/Cross-Sensor System-Level Anomalies/requirements.txt index 96c97529c..f32d99731 100644 --- a/services/Cross-Sensor System-Level Anomalies/requirements.txt +++ b/services/Cross-Sensor System-Level Anomalies/requirements.txt @@ -1,4 +1,4 @@ -numpy==1.26.4 +numpy>=2.0.0 scipy>=1.11 pandas>=2.2 scikit-learn>=1.4 diff --git a/services/air/job.py b/services/air/job.py index b988ea292..e1b5509e0 100644 --- a/services/air/job.py +++ b/services/air/job.py @@ -97,7 +97,7 @@ def process_element(self, value, ctx): # === Parse Kafka message === try: data = json.loads(value) - image_key = data.get("img_key") + image_key = data.get("Key") if not image_key: logger.warning("⚠️ Missing 'key' in Kafka message") return [] @@ -155,7 +155,7 @@ def process_element(self, value, ctx): # === Step 3.1: Upload mask to MinIO === try: bucket_name = os.getenv("MINIO_BUCKET", "imagery") - mask_object_key = f"air_mask/{mask_filename}" + mask_object_key = f"image/air_mask/{mask_filename}" upload_to_minio(mask_path, bucket_name, mask_object_key) logger.info(f"☁️ Uploaded mask to MinIO at {bucket_name}/{mask_object_key}") except Exception as e: diff --git a/services/compression/src/minio_client.py b/services/compression/src/minio_client.py index 6e970881b..2133784fd 100644 --- a/services/compression/src/minio_client.py +++ b/services/compression/src/minio_client.py @@ -4,7 +4,7 @@ MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "localhost:9001") ACCESS_KEY = os.getenv("ACCESS_KEY", "minioadmin") SECRET_KEY = os.getenv("SECRET_KEY", "minioadmin123") -BUCKET_NAME = os.getenv("BUCKET_NAME", "telemetry") +BUCKET_NAME = os.getenv("BUCKET_NAME", "sound") client = Minio( MINIO_ENDPOINT, diff --git a/services/compression/src/prototype_lib.py b/services/compression/src/prototype_lib.py index d2ef512af..5d9b6b32d 100644 --- a/services/compression/src/prototype_lib.py +++ b/services/compression/src/prototype_lib.py @@ -9,7 +9,7 @@ # Supported audio formats for compression AUDIO_EXTS = {".wav", ".mp3", ".flac", ".ogg", ".m4a", ".aac", ".wma", ".opus"} -RAW_PREFIX = "sound/" +RAW_PREFIX = "" def is_audio_file(filename: str) -> bool: """Check if file is an audio file that should be compressed.""" diff --git a/services/db_api_service/app/main.py b/services/db_api_service/app/main.py index 0a5c6463b..40985131c 100644 --- a/services/db_api_service/app/main.py +++ b/services/db_api_service/app/main.py @@ -10,6 +10,9 @@ from app.router import build_router from app.tables.generic import repo from .config import settings +from fastapi import WebSocket, WebSocketDisconnect +from app.ws_manager import manager +import asyncio contract_store = ContractStore(settings.CONTRACTS_DIR) repo.set_contract_store(contract_store) @@ -34,3 +37,12 @@ def ready(): app.include_router(build_router(contract_store)) +@app.websocket("/ws/aerial-updates") +async def websocket_endpoint(websocket: WebSocket): + await manager.connect(websocket) + try: + while True: + # רק כדי לשמור על החיבור חי + await asyncio.sleep(60) + except WebSocketDisconnect: + manager.disconnect(websocket) \ No newline at end of file diff --git a/services/db_api_service/app/tables/files/router.py b/services/db_api_service/app/tables/files/router.py index 7a606ff42..93af4f00c 100644 --- a/services/db_api_service/app/tables/files/router.py +++ b/services/db_api_service/app/tables/files/router.py @@ -6,11 +6,68 @@ from fastapi import APIRouter, HTTPException, Query from .schemas import FilesCreate, FilesUpdate from . import repo +import requests router = APIRouter(prefix="/files", tags=["files"]) -PUBLIC_S3_BASE = os.getenv("PUBLIC_S3_BASE") - +# PUBLIC_S3_BASE = os.getenv("PUBLIC_S3_BASE") +PUBLIC_S3_BASE = os.getenv("PUBLIC_S3_BASE", "http://localhost:9001") +MINIO_BASE = "http://minio-hot:9000" +def _check_if_compressed_in_minio(bucket: str, object_key: str) -> bool: + """ + Check in MinIO if the file is compressed (OPUS format) + Uses direct file path checking via MinIO container + """ + try: + # הסר .wav/.opus מהשם + base_key = object_key.replace('.wav', '').replace('.opus', '') + + # נסה את כל האפשרויות + possible_extensions = ['.opus', '.wav', ''] + + print(f"[DEBUG] Checking MinIO for bucket={bucket}, base={base_key}") + + for ext in possible_extensions: + test_key = f"{base_key}{ext}" + + # MinIO S3 API endpoint format + url = f"{MINIO_BASE}/{bucket}/{test_key}" + + try: + print(f"[DEBUG] HEAD request to: {url}") + + # נסה GET במקום HEAD (יותר אמין) + response = requests.get(url, timeout=3, stream=True) + + if response.status_code == 200: + is_opus = test_key.lower().endswith('.opus') + print(f"[DEBUG] ✓ Found: {url} (OPUS={is_opus})") + response.close() + return is_opus + elif response.status_code == 404: + print(f"[DEBUG] Not found (404): {url}") + else: + print(f"[DEBUG] Status {response.status_code}: {url}") + + except requests.exceptions.Timeout: + print(f"[DEBUG] Timeout for: {url}") + continue + except requests.exceptions.ConnectionError as e: + print(f"[DEBUG] Connection error for {url}: {e}") + continue + except Exception as e: + print(f"[DEBUG] Error for {url}: {e}") + continue + + print(f"[DEBUG] ✗ No file found for {bucket}/{object_key}") + return False + + except Exception as e: + print(f"[ERROR] MinIO check failed: {e}") + import traceback + traceback.print_exc() + return False + def _attach_url_if_possible(row: Dict[str, Any]) -> Dict[str, Any]: """Attach a public URL to access the file from MinIO.""" if not row: @@ -35,16 +92,12 @@ def _attach_url_if_possible(row: Dict[str, Any]) -> Dict[str, Any]: row.setdefault("url", built) return row -def _is_compressed(filename: str) -> bool: - """Check if file is compressed (OPUS format)""" - if not filename: - return False - return filename.lower().endswith('.opus') @router.post("", status_code=201) def create_or_upsert_file(payload: FilesCreate): repo.upsert_file(payload.model_dump(by_alias=True)) return {"status": "ok"} + @router.get("/audio-aggregates/", summary="List audio file aggregates (environment sounds)") def list_audio_aggregates( run_id: Optional[str] = None, @@ -74,7 +127,7 @@ def list_audio_aggregates( conditions.append("snsc.file_name ILIKE :search") params["search"] = f"%{search}%" - # Device filter (based on snsc.file_name prefix) + # Device filter if device_ids: device_list = [d.strip() for d in device_ids.split(",") if d.strip()] if device_list: @@ -83,7 +136,7 @@ def list_audio_aggregates( for i, dev in enumerate(device_list): params[f"dev_{i}"] = dev - # Date filters (use capture_time from sounds_metadata or snsc.linked_time) + # Date filters if date_from: conditions.append("COALESCE(sm.capture_time, snsc.linked_time) >= CAST(:date_from AS timestamptz)") params["date_from"] = date_from @@ -98,7 +151,7 @@ def list_audio_aggregates( sort_map = { "Date (Newest)": "COALESCE(sm.capture_time, snsc.linked_time) DESC", "Date (Oldest)": "COALESCE(sm.capture_time, snsc.linked_time) ASC", - "Length": "sm.duration_sec DESC", # if you prefer duration from sounds_metadata + "Length": "sm.duration_sec DESC", "Device": "snsc.file_name ASC", "processing_ms": "fa.processing_ms DESC", "filename": "snsc.file_name ASC", @@ -131,26 +184,36 @@ def list_audio_aggregates( params["limit"] = limit try: + print("===== SQL QUERY =====") + print(query) + print("===== PARAMS =====") + print(params) + rows = repo.db_query(query, params) results = [] for r in rows: - # build URL server-side bucket = r.get("bucket") object_key = r.get("object_key") + filename = r.get("filename", "") + url = None if bucket and object_key: - # PUBLIC_S3_BASE should be like "https://minio.example.com" url = f"{PUBLIC_S3_BASE.rstrip('/')}/{quote(bucket, safe='')}/{quote(object_key, safe='/')}" + is_compressed = False + if bucket and object_key: + is_compressed = _check_if_compressed_in_minio(bucket, object_key) + print(f"[DEBUG] File {filename}: is_compressed={is_compressed}") results.append({ "file_id": r.get("file_id"), - "filename": r.get("filename"), + "filename": filename, "predicted_label": r.get("head_pred_label"), "probability": r.get("head_pred_prob"), - "device_id": (r.get("filename") or "").split("_")[0], + "device_id": (filename or "").split("_")[0] if filename else "Unknown", "url": url, + "is_compressed": is_compressed, }) return results @@ -165,29 +228,21 @@ def list_plant_predictions( date_from: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"), date_to: Optional[str] = Query(None, description="End date (YYYY-MM-DD)"), search: Optional[str] = Query(None, description="Search by filename"), - device_ids: Optional[str] = Query(None, description="Comma-separated device IDs (if applicable)"), + device_ids: Optional[str] = Query(None, description="Comma-separated device IDs"), sort_by: Optional[str] = Query("Date (Newest)", description="Sort field"), limit: int = Query(100, ge=1, le=500), ): - """ - Returns ultrasonic plant predictions from public.ultrasonic_plant_predictions - - This endpoint serves plant stress/watering sounds (Tomato Cut, Tobacco Dry, etc.) - """ conditions = [] params: Dict[str, Any] = {} - # Filter by predicted class if predicted_class and predicted_class.lower() not in ("all signals", "all types"): conditions.append("upp.predicted_class ILIKE :pred_class") params["pred_class"] = f"%{predicted_class}%" - # Search by filename if search: conditions.append("upp.file ILIKE :search") params["search"] = f"%{search}%" - # Date filters if date_from: conditions.append("upp.prediction_time >= CAST(:date_from AS timestamptz)") params["date_from"] = date_from @@ -197,7 +252,6 @@ def list_plant_predictions( where_clause = "WHERE " + " AND ".join(conditions) if conditions else "" - # Sort mapping sort_map = { "Date (Newest)": "upp.prediction_time DESC", "Date (Oldest)": "upp.prediction_time ASC", @@ -205,7 +259,6 @@ def list_plant_predictions( } order_clause = sort_map.get(sort_by, "upp.prediction_time DESC") - # Join with files table to get bucket/key for URL construction query = f""" SELECT upp.id, @@ -236,27 +289,34 @@ def list_plant_predictions( results = [] for r in rows: + bucket = r.get("bucket") + object_key = r.get("object_key") + filename = r.get("file", "") + url = None - if r.get("bucket") and r.get("object_key"): - bucket = str(r["bucket"]) - key = str(r["object_key"]) - url = f"{PUBLIC_S3_BASE.rstrip('/')}/{quote(bucket, safe='')}/{quote(key, safe='/')}" + if bucket and object_key: + url = f"{PUBLIC_S3_BASE.rstrip('/')}/{quote(bucket, safe='')}/{quote(object_key, safe='/')}" + + is_compressed = False + if bucket and object_key: + is_compressed = _check_if_compressed_in_minio(bucket, object_key) + results.append({ "id": r.get("id"), - "file": r.get("file"), + "file": filename, "predicted_class": r.get("predicted_class"), "confidence": r.get("confidence"), "watering_status": r.get("watering_status"), "status": r.get("status"), - "device_id": ((r.get("file") or "").split("_")[0] or "Unknown"), - "url": url + "device_id": ((filename or "").split("_")[0] or "Unknown"), + "url": url, + "is_compressed": is_compressed, }) return results except Exception as e: raise HTTPException(status_code=500, detail=f"Database error: {e}") - @router.get("/{file_id:int}", summary="Get file by ID") def get_file_by_id(file_id: int): row = repo.get_file_by_id(file_id) diff --git a/services/db_api_service/app/tables/generic/repo.py b/services/db_api_service/app/tables/generic/repo.py index 5b19aa6e2..18e0803e1 100644 --- a/services/db_api_service/app/tables/generic/repo.py +++ b/services/db_api_service/app/tables/generic/repo.py @@ -5,6 +5,9 @@ from typing import Any, Dict, List, Optional from sqlalchemy.dialects.postgresql import insert as pg_insert from functools import lru_cache +from app.ws_manager import manager +from sqlalchemy.exc import IntegrityError, SQLAlchemyError +import asyncio import json import os @@ -25,6 +28,7 @@ desc, update, delete, + text, ) from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.exc import IntegrityError, SQLAlchemyError @@ -246,13 +250,9 @@ def list_rows( return {"rows": rows, "count": len(rows)} except SQLAlchemyError as e: raise DbSqlError("sql error", {"detail": str(e)}) + -def insert_row(resource: str, payload: Dict[str, Any], returning: str = "keys") -> Dict[str, Any]: - """ - Insert a single row into resource after validating against the contract. - Returns {"affected_rows": 1, "returning": | None}. - """ - # load contract first and use its properties to determine allowed fields +async def insert_row(resource: str, payload: Dict[str, Any], returning: str = "keys") -> Dict[str, Any]: contract = _load_contract(resource) props = contract.get("properties", {}) allowed_cols = set(props.keys()) @@ -261,43 +261,106 @@ def insert_row(resource: str, payload: Dict[str, Any], returning: str = "keys") if unknown: raise ValidationFailed("unknown fields", {"unknown_fields": sorted(unknown)}) - # validate payload against contract (may add defaults / coerce) - try: - valid = _validate_with_contract(resource, payload) - except ValidationFailed as e: - raise e + # validate + valid = _validate_with_contract(resource, payload) - # build SQLAlchemy table afterwards for SQL generation table = _build_table_from_contract(resource) + stmt = insert(table).values(**valid).returning(*table.columns) + + try: + with session_scope() as s: + res = s.execute(stmt) + row = res.mappings().first() if res.returns_rows else None - key_fields = contract.get("x-keyFields") or (["id"] if "id" in props else []) + # === ASYNC BROADCAST === + if resource == "aerial_images_complete_metadata": + asyncio.create_task(manager.broadcast({ + "type": "new_image_metadata", + "table": resource, + "img_key": payload.get("img_key"), + "timestamp": payload.get("timestamp_utc"), + })) + + elif resource == "image_new_aerial_connections": + asyncio.create_task(manager.broadcast({ + "type": "new_connection", + "table": resource, + "img_key": payload.get("img_key"), + "timestamp": payload.get("linked_time"), + })) + return { + "affected_rows": 1, + "returning": dict(row) if row else None + } - if not key_fields: - raise ValidationFailed("no key fields", {"detail": "contract has no x-keyFields and no id"}) + except IntegrityError as e: + raise DbConstraintError("integrity error", {"detail": str(e.orig)}) + except SQLAlchemyError as e: + raise DbSqlError("sql error", {"detail": str(e)}) - # Build UPSERT statement - stmt = pg_insert(table).values(**valid) - update_fields = {k: stmt.excluded[k] for k in valid.keys() if k not in key_fields} - stmt = stmt.on_conflict_do_update( - index_elements=key_fields, - set_=update_fields, - ).returning(*table.columns) - +async def insert_field_polygon(payload: dict): + table = _build_table_from_contract("field_polygons") + + pixel = payload.get("pixel_points") + if isinstance(pixel, list): + pixel = {"points": pixel} + if isinstance(pixel, str): + pixel = {"points": json.loads(pixel)} + + wkt = payload.get("boundary") + if not isinstance(wkt, str): + raise ValidationFailed("boundary must be WKT string") + + stmt = ( + insert(table) + .values( + gis_origin=payload.get("gis_origin"), + pixel_points=pixel, + + # 👇 הכי חשוב — להפוך ל-geometry אמיתי + boundary=text(f"ST_GeomFromText('{wkt}', 4326)") + ) + .returning(*table.columns) + ) + try: with session_scope() as s: - res = s.execute(stmt) - row = res.mappings().first() if res.returns_rows else None - if not row: - return {"affected_rows": 1, "returning": None} - - full = dict(row) - if returning == "full": - return {"affected_rows": 1, "returning": full} - - key_fields = contract.get("x-keyFields") or (["id"] if "id" in props else []) - keys_obj = {k: full[k] for k in key_fields if k in full} if key_fields else None - return {"affected_rows": 1, "returning": keys_obj} + row = s.execute(stmt).mappings().first() + return {"affected_rows": 1, "returning": dict(row)} + except IntegrityError as e: + raise DbConstraintError("integrity error", {"detail": str(e.orig)}) + except SQLAlchemyError as e: + raise DbSqlError("sql error", {"detail": str(e)}) + + +def update_field_polygon(row_id: int, payload: dict): + table = _build_table_from_contract("field_polygons") + + pixel = payload.get("pixel_points") + if isinstance(pixel, list): + pixel = {"points": pixel} + if isinstance(pixel, str): + pixel = {"points": json.loads(pixel)} + + wkt = payload.get("boundary") + geom_value = text(f"ST_GeomFromText('{wkt}', 4326)") if wkt else None + + stmt = ( + update(table) + .where(table.c.id == row_id) + .values( + gis_origin=payload.get("gis_origin"), + pixel_points=pixel, + boundary=geom_value, + ) + .returning(*table.columns) + ) + + try: + with session_scope() as s: + row = s.execute(stmt).mappings().first() + return {"affected_rows": 1, "returning": dict(row)} except IntegrityError as e: raise DbConstraintError("integrity error", {"detail": str(e.orig)}) except SQLAlchemyError as e: diff --git a/services/db_api_service/app/tables/generic/router.py b/services/db_api_service/app/tables/generic/router.py index 21696fdc1..fc300a9a0 100644 --- a/services/db_api_service/app/tables/generic/router.py +++ b/services/db_api_service/app/tables/generic/router.py @@ -73,17 +73,22 @@ def list_rows( except Exception as e: handle_repo_exceptions(e) + # Insert a single row into the resource after validation. @tables_router.post("/{resource}", status_code=201) - def create_row( + async def create_row( resource: str = Path(..., regex=r"^[a-zA-Z_][a-zA-Z0-9_]*$"), payload: Dict[str, Any] = Body(...), returning: Literal["keys","full"] = Query("keys") ): try: - return repo.insert_row(resource, payload, returning) + if resource == "field_polygons": + print("enter to field_polygons") + return await repo.insert_field_polygon(payload) + return await repo.insert_row(resource, payload, returning) except Exception as e: - handle_repo_exceptions(e) + handle_repo_exceptions(e) + # Insert multiple rows (batch) into the resource, validating each entry. @tables_router.post("/{resource}/rows:batch") @@ -122,10 +127,16 @@ def put_row( data = body.get("data") if not isinstance(keys, dict) or not isinstance(data, dict): raise HTTPException(status_code=400, detail="body must include 'keys' and 'data' objects") + + if resource == "field_polygons": + print("enter to field_polygons") + row_id = keys.get("id") + return repo.update_field_polygon(row_id, data) return repo.update_row(resource, keys, data, replace=True) except Exception as e: handle_repo_exceptions(e) + # Delete row @tables_router.delete("/{resource}") def delete_row( diff --git a/services/db_api_service/app/ws_manager.py b/services/db_api_service/app/ws_manager.py new file mode 100644 index 000000000..35e6031e7 --- /dev/null +++ b/services/db_api_service/app/ws_manager.py @@ -0,0 +1,51 @@ +import json +import asyncio +from fastapi import WebSocket +from typing import Set + +class ConnectionManager: + """Handles all active WebSocket clients.""" + def __init__(self): + self.active_connections: Set[WebSocket] = set() + + async def connect(self, websocket: WebSocket): + await websocket.accept() + self.active_connections.add(websocket) + print(f"🟢 Client connected ({len(self.active_connections)} total)") + + def disconnect(self, websocket: WebSocket): + if websocket in self.active_connections: + self.active_connections.remove(websocket) + print(f"🔴 Client disconnected ({len(self.active_connections)} total)") + + async def broadcast(self, message: dict): + """Send message to all active clients concurrently.""" + if not self.active_connections: + return + + data = json.dumps(message) + + print(f"📡 Broadcast to {len(self.active_connections)} clients: {message}") + + tasks = [] + dead_clients = [] + + for ws in list(self.active_connections): + tasks.append(self._safe_send(ws, data, dead_clients)) + + # send to all concurrently + await asyncio.gather(*tasks, return_exceptions=True) + + # Remove closed connections + for ws in dead_clients: + self.disconnect(ws) + + async def _safe_send(self, websocket: WebSocket, data: str, dead_clients: list): + """Send safely to a single WS; mark if closed.""" + try: + await websocket.send_text(data) + except Exception: + dead_clients.append(websocket) + +# מופע גלובלי +manager = ConnectionManager() diff --git a/services/db_api_service/requirements.txt b/services/db_api_service/requirements.txt index 091ea771c..b37f0a4b6 100644 --- a/services/db_api_service/requirements.txt +++ b/services/db_api_service/requirements.txt @@ -11,3 +11,4 @@ pydantic>=2.7 pydantic-settings>=2.0 pathlib jsonschema>=4.18,<5 +requests>=2.31.0 diff --git a/services/flink_parts_img/Dockerfile.flink b/services/flink_parts_img/Dockerfile.flink new file mode 100644 index 000000000..8364df867 --- /dev/null +++ b/services/flink_parts_img/Dockerfile.flink @@ -0,0 +1,69 @@ +FROM flink:1.19.3-scala_2.12-java11 + +USER root + +# ---------- SSL ---------- +COPY netfree-ca.crt /usr/local/share/ca-certificates/corp-ca.crt +RUN chmod 644 /usr/local/share/ca-certificates/corp-ca.crt && update-ca-certificates + +ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt +ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt + +# ---------- System Deps ---------- +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 python3-pip python3-venv python3-dev gcc libgomp1 curl && \ + rm -rf /var/lib/apt/lists/* + +# ---------- Python venv ---------- +RUN python3 -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +ENV PYFLINK_PYTHON=/opt/venv/bin/python +ENV PYFLINK_CLIENT_EXECUTABLE=/opt/venv/bin/python +ENV PYTHONPATH="/opt/venv/lib/python3.10/site-packages:$PYTHONPATH" + +# ---------- pip SSL ---------- +RUN printf "[global]\ntrusted-host = pypi.org\n\tfiles.pythonhosted.org\ncert = /etc/ssl/certs/ca-certificates.crt\n" > /etc/pip.conf + +# ---------- PyFlink ---------- +RUN pip install --no-cache-dir apache-flink==1.19.3 + +# ---------- Python libs ---------- +RUN pip install --no-cache-dir \ + numpy==1.24.4 \ + opencv-python-headless==4.8.0.76 \ + pandas==2.1.4 \ + pyarrow==10.0.1 \ + shapely==2.0.2 \ + minio \ + kafka-python \ + cloudpickle \ + apache-beam==2.48.0 + +# ---------- Fix PyFlink runner ---------- +RUN mkdir -p /opt/flink/pyflink && \ + cp -r /opt/venv/lib/python3.10/site-packages/pyflink /opt/flink/pyflink + +ENV FLINK_PYTHONPATH=/opt/flink/pyflink + +# ================ +# *** OLD PART (correct place) *** +# יצירת התיקיות של האפליקציה כמו בדוקר הישן +# ================ +WORKDIR /opt/app +RUN mkdir -p /opt/app/tmp /opt/app/secrets && chmod -R 777 /opt/app +COPY script.py job_with_stitch.py /opt/app/ + + +# ---------- Kafka Connectors ---------- +RUN curl -fSL https://repo1.maven.org/maven2/org/apache/kafka/kafka-clients/3.7.0/kafka-clients-3.7.0.jar \ + -o /opt/flink/lib/kafka-clients-3.7.0.jar && \ + curl -fSL https://repo1.maven.org/maven2/org/apache/flink/flink-connector-kafka/3.2.0-1.19/flink-connector-kafka-3.2.0-1.19.jar \ + -o /opt/flink/lib/flink-connector-kafka-3.2.0-1.19.jar + +# ---------- Copy job also to usrlib ---------- +RUN mkdir -p /opt/flink/usrlib +COPY job_with_stitch.py /opt/flink/usrlib/ + +# ---------- Auto-run job ---------- +ENTRYPOINT ["/docker-entrypoint.sh"] +CMD ["bash"] diff --git a/services/flink_parts_img/README (1).md b/services/flink_parts_img/README (1).md new file mode 100644 index 000000000..9a18b2b0f --- /dev/null +++ b/services/flink_parts_img/README (1).md @@ -0,0 +1,28 @@ +## Flink + kafka + minIO + +This is a folder that build a flink that enter a img to minIO +create a json and send it to kafka + + +Before start run this run the main docker compose of all the project! + +to run it from start: + +run: +```bash + +docker-compose up -d +``` + +and then: +```bash +docker exec -it flink-jobmanager /opt/flink/bin/flink run -py /opt/app/job_with_stitch.py +``` + + +and to see the outputs: +```bash + docker logs -f flink-taskmanager + ``` + + diff --git a/services/flink_parts_img/docker-compose.yml b/services/flink_parts_img/docker-compose.yml new file mode 100644 index 000000000..d22f2ea9e --- /dev/null +++ b/services/flink_parts_img/docker-compose.yml @@ -0,0 +1,42 @@ +version: "3.9" + +services: + jobmanager-img: + build: + context: . + dockerfile: Dockerfile.flink + container_name: flink-jobmanager-img + command: ["jobmanager"] + ports: + - "8055:8081" + environment: + - JOB_MANAGER_RPC_ADDRESS=jobmanager-img + - KAFKA_BROKERS=kafka:9092 + volumes: + - ./secrets:/app/secrets:ro + networks: + - ag_cloud + + taskmanager-img: + build: + context: . + dockerfile: Dockerfile.flink + container_name: flink-taskmanager-img + command: ["taskmanager", "-D", "taskmanager.numberOfTaskSlots=4"] + depends_on: + - jobmanager-img + environment: + - JOB_MANAGER_RPC_ADDRESS=jobmanager-img + - KAFKA_BROKERS=kafka:9092 + - MINIO_ENDPOINT=minio-hot:9000 + - MINIO_ACCESS_KEY=minioadmin + - MINIO_SECRET_KEY=minioadmin123 + - MINIO_BUCKET=imagery + volumes: + - ./secrets:/app/secrets:ro + networks: + - ag_cloud + +networks: + ag_cloud: + external: true diff --git a/services/flink_parts_img/job_with_stitch.py b/services/flink_parts_img/job_with_stitch.py new file mode 100644 index 000000000..213d7191b --- /dev/null +++ b/services/flink_parts_img/job_with_stitch.py @@ -0,0 +1,302 @@ +# -*- coding: utf-8 -*- +import os +import json +import pathlib +from datetime import datetime +from collections import deque +import logging +import shutil +import traceback + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry +import subprocess + +from minio import Minio +from minio.error import S3Error + +from pyflink.datastream import StreamExecutionEnvironment +from pyflink.common.serialization import SimpleStringSchema +from pyflink.datastream.connectors.kafka import FlinkKafkaConsumer + +import cv2 +import numpy as np +from shapely.geometry import Polygon +from shapely.ops import unary_union +from shapely.errors import TopologicalError +from kafka import KafkaProducer + + +logging.basicConfig(level=logging.INFO, format=" %(message)s") + +# ============================= +# DB API AUTH +# ============================= +DB_API_BASE = os.getenv("DB_API_BASE", "http://db_api_service:8001") +DB_API_AUTH_MODE = os.getenv("DB_API_AUTH_MODE", "service") +DB_API_TOKEN_FILE = os.getenv( + "DB_API_TOKEN_FILE", + "/opt/app/secrets/db_api_token" +) +DB_API_SERVICE_NAME = os.getenv("DB_API_SERVICE_NAME", "image-processor") + + +def _safe_join_url(base: str, path: str) -> str: + return f"{base.rstrip('/')}/{path.lstrip('/')}" + + +def _read_token_from_file(path: str) -> str | None: + p = pathlib.Path(path) + if p.exists(): + t = p.read_text(encoding="utf-8").strip() + return t or None + return None + + +def _fetch_token_via_dev_bootstrap(base: str) -> str | None: + url = _safe_join_url(base, "/auth/_dev_bootstrap") + payload = {"service_name": DB_API_SERVICE_NAME, + "rotate_if_exists": True} + try: + r = requests.post(url, json=payload, timeout=10) + if r.status_code not in (200, 201): + print(f"[DB_API][WARN] bootstrap failed: {r.status_code}") + return None + data = r.json() + return (data.get("service_account", {}) or {}).get("raw_token") + except Exception as e: + print(f"[DB_API][ERROR] bootstrap: {e}") + return None + + +def get_or_bootstrap_token(): + token = _read_token_from_file(DB_API_TOKEN_FILE) + if token: + return token + token = _fetch_token_via_dev_bootstrap(DB_API_BASE) + if token: + pathlib.Path(DB_API_TOKEN_FILE).write_text(token, + encoding="utf-8") + return token + + +_http = requests.Session() +svc_token = get_or_bootstrap_token() +if svc_token: + if DB_API_AUTH_MODE == "service": + _http.headers.update({"X-Service-Token": svc_token}) + else: + _http.headers.update({"Authorization": f"Bearer {svc_token}"}) +_http.headers.update({"Content-Type": "application/json"}) +_http.mount("http://", HTTPAdapter(max_retries=Retry(total=5, + backoff_factor=0.5))) +_http.mount("https://", HTTPAdapter(max_retries=Retry(total=5, + backoff_factor=0.5))) +current_batch_dir = None +current_drone_id = None +all_gis = [] + + +def process_message(message: str) -> None: + """Kafka → MinIO → Folder batching → Script → Delete folder.""" + global current_batch_dir + global current_drone_id + global all_gis + + # Always initialize defaults + if 'current_drone_id' not in globals() or current_drone_id is None: + current_drone_id = "UNKNOWN" + + if 'all_gis' not in globals() or all_gis is None: + all_gis = [] + + print("\n" + "=" * 80) + + # --- Decode message --- + if isinstance(message, bytes): + try: + message = message.decode("utf-8") + except Exception as e: + print(f"[ERROR] Failed to decode bytes: {e}") + return + + # --- Parse JSON --- + try: + data = json.loads(message) + except Exception as e: + print(f"[ERROR] Invalid JSON: {e}") + return + + file_name = data.get("file_name") + image_key = data.get("key") + + if not file_name or not image_key: + print("[WARN] Missing fields in message") + return + + # Normalize file name + if "t" in file_name: + file_name = file_name.replace("t", "T") + if file_name.endswith("z.jpg"): + file_name = file_name.replace("z.jpg", "Z.jpg") + + print(f"[DEBUG] Fetch metadata for: {file_name}") + + # --- Metadata loading from DB (optional) --- + done_flag = False + rows = [] + try: + url = _safe_join_url(DB_API_BASE, "/api/tables/aerial_images_metadata") + params = { + "file_name": file_name, + "order_by": "id", + "order_dir": "desc", + "limit": 500, + } + r = _http.get(url, params=params, timeout=10) + if r.status_code != 200: + print(f"[ERROR] DB API: {r.status_code} {r.text[:200]}") + else: + rows = r.json().get("rows", []) + if rows: + print("[OK] Found metadata for image:") + print(json.dumps(rows[0], ensure_ascii=False, indent=2)) + done_flag = rows[0].get("done", False) + current_drone_id = rows[0].get("drone_id", None) if current_drone_id is None else current_drone_id + all_gis.append(rows[0].get("gis_origin", None)) + else: + print(f"[WARN] No metadata found for {file_name}") + except Exception as e: + print(f"[ERROR] DB API request failed: {e}") + + # ===================================================================== + # === Create a new folder if there is no current active folder ===================== + # ===================================================================== + + base_dir = "/opt/app/tmp" + os.makedirs(base_dir, exist_ok=True) + + if current_batch_dir is None: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + current_batch_dir = os.path.join(base_dir, f"batch_{timestamp}") + os.makedirs(current_batch_dir, exist_ok=True) + print(f"[INFO] 🆕 נוצרה תיקייה חדשה: {current_batch_dir}") + else: + print(f"[INFO] ➕ משתמשים בתיקייה קיימת: {current_batch_dir}") + + # ===================================================================== + # === Download from Minayo ==================================================== + # ===================================================================== + + minio_client = Minio( + os.getenv("MINIO_ENDPOINT", "minio-hot:9000"), + os.getenv("MINIO_ACCESS_KEY", "minioadmin"), + os.getenv("MINIO_SECRET_KEY", "minioadmin123"), + secure=False, + ) + + uniq = datetime.utcnow().strftime("%Y%m%d_%H%M%S_%f") + filename = f"{uniq}_{os.path.basename(image_key)}" + local_path = os.path.join(current_batch_dir, filename) + + try: + bucket, *path_parts = image_key.strip("/").split("/") + object_path = "/".join(path_parts) + minio_client.fget_object(bucket, object_path, local_path) + print(f"[OK] שמירה: {local_path}") + except Exception as e: + print(f"[ERROR] MinIO download failed: {e}") + return + + # ===================================================================== + # === If done=True → run script and delete folder =================== + # ===================================================================== + + if done_flag: + print("[INFO] 🏁 הבאטץ' הושלם! מריץ סקריפט...") + + script_path = os.getenv("BATCH_SCRIPT_PATH", "/opt/app/script.py") + cmd = ["python", script_path, "--batch-dir", current_batch_dir, "--drone-id", str(current_drone_id), "--gis-origins", json.dumps(all_gis)] + + try: + print("[INFO] Running batch script...") + result = subprocess.run( + cmd, + capture_output=True, + text=True + ) + + print("========== SCRIPT STDOUT ==========") + print(result.stdout) + print("========== SCRIPT STDERR ==========") + print(result.stderr) + + if result.returncode != 0: + print(f"[ERROR] Script failed with code {result.returncode}") + else: + print("[OK] הסקריפט הסתיים בהצלחה.") + + except Exception as e: + print(f"[ERROR] Script execution crashed: {e}") + + + # Deleting the folder + try: + shutil.rmtree(current_batch_dir) + print(f"[INFO] 🗑️ התיקייה נמחקה: {current_batch_dir}") + except Exception as e: + print(f"[ERROR] Folder delete failed: {e}") + + # Initialize so that the next image creates a new folder + current_batch_dir = None + + print("=" * 80 + "\n") + + +def process_message_and_return_dummy(message: str) -> str: + """ + Wrapper for Flink map: + -Runs process_message (side-effect) + - returns a dummy string, so it won't be None. + """ + process_message(message) + return "ok" + + +# ============================= +# MAIN FLINK JOB +# ============================= + +def main(): + print("🚀 Starting Unified Kafka-MinIO Stitch Job") + + bootstrap = os.getenv("KAFKA_BROKERS", "kafka:9092") + topic_in = os.getenv("IN_TOPIC", + "image_new_aerial_connections") + + env = StreamExecutionEnvironment.get_execution_environment() + env.set_parallelism(1) + + props = { + "bootstrap.servers": bootstrap, + "group.id": "flink-device-pipeline", + "auto.offset.reset": "earliest", + } + + consumer = FlinkKafkaConsumer( + [topic_in], + SimpleStringSchema(), + props, + ) + + stream = env.add_source(consumer) + + processed = stream.map(process_message_and_return_dummy) + processed.print() + + env.execute("Unified Kafka-MinIO Stitching Job") + + +if __name__ == "__main__": + main() diff --git a/services/flink_parts_img/script.py b/services/flink_parts_img/script.py new file mode 100644 index 000000000..4256f3a57 --- /dev/null +++ b/services/flink_parts_img/script.py @@ -0,0 +1,324 @@ +# -*- coding: utf-8 -*- +""" +Stitching script for aerial image batches. +Uses PRINT ONLY for all logs. +""" + +import os +import cv2 +import json +import traceback +import numpy as np +from datetime import datetime +from collections import deque + +from shapely.geometry import Polygon +from shapely.ops import unary_union +from shapely.errors import TopologicalError + +from kafka import KafkaProducer +from minio import Minio +from minio.error import S3Error + + + +# ------------------------------------------------------------- +# SIFT matching +# ------------------------------------------------------------- +def compute_connection(img1, img2, min_matches: int = 10, ratio: float = 0.5): + sift = cv2.SIFT_create() + + kp1, des1 = sift.detectAndCompute(img1, None) + kp2, des2 = sift.detectAndCompute(img2, None) + + if des1 is None or des2 is None: + return False, None + + bf = cv2.BFMatcher() + matches = bf.knnMatch(des1, des2, k=2) + + good = [m for m, n in matches if m.distance < ratio * n.distance] + if len(good) < min_matches: + return False, None + + pts1 = np.float32([kp1[m.queryIdx].pt for m in good]) + pts2 = np.float32([kp2[m.trainIdx].pt for m in good]) + + M, mask = cv2.estimateAffinePartial2D(pts2, pts1, method=cv2.RANSAC) + return True, M + + +# ------------------------------------------------------------- +# Warp polygon +# ------------------------------------------------------------- +def transform_polygon(points, transform): + pts = np.array(points, dtype=np.float32).reshape(-1, 1, 2) + warped = cv2.ppectiveTransform(pts, transform) + return warped.reshape(-1, 2).tolist() + + +# ------------------------------------------------------------- +# Upload to Minio +# ------------------------------------------------------------- +def upload_to_minio(local_path, bucket, object_name): + try: + endpoint = os.getenv("MINIO_ENDPOINT", "minio-hot:9000") + access_key = os.getenv("MINIO_ACCESS_KEY", "minioadmin") + secret_key = os.getenv("MINIO_SECRET_KEY", "minioadmin123") + + client = Minio(endpoint, access_key, secret_key, secure=False) + + if not client.bucket_exists(bucket): + client.make_bucket(bucket) + print(f"[INFO] Created bucket: {bucket}") + + client.fput_object(bucket, object_name, local_path) + print(f"[INFO] Uploaded to MinIO: {object_name}") + + except Exception as e: + print(f"[ERROR] MinIO upload failed: {e}") + + +# ------------------------------------------------------------- +# MAIN STITCH FUNCTION +# ------------------------------------------------------------- +def stitch_with_checks_and_polygons(folder, output_img, output_poly, min_matches=10, drone_id="unknown", all_gis_origins=None): + try: + print(f"[INFO] Processing folder: {folder}") + + files = sorted([ + f for f in os.listdir(folder) + if f.lower().endswith((".jpg", ".jpeg", ".png")) + ]) + + if not files: + print("[ERROR] No images found!") + return None + + images = [cv2.imread(os.path.join(folder, f)) for f in files] + + for i, img in enumerate(images): + if img is None: + print(f"[ERROR] Failed loading image: {files[i]}") + + n = len(images) + graph = {i: {} for i in range(n)} + + # ----------------------------- + # Pairwise SIFT graph + # ----------------------------- + print("[INFO] Computing SIFT connections...") + for i in range(n): + for j in range(i + 1, n): + ok, M = compute_connection(images[i], images[j], min_matches) + if ok: + graph[i][j] = M + Minv = np.linalg.inv(np.vstack([M, [0, 0, 1]]))[:2] + graph[j][i] = Minv + print(f"[OK] {files[i]} <--> {files[j]} connected") + + # ----------------------------- + # BFS transforms + # ----------------------------- + positions = {0: np.eye(3, dtype=np.float32)} + q = deque([0]) + visited = set() + + print("[INFO] Computing global transforms...") + while q: + i = q.popleft() + if i in visited: + continue + visited.add(i) + + for j, M in graph[i].items(): + if j not in positions: + M3 = np.vstack([M, [0, 0, 1]]) + positions[j] = positions[i] @ M3 + q.append(j) + + # ----------------------------- + # Warp images to canvas + # ----------------------------- + print("[INFO] Warping images...") + all_corners = [] + + + for i, img in enumerate(images): + if i not in positions: + print(f"[WARN] No transform for image {files[i]}") + continue + + h, w = img.shape[:2] + corners = np.float32([[0, 0], [0, h], [w, h], [w, 0]]).reshape(-1, 1, 2) + warped = cv2.perspectiveTransform(corners, positions[i]) + all_corners.append(warped) + + # meta = load_metadata(os.path.join(folder, files[i])) + # print("*******************") + # print(meta) + # if meta: + # if device_id == "unknown" and "drone_id" in meta: + # device_id = meta["drone_id"] + # if "gis_origin" in meta: + # all_gis_origins.append(meta["gis_origin"]) + # if "polygon" in meta: + # poly = transform_polygon(meta["polygon"], positions[i]) + # polygons.append(Polygon(poly)) + + if not all_corners: + print("[ERROR] No corners collected") + return None + + all_corners = np.concatenate(all_corners, axis=0) + xmin, ymin = np.int32(all_corners.min(axis=0).ravel()) + xmax, ymax = np.int32(all_corners.max(axis=0).ravel()) + + canvas = np.zeros((ymax - ymin, xmax - xmin, 3), dtype=np.uint8) + T = np.array([[1, 0, -xmin], + [0, 1, -ymin], + [0, 0, 1]], dtype=np.float32) + + print("[INFO] Drawing warped images...") + for i, img in enumerate(images): + if i not in positions: + continue + M_final = (T @ positions[i])[:2] + cv2.warpAffine(img, M_final, (xmax - xmin, ymax - ymin), + dst=canvas, borderMode=cv2.BORDER_TRANSPARENT) + + # ----------------------------- + # Merge polygons + # ----------------------------- + # if polygons: + # try: + # merged = unary_union(polygons) + # coords = np.array(list(merged.exterior.coords), dtype=np.int32) + # cv2.polylines(canvas, [coords], True, (0, 255, 0), 3) + + # with open(output_poly, "w") as f: + # json.dump({"field_polygon": coords.tolist()}, f, indent=2) + + # print("[INFO] Polygon merged") + + # except Exception as e: + # print(f"[ERROR] Polygon merge failed: {e}") + + # ----------------------------- + # Save stitched image + # ----------------------------- + cv2.imwrite(output_img, canvas) + print(f"[OK] Saved stitched image: {output_img}") + + # ----------------------------- + # UNIQUE FILE NAME + # ----------------------------- + timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + file_name = f"compleat_{timestamp}" + + # rename stitched image to unique name + unique_output_name = f"{file_name}.jpg" + final_output_path = os.path.join(folder, unique_output_name) + os.rename(output_img, final_output_path) + print(f"[OK] Renamed to unique name: {final_output_path}") + + # ----------------------------- + # Compute GIS center + # ----------------------------- + stitched_gis = None + if all_gis_origins: + print(all_gis_origins) + avg_lat = sum(p["latitude"] for p in all_gis_origins) / len(all_gis_origins) + avg_lon = sum(p["longitude"] for p in all_gis_origins) / len(all_gis_origins) + stitched_gis = { + "latitude": avg_lat, + "longitude": avg_lon + } + else: + print("⚠️ No GIS data found in source images.") + + if stitched_gis is None: + stitched_gis = {} + + # ----------------------------- + # METADATA (DB format) + # ----------------------------- + metadata = { + "file_name": file_name, + "device_id": drone_id, + "gis_origin": stitched_gis, # correct DB field + "img_key": f"air/compleat/{unique_output_name}", + "timestamp_utc": timestamp + } + + meta_path = os.path.join(folder, f"{file_name}.json") + with open(meta_path, "w") as f: + json.dump(metadata, f, indent=2) + + print(f"[OK] Metadata saved: {meta_path}") + + # ----------------------------- + # UPLOAD TO MINIO + # ----------------------------- + bucket = os.getenv("MINIO_BUCKET", "imagery") + upload_to_minio( + final_output_path, + bucket, + f"aerial/compleat/{unique_output_name}" + ) + + # ----------------------------- + # Send metadata to Kafka + # ----------------------------- + try: + producer = KafkaProducer( + bootstrap_servers=[os.getenv("KAFKA_BROKERS", "kafka:9092")], + value_serializer=lambda v: json.dumps(v).encode("utf-8"), + ) + + producer.send(os.getenv("OUT_TOPIC", "aerial_images_complete_metadata"), metadata) + producer.flush() + + print("[OK] Metadata sent to Kafka") + + except Exception as e: + print(f"[ERROR] Kafka send failed: {e}") + + return canvas + + except Exception: + print("[FATAL] Stitch process crashed:") + print(traceback.format_exc()) + return None + + +# ------------------------------------------------------------- +# CLI ENTRYPOINT +# ------------------------------------------------------------- +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("--batch-dir", type=str, required=True) + parser.add_argument("--drone-id", type=str, required=True) + parser.add_argument("--gis-origins", type=json.loads, required=True) + + args = parser.parse_args() + + + print(f"[INFO] Running stitcher on: {args.batch_dir}") + print(f"[INFO] Running stitcher on: {args.drone_id}") + print(f"[INFO] Running stitcher on: {args.gis_origins}") + + try: + stitch_with_checks_and_polygons( + args.batch_dir, + os.path.join(args.batch_dir, "stitched_result.jpg"), + os.path.join(args.batch_dir, "field_polygon.json"), + min_matches=10, + drone_id=args.drone_id, + all_gis_origins=args.gis_origins + ) + except Exception: + print("[FATAL] Crash in main():") + print(traceback.format_exc()) diff --git a/services/sensorAnomalyPro/sensorAnomalyPro/app.py b/services/sensorAnomalyPro/sensorAnomalyPro/app.py index 42be1cc4e..675796b1e 100644 --- a/services/sensorAnomalyPro/sensorAnomalyPro/app.py +++ b/services/sensorAnomalyPro/sensorAnomalyPro/app.py @@ -98,7 +98,7 @@ def _classify_condition(sensor: str, value: float, lower: float, upper: float) - # --- Anomaly detection --- def detect_anomaly(evt: dict) -> dict: - plant_id, sensor = evt.get("plant_id"), evt.get("sensor") + plant_id, sensor = evt.get("plant_id"), evt.get("sensor_name") key = (plant_id, sensor) if evt.get("value") is None: @@ -164,7 +164,7 @@ def process_event(raw: str): return None out = { - "idsensor": evt.get("id"), + "idsensor": evt.get("sensor_id"), "plant_id": evt.get("plant_id"), "sensor": evt.get("sensor_name"), "ts": evt.get("timestamp") or evt.get("ts"), @@ -218,7 +218,7 @@ def apply(self, key, window, inputs): # --- Main --- def main(): brokers = os.getenv("KAFKA_BROKERS", "kafka:9092") - in_topic = os.getenv("IN_TOPIC", "sensor-telemetry") + in_topic = os.getenv("IN_TOPIC", "sensors") env = StreamExecutionEnvironment.get_execution_environment() env.set_parallelism(int(os.getenv("FLINK_PARALLELISM", "2"))) diff --git a/services/sensorGuard/flink_app/api/auth.py b/services/sensorGuard/flink_app/api/auth.py index ed57b5d02..3458232aa 100644 --- a/services/sensorGuard/flink_app/api/auth.py +++ b/services/sensorGuard/flink_app/api/auth.py @@ -28,7 +28,7 @@ def _write_token_to_file(path: str, token: str) -> None: p = pathlib.Path(path) p.parent.mkdir(parents=True, exist_ok=True) p.write_text(token, encoding="utf-8") - print(f"[AUTH] Token saved to {path}", flush=True) + print(f"[AUTH] Token saved to {path}") # === FETCH LOGIC === def _fetch_token_via_bootstrap(base: str, retries: int = 3, backoff: float = 1.0) -> str | None: @@ -39,7 +39,7 @@ def _fetch_token_via_bootstrap(base: str, retries: int = 3, backoff: float = 1.0 try: r = requests.post(url, json=payload, timeout=10) if r.status_code not in (200, 201): - print(f"[AUTH] Bootstrap failed ({r.status_code}): {r.text[:200]}", flush=True) + print(f"[AUTH] Bootstrap failed ({r.status_code}): {r.text[:200]}") time.sleep(backoff * attempt) continue @@ -48,24 +48,49 @@ def _fetch_token_via_bootstrap(base: str, retries: int = 3, backoff: float = 1.0 or (data.get("service_account", {}) or {}).get("token") if raw and isinstance(raw, str) and raw.strip() and "***" not in raw: - print("[AUTH] Token fetched successfully", flush=True) + print("[AUTH] Token fetched successfully") return raw.strip() except Exception as e: - print(f"[AUTH] Exception: {e}", flush=True) + print(f"[AUTH] Exception: {e}") time.sleep(backoff * attempt) - print("[AUTH] Failed to bootstrap service token", flush=True) + print("[AUTH] Failed to bootstrap service token") return None # === PUBLIC API === +def validate_token(base: str, token: str) -> bool: + """ + Test if token is valid by making a simple API call. + Returns True if token works, False otherwise. + + NOTE: Disabled because /api/me endpoint doesn't exist. + We just assume the token is valid and let actual API calls fail if needed. + """ + # url = _safe_join_url(base, "/api/me") + # try: + # r = requests.get(url, headers={"X-Service-Token": token}, timeout=5) + # return r.status_code == 200 + # except Exception: + # return False + + # Skip validation - assume token is valid + return True if token else False + + def get_access_token(base_url: str | None = None) -> str: """ - Loads token from file if exists, otherwise bootstraps new one via /auth/_dev_bootstrap. + Loads token from file if exists and valid, otherwise bootstraps new one via /auth/_dev_bootstrap. Returns a valid token string. """ base = base_url or DB_API_BASE token = _read_token_from_file(DB_API_TOKEN_FILE) + + # If token exists, validate it first if token: - return token + if validate_token(base, token): + print("[AUTH] Existing token is valid") + return token + else: + print("[AUTH] Existing token is invalid, fetching new one...") new_token = _fetch_token_via_bootstrap(base) if new_token: diff --git a/services/sensorGuard/flink_app/api/devices_client.py b/services/sensorGuard/flink_app/api/devices_client.py index 7dfa2924f..a46a4f55f 100644 --- a/services/sensorGuard/flink_app/api/devices_client.py +++ b/services/sensorGuard/flink_app/api/devices_client.py @@ -6,6 +6,7 @@ """ import requests from typing import Iterable, Tuple +from api.auth import get_access_token def list_active_sensors(api_base: str, token: str, timeout: float = 10.0) -> Iterable[str]: @@ -43,19 +44,25 @@ def list_active_sensors(api_base: str, token: str, timeout: float = 10.0) -> Ite return -def get_sensors_last_seen(api_base: str, token: str, timeout: float = 10.0): +def get_sensors_last_seen(api_base: str = None, timeout: float = 10.0): """ Fetch all sensors from devices_sensor with their last_seen timestamp. Used for silence sweep. Args: - api_base: Base URL of the API. - token: Service token. + api_base: Base URL of the API. If None, uses host.docker.internal (same as PATCH). timeout: Request timeout. Returns: List of dicts like: [{"id": "dev-a", "sensor_type": "temp", "last_seen": "2025-11-11T13:00:00Z"}, ...] """ + # Use same URL pattern as update_device_last_seen (PATCH) + if api_base is None: + api_base = "http://host.docker.internal:8001" + + # Get fresh token each time (same pattern as update_device_last_seen) + token = get_access_token(api_base) + url = f"{api_base.rstrip('/')}/api/tables/devices_sensor" headers = {"X-Service-Token": token} diff --git a/services/sensorGuard/flink_app/core/engine.py b/services/sensorGuard/flink_app/core/engine.py index c4669b16b..f4b897b93 100644 --- a/services/sensorGuard/flink_app/core/engine.py +++ b/services/sensorGuard/flink_app/core/engine.py @@ -5,6 +5,7 @@ from .rules import corrupted, out_of_range, stuck_sensor from datetime import datetime, timezone import time +import os from api.devices_updater import update_device_last_seen from api.devices_client import get_sensors_last_seen @@ -22,7 +23,7 @@ def __init__(self, cfg, writer, state: StateStore | None = None): self.state = state or StateStore() # --- API info & persistent token --- - self.api_base = "http://host.docker.internal:8001" + self.api_base = os.getenv("DB_API_BASE", "http://db_api_service:8001") self.token = get_access_token(self.api_base) if self.token: print("[ENGINE] Access token acquired successfully.") @@ -71,8 +72,8 @@ def sweep_silence(self, now): """ print("[ENGINE] Starting silence sweep (via DB API)...") - # Fetch sensors via API (single attempt) - sensors = get_sensors_last_seen(self.api_base, self.token) + # Fetch sensors via API - uses same pattern as PATCH (host.docker.internal) + sensors = get_sensors_last_seen() if not sensors: print("[ENGINE][ERROR] No sensors retrieved. Skipping silence sweep.") diff --git a/services/sensorGuard/flink_app/main.py b/services/sensorGuard/flink_app/main.py index b5292b58e..2fe44b93b 100644 --- a/services/sensorGuard/flink_app/main.py +++ b/services/sensorGuard/flink_app/main.py @@ -30,7 +30,7 @@ def to_event(obj: dict) -> Event | None: return None ts = datetime.now(timezone.utc) - device_id = obj.get("id") + device_id = obj.get("sensor_id") sensor_type = obj.get("sensor_type") or obj.get("sensor_name", "unknown_sensor") if not device_id: @@ -143,7 +143,7 @@ def main(): cfg_path = base_dir / "config" / "rules.yaml" # --- Load sensors from API --- - api_base = os.getenv("DEVICES_API_BASE", "http://host.docker.internal:8001") + api_base = os.getenv("DB_API_BASE", "http://db_api_service:8001") print(f"[INIT] Fetching active sensors from {api_base}...") token = get_access_token(api_base) diff --git a/services/sensorGuard/sensorGuard/Dockerfile.flink b/services/sensorGuard/sensorGuard/Dockerfile.flink deleted file mode 100644 index 8d94314f5..000000000 --- a/services/sensorGuard/sensorGuard/Dockerfile.flink +++ /dev/null @@ -1,135 +0,0 @@ -FROM flink:1.19.3-scala_2.12-java11 - -USER root - -COPY certs/*.crt /app/certs/ -RUN if [ -d ./certs ] && [ "$(ls ./certs/*.crt 2>/dev/null)" ]; then \ - echo "Configuring NetFree certificates..."; \ - cp ./certs/*.crt /usr/local/share/ca-certificates/; \ - update-ca-certificates; \ - fi -ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt -ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt -ENV PIP_CERT=/etc/ssl/certs/ca-certificates.crt - -RUN set -eux; \ - apt-get update; \ - apt-get install -y --no-install-recommends \ - python3 python3-venv python3-pip ca-certificates curl libgomp1; \ - rm -rf /var/lib/apt/lists/* - -RUN curl -fSL https://repo1.maven.org/maven2/org/apache/flink/flink-connector-kafka/3.2.0-1.19/flink-connector-kafka-3.2.0-1.19.jar \ - -o /opt/flink/lib/flink-connector-kafka-3.2.0-1.19.jar - -# Ensure lib directory exists -RUN mkdir -p /opt/flink/lib - -# venv + PATH -RUN python3 -m venv /opt/venv -ENV PATH="/opt/venv/bin:${PATH}" \ - PYFLINK_CLIENT_EXECUTABLE=/opt/venv/bin/python \ - PYFLINK_PYTHON=/opt/venv/bin/python \ - PYTHONUNBUFFERED=1 - -# Configure pip to use SSL certificates -RUN printf "[global]\ntrusted-host = pypi.org\n\tfiles.pythonhosted.org\ncert = /etc/ssl/certs/ca-certificates.crt\n" > /etc/pip.conf - -COPY requirements.txt /tmp/requirements.txt -# Install Python dependencies including PyFlink -RUN pip install -r /tmp/requirements.txt && \ - pip install "apache-flink==1.19.3" - -# Compatible versions for PyFlink 1.19.3 - -# kafka-clients -RUN curl -fSL https://repo1.maven.org/maven2/org/apache/kafka/kafka-clients/3.7.0/kafka-clients-3.7.0.jar \ - -o /opt/flink/lib/kafka-clients-3.7.0.jar - -RUN curl -fSL https://repo1.maven.org/maven2/org/apache/flink/flink-connector-kafka/3.2.0-1.19/flink-connector-kafka-3.2.0-1.19.jar \ - -o /opt/flink/lib/flink-connector-kafka-3.2.0-1.19.jar - -WORKDIR /opt/app -COPY flink_app/main.py /opt/app/main.py -COPY flink_app/core /opt/app/core -COPY flink_app/io_mod /opt/app/io_mod -COPY flink_app/config /opt/app/config -COPY flink_app/api /opt/app/api - -RUN mkdir -p /opt/app/resources - -RUN chown -R flink:flink /opt/app /opt/flink && chmod -R g+rwX /opt/app -USER flink - -# Default environment variables -ENV KAFKA_BROKERS=kafka:9092 \ - IN_TOPIC=sensors \ - OUT_TOPIC=event_logs_sensors \ - KAFKA_GROUP_ID=flink-device-pipeline \ - PYTHONPATH=/opt/app \ - PYFLINK_CLIENT_EXECUTABLE=/opt/venv/bin/python \ - PYFLINK_PYTHON=/opt/venv/bin/python - -USER root - -COPY netfree-ca.crt /usr/local/share/ca-certificates/corp-ca.crt -RUN chmod 644 /usr/local/share/ca-certificates/corp-ca.crt && update-ca-certificates - -ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \ - REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt - -RUN set -eux; \ - apt-get update; \ - apt-get install -y --no-install-recommends \ - python3 python3-venv python3-pip ca-certificates curl libgomp1; \ - rm -rf /var/lib/apt/lists/* - -RUN curl -fSL https://repo1.maven.org/maven2/org/apache/flink/flink-connector-kafka/3.2.0-1.19/flink-connector-kafka-3.2.0-1.19.jar \ - -o /opt/flink/lib/flink-connector-kafka-3.2.0-1.19.jar - -RUN mkdir -p /opt/flink/lib - -RUN python3 -m venv /opt/venv -ENV PATH="/opt/venv/bin:${PATH}" \ - PYFLINK_CLIENT_EXECUTABLE=/opt/venv/bin/python \ - PYFLINK_PYTHON=/opt/venv/bin/python \ - PYTHONUNBUFFERED=1 - -RUN printf "[global]\ntrusted-host = pypi.org\n\tfiles.pythonhosted.org\ncert = /etc/ssl/certs/ca-certificates.crt\n" > /etc/pip.conf - -COPY requirements.txt /tmp/requirements.txt -RUN pip install -r /tmp/requirements.txt && \ - pip install "apache-flink==1.19.3" - - -RUN curl -fSL https://repo1.maven.org/maven2/org/apache/kafka/kafka-clients/3.7.0/kafka-clients-3.7.0.jar \ - -o /opt/flink/lib/kafka-clients-3.7.0.jar - -RUN curl -fSL https://repo1.maven.org/maven2/org/apache/flink/flink-connector-kafka/3.2.0-1.19/flink-connector-kafka-3.2.0-1.19.jar \ - -o /opt/flink/lib/flink-connector-kafka-3.2.0-1.19.jar - -WORKDIR /opt/app -COPY flink_app/main.py /opt/app/main.py -COPY flink_app/core /opt/app/core -COPY flink_app/io_mod /opt/app/io_mod -COPY flink_app/config /opt/app/config -COPY flink_app/api /opt/app/api - -RUN mkdir -p /opt/app/secrets && \ - chown -R flink:flink /opt/app /opt/flink /opt/app/secrets && \ - chmod -R g+rwX /opt/app && \ - chmod 775 /opt/app/secrets - -# Copy and set up entrypoint script -COPY entrypoint.sh /usr/local/bin/entrypoint.sh -RUN chmod +x /usr/local/bin/entrypoint.sh - -ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] -CMD ["jobmanager"] - -ENV KAFKA_BROKERS=kafka:9092 \ - IN_TOPIC=sensors \ - OUT_TOPIC=event_logs_sensors \ - KAFKA_GROUP_ID=flink-device-pipeline \ - PYTHONPATH=/opt/app \ - PYFLINK_CLIENT_EXECUTABLE=/opt/venv/bin/python \ - PYFLINK_PYTHON=/opt/venv/bin/python diff --git a/services/sensorGuard/sensorGuard/README.md b/services/sensorGuard/sensorGuard/README.md deleted file mode 100644 index e69de29bb..000000000 diff --git a/services/sensorGuard/sensorGuard/docker-compose.yml b/services/sensorGuard/sensorGuard/docker-compose.yml deleted file mode 100644 index bacbeb99d..000000000 --- a/services/sensorGuard/sensorGuard/docker-compose.yml +++ /dev/null @@ -1,42 +0,0 @@ -version: "3.9" - -services: - jobmanager: - build: - context: . - dockerfile: Dockerfile.flink - container_name: flink-jobmanager - command: jobmanager - ports: - - "8081:8081" - environment: - - JOB_MANAGER_RPC_ADDRESS=jobmanager - - KAFKA_BROKERS=kafka:9092 - - KAFKA_GROUP_ID=flink-device-pipeline - networks: - - ag_cloud - volumes: - - ./secrets:/opt/app/secrets - - taskmanager: - build: - context: . - dockerfile: Dockerfile.flink - container_name: flink-taskmanager - command: taskmanager -D taskmanager.numberOfTaskSlots=4 - depends_on: - - jobmanager - environment: - - JOB_MANAGER_RPC_ADDRESS=jobmanager - - KAFKA_BROKERS=kafka:9092 - - taskmanager.numberOfTaskSlots=4 - - KAFKA_GROUP_ID=flink-device-pipeline - networks: - - ag_cloud - volumes: - - ./secrets:/opt/app/secrets - -networks: - ag_cloud: - external: true - name: agcloud_ag_cloud diff --git a/services/sensorGuard/sensorGuard/entrypoint.sh b/services/sensorGuard/sensorGuard/entrypoint.sh deleted file mode 100644 index a301d88f6..000000000 --- a/services/sensorGuard/sensorGuard/entrypoint.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash -set -e - -# Fix permissions for secrets directory if it exists (runs as root) -if [ -d "/opt/app/secrets" ]; then - echo "Fixing permissions for /opt/app/secrets..." - chown -R flink:flink /opt/app/secrets - chmod 775 /opt/app/secrets -fi - -# Call the original Flink entrypoint -exec /docker-entrypoint.sh "$@" diff --git a/services/sensorGuard/sensorGuard/flink_app/__init__.py b/services/sensorGuard/sensorGuard/flink_app/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/services/sensorGuard/sensorGuard/flink_app/api/__init__.py b/services/sensorGuard/sensorGuard/flink_app/api/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/services/sensorGuard/sensorGuard/flink_app/api/auth.py b/services/sensorGuard/sensorGuard/flink_app/api/auth.py deleted file mode 100644 index ed57b5d02..000000000 --- a/services/sensorGuard/sensorGuard/flink_app/api/auth.py +++ /dev/null @@ -1,74 +0,0 @@ -import os -import pathlib -import requests -import time - -# === CONFIG === -DB_API_BASE = os.getenv("DB_API_BASE", "http://db_api_service:8001") -DB_API_TOKEN_FILE = os.getenv("DB_API_TOKEN_FILE", "/opt/app/secrets/db_api_token") -DB_API_SERVICE_NAME = os.getenv("DB_API_SERVICE_NAME", "flink_job_sensors") -ROTATE_IF_EXISTS = True # Can be set to False if token rotation is not desired on restart - -# === PATH HELPERS === -def _safe_join_url(base: str, path: str) -> str: - return f"{base.rstrip('/')}/{path.lstrip('/')}" - -def _read_token_from_file(path: str) -> str | None: - try: - p = pathlib.Path(path) - if p.exists(): - token = p.read_text(encoding="utf-8").strip() - if token and len(token) > 10: - return token - except Exception: - pass - return None - -def _write_token_to_file(path: str, token: str) -> None: - p = pathlib.Path(path) - p.parent.mkdir(parents=True, exist_ok=True) - p.write_text(token, encoding="utf-8") - print(f"[AUTH] Token saved to {path}", flush=True) - -# === FETCH LOGIC === -def _fetch_token_via_bootstrap(base: str, retries: int = 3, backoff: float = 1.0) -> str | None: - url = _safe_join_url(base, "/auth/_dev_bootstrap") - payload = {"service_name": DB_API_SERVICE_NAME, "rotate_if_exists": ROTATE_IF_EXISTS} - - for attempt in range(1, retries + 1): - try: - r = requests.post(url, json=payload, timeout=10) - if r.status_code not in (200, 201): - print(f"[AUTH] Bootstrap failed ({r.status_code}): {r.text[:200]}", flush=True) - time.sleep(backoff * attempt) - continue - - data = r.json() - raw = (data.get("service_account", {}) or {}).get("raw_token") \ - or (data.get("service_account", {}) or {}).get("token") - - if raw and isinstance(raw, str) and raw.strip() and "***" not in raw: - print("[AUTH] Token fetched successfully", flush=True) - return raw.strip() - except Exception as e: - print(f"[AUTH] Exception: {e}", flush=True) - time.sleep(backoff * attempt) - print("[AUTH] Failed to bootstrap service token", flush=True) - return None - -# === PUBLIC API === -def get_access_token(base_url: str | None = None) -> str: - """ - Loads token from file if exists, otherwise bootstraps new one via /auth/_dev_bootstrap. - Returns a valid token string. - """ - base = base_url or DB_API_BASE - token = _read_token_from_file(DB_API_TOKEN_FILE) - if token: - return token - - new_token = _fetch_token_via_bootstrap(base) - if new_token: - _write_token_to_file(DB_API_TOKEN_FILE, new_token) - return new_token - raise RuntimeError("[AUTH] Could not obtain or save service token") diff --git a/services/sensorGuard/sensorGuard/flink_app/api/devices_client.py b/services/sensorGuard/sensorGuard/flink_app/api/devices_client.py deleted file mode 100644 index 7dfa2924f..000000000 --- a/services/sensorGuard/sensorGuard/flink_app/api/devices_client.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -api/devices_client.py ------------------------------------ -Fetches all active sensors (devices) from the API -and returns their IDs and models. -""" -import requests -from typing import Iterable, Tuple - - -def list_active_sensors(api_base: str, token: str, timeout: float = 10.0) -> Iterable[str]: - """ - Fetch all sensors from the devices_sensor table. - - Args: - api_base: Base URL of the API (e.g., "http://localhost:8001") - token: Access token (returned from get_access_token) - timeout: HTTP request timeout in seconds - - Yields: - Device IDs as strings. - """ - url = f"{api_base.rstrip('/')}/api/tables/devices_sensor" - headers = {"X-Service-Token": token} - - try: - response = requests.get(url, headers=headers, timeout=timeout) - if response.status_code != 200: - print(f"[DEVICES] Failed ({response.status_code}): {response.text[:120]}") - return - - items = (response.json() or {}).get("rows", []) - print(f"[DEVICES] Fetched {len(items)} sensors from API") - for dev in items: - # All sensors in table are active, just return the IDs - device_id = dev.get("id", "") - if device_id: - print(f"[DEVICES] Adding sensor: id={device_id}") - yield str(device_id) - - except requests.RequestException as e: - print(f"[DEVICES] Request error: {e}") - return - - -def get_sensors_last_seen(api_base: str, token: str, timeout: float = 10.0): - """ - Fetch all sensors from devices_sensor with their last_seen timestamp. - Used for silence sweep. - - Args: - api_base: Base URL of the API. - token: Service token. - timeout: Request timeout. - - Returns: - List of dicts like: [{"id": "dev-a", "sensor_type": "temp", "last_seen": "2025-11-11T13:00:00Z"}, ...] - """ - url = f"{api_base.rstrip('/')}/api/tables/devices_sensor" - headers = {"X-Service-Token": token} - - try: - response = requests.get(url, headers=headers, timeout=timeout) - if response.status_code != 200: - print(f"[DEVICES] Failed ({response.status_code}): {response.text[:120]}") - return [] - - items = (response.json() or {}).get("rows", []) - print(f"[DEVICES] Fetched {len(items)} sensors (with last_seen) from API") - return items - - except requests.RequestException as e: - print(f"[DEVICES][ERROR] {e}") - return [] diff --git a/services/sensorGuard/sensorGuard/flink_app/api/devices_updater.py b/services/sensorGuard/sensorGuard/flink_app/api/devices_updater.py deleted file mode 100644 index 534d2893f..000000000 --- a/services/sensorGuard/sensorGuard/flink_app/api/devices_updater.py +++ /dev/null @@ -1,30 +0,0 @@ -import requests -from datetime import datetime, timezone -from api.auth import get_access_token - -def update_device_last_seen(device_id: str): - """ - Updates the 'last_seen' field for a specific device in the devices_sensor table. - Uses PATCH /api/tables/devices_sensor - """ - api_base = "http://host.docker.internal:8001" - token = get_access_token(api_base) - headers = { - "X-Service-Token": token, - "Content-Type": "application/json" - } - url = f"{api_base}/api/tables/devices_sensor" - - payload = { - "keys": {"id": device_id}, - "data": {"last_seen": datetime.now(timezone.utc).isoformat()} - } - - try: - r = requests.patch(url, json=payload, headers=headers, timeout=10) - if r.status_code == 200: - print(f"[DB-UPDATER] Updated last_seen for device {device_id}") - else: - print(f"[DB-UPDATER] Failed ({r.status_code}): {r.text}") - except Exception as e: - print(f"[DB-UPDATER] Exception: {e}") diff --git a/services/sensorGuard/sensorGuard/flink_app/config/__init__.py b/services/sensorGuard/sensorGuard/flink_app/config/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/services/sensorGuard/sensorGuard/flink_app/config/rules.yaml b/services/sensorGuard/sensorGuard/flink_app/config/rules.yaml deleted file mode 100644 index dd718a617..000000000 --- a/services/sensorGuard/sensorGuard/flink_app/config/rules.yaml +++ /dev/null @@ -1,25 +0,0 @@ -defaults: - expected_interval_seconds: 60 - keepalive_miss_factor: 2 - prolonged_silence_seconds: 120 - silence_sweep_interval_seconds: 120 - -ranges: - temperature: { min: 21, max: 29 } - Ambient_Temperature: { min: 21, max: 29 } - humidity: { min: 0, max: 100 } - Humidity: { min: 0, max: 100 } - soil_moist: { min: 0, max: 100 } - Soil_Moisture: { min: 0, max: 100 } - unknown_sensor: { min: 21, max: 29 } # Default range - using temperature range for testing - -stuck: - epsilon: 0.1 - min_run_length: 3 - min_duration_seconds: 600 - -features: - corrupted: true - out_of_range: true - stuck_sensor: true - silence: true \ No newline at end of file diff --git a/services/sensorGuard/sensorGuard/flink_app/config/settings.py b/services/sensorGuard/sensorGuard/flink_app/config/settings.py deleted file mode 100644 index 59853c063..000000000 --- a/services/sensorGuard/sensorGuard/flink_app/config/settings.py +++ /dev/null @@ -1,21 +0,0 @@ -import os -from pathlib import Path - -BASE_DIR = Path(__file__).resolve().parent.parent - -class Settings: - # --- API Configuration --- - DEVICES_API_BASE = os.getenv("DEVICES_API_BASE", "http://host.docker.internal:8001") - DEVICES_API_TOKEN = os.getenv("DEVICES_API_TOKEN", None) - - # --- Kafka Configuration --- - KAFKA_BROKERS = os.getenv("KAFKA_BROKERS", "kafka:9092") - IN_TOPIC = os.getenv("IN_TOPIC", "sensors") - OUT_TOPIC = os.getenv("OUT_TOPIC", "event_logs_sensors") - KAFKA_GROUP_ID = os.getenv("KAFKA_GROUP_ID", "flink-device-pipeline") - - # --- Flink runtime paths --- - PYTHON_EXEC = os.getenv("PYFLINK_PYTHON", "/opt/venv/bin/python") - RULES_FILE = BASE_DIR / "config" / "rules.yaml" - -settings = Settings() diff --git a/services/sensorGuard/sensorGuard/flink_app/core/__init__.py b/services/sensorGuard/sensorGuard/flink_app/core/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/services/sensorGuard/sensorGuard/flink_app/core/engine.py b/services/sensorGuard/sensorGuard/flink_app/core/engine.py deleted file mode 100644 index c4669b16b..000000000 --- a/services/sensorGuard/sensorGuard/flink_app/core/engine.py +++ /dev/null @@ -1,181 +0,0 @@ -# core/engine.py - -from .state import StateStore -from .types import Event, Alert -from .rules import corrupted, out_of_range, stuck_sensor -from datetime import datetime, timezone -import time - -from api.devices_updater import update_device_last_seen -from api.devices_client import get_sensors_last_seen -from api.auth import get_access_token - - -class Engine: - def __init__(self, cfg, writer, state: StateStore | None = None): - """ - cfg: dict read from rules.yaml (includes features/ranges/defaults/stuck) - writer: either a single object with write(alert) or a list of writers - """ - self.cfg = cfg - self.writers = writer if isinstance(writer, (list, tuple)) else [writer] - self.state = state or StateStore() - - # --- API info & persistent token --- - self.api_base = "http://host.docker.internal:8001" - self.token = get_access_token(self.api_base) - if self.token: - print("[ENGINE] Access token acquired successfully.") - else: - print("[ENGINE][WARN] Failed to get API token at startup.") - - # --- Utilities --------------------------------------------------------- - - def _emit(self, alert: Alert): - for w in self.writers: - w.write(alert) - - def _open_once(self, dev_state, alert: Alert): - """Open an event only if it’s not already open for the same issue_type.""" - if alert.issue_type not in dev_state.open_alerts: - dev_state.open_alerts[alert.issue_type] = alert - print(f"[ENGINE] Opening new alert: {alert.issue_type} for device {alert.device_id}") - self._emit(alert) - - def _close_if_open(self, dev_state, issue_type: str, ts): - """Close an open event (if exists) and update end_ts.""" - if issue_type in dev_state.open_alerts: - a = dev_state.open_alerts.pop(issue_type) - a.end_ts = ts - print(f"[ENGINE] Closing alert: {issue_type} for device {a.device_id}") - self._emit(a) - - def _close_all_keepalive_alerts(self, dev_state, ts): - """Close missing_keepalive alert when sensor sends valid data.""" - print(f"[ENGINE] Checking open alerts before close_all_keepalive_alerts: {list(dev_state.open_alerts.keys())}") - - if "missing_keepalive" in dev_state.open_alerts: - a = dev_state.open_alerts.pop("missing_keepalive") - a.end_ts = ts - print(f"[ENGINE] Closing missing_keepalive alert (sensor back online) for {a.device_id}") - self._emit(a) - else: - print(f"[ENGINE] No missing_keepalive alert to close for this device") - - # ---------------------------------------------------------------------- - - def sweep_silence(self, now): - """ - Periodic silence check based on DB 'devices_sensor.last_seen'. - Checks for missing_keepalive (not prolonged_silence). - """ - print("[ENGINE] Starting silence sweep (via DB API)...") - - # Fetch sensors via API (single attempt) - sensors = get_sensors_last_seen(self.api_base, self.token) - - if not sensors: - print("[ENGINE][ERROR] No sensors retrieved. Skipping silence sweep.") - return - - expected = self.cfg.get("expected_interval_seconds", 60) - miss_factor = self.cfg.get("keepalive_miss_factor", 3) - miss_thr = miss_factor * expected - print(f"[ENGINE] Checking {len(sensors)} sensors for missing keepalive > {miss_thr}s") - - for s in sensors: - sensor_id = s.get("id") - last_seen_str = s.get("last_seen") - if not sensor_id or not last_seen_str: - continue - - try: - last_seen = datetime.fromisoformat(last_seen_str.replace("Z", "+00:00")) - except Exception: - print(f"[ENGINE][WARN] Invalid timestamp for {sensor_id}: {last_seen_str}") - continue - - gap = (now - last_seen).total_seconds() - - # Check for missing_keepalive only - dev_state = self.state.get(sensor_id) - if dev_state and gap >= miss_thr and "missing_keepalive" not in dev_state.open_alerts: - alert = Alert( - issue_type="missing_keepalive", - device_id=sensor_id, - sensor_type=s.get("sensor_type", "unknown"), - site_id=None, - severity="critical", - start_ts=last_seen, - end_ts=None, - details={"gap_sec": int(gap), "expected": expected}, - ) - print(f"[ENGINE] Sensor {sensor_id} missing keepalive for {int(gap)}s — creating alert.") - dev_state.open_alerts["missing_keepalive"] = alert - self._emit(alert) - elif dev_state and gap < miss_thr and "missing_keepalive" in dev_state.open_alerts: - # Close the alert if gap is back to normal - alert = dev_state.open_alerts.pop("missing_keepalive") - alert.end_ts = now - print(f"[ENGINE] Sensor {sensor_id} keepalive restored — closing alert.") - self._emit(alert) - - # ---------------------------------------------------------------------- - - def process_event(self, ev: Event): - """Process a single event and manage open/close logic for alerts.""" - print(f"[ENGINE] Processing event: device_id={ev.device_id}, msg_type={ev.msg_type}, sensor_type={ev.sensor_type}") - - if not self.state.is_known_device(ev.device_id): - print(f"[ENGINE] Unknown device {ev.device_id} - skipping") - return - - print(f"[ENGINE] Known device {ev.device_id} - processing") - feats = (self.cfg.get("features") or {}) - dev = self.state.get(ev.device_id) - - # === Step 1: Update device state and DB === - print(f"[ENGINE] Updating device {ev.device_id} last_seen_ts from {dev.last_seen_ts} to {ev.ts}") - dev.last_seen_ts = ev.ts - dev.last_value = ev.value - - # --- Update API record --- - update_device_last_seen(ev.device_id) - - if ev.sensor_type and ev.sensor_type != "unknown_sensor": - dev.sensor_type = ev.sensor_type - - # === Step 2: Close keepalive-related alerts === - self._close_all_keepalive_alerts(dev, ev.ts) - - # === Step 3: Corrupted readings === - if feats.get("corrupted", True): - a = corrupted(ev, self.cfg) - if a: - print(f"[ENGINE] Corrupted reading detected for device {ev.device_id}") - self._open_once(dev, a) - self._close_if_open(dev, "out_of_range", ev.ts) - self._close_if_open(dev, "stuck_sensor", ev.ts) - return - print(f"[ENGINE] No corrupted reading for device {ev.device_id}") - - # === Step 4: Out-of-range checks === - if feats.get("out_of_range", True): - print(f"[ENGINE] Checking out_of_range for device {ev.device_id}, value={ev.value}") - a = out_of_range(ev, self.cfg) - if a: - print(f"[ENGINE] Out-of-range detected for device {ev.device_id}: {a}") - self._open_once(dev, a) - else: - print(f"[ENGINE] Value in range for device {ev.device_id}") - self._close_if_open(dev, "out_of_range", ev.ts) - - # === Step 5: Stuck sensor checks === - if feats.get("stuck_sensor", True): - print(f"[ENGINE] Checking stuck_sensor for device {ev.device_id}") - a = stuck_sensor(ev, dev, self.cfg) - if a: - print(f"[ENGINE] Stuck sensor detected for device {ev.device_id}") - self._open_once(dev, a) - else: - self._close_if_open(dev, "stuck_sensor", ev.ts) diff --git a/services/sensorGuard/sensorGuard/flink_app/core/rules.py b/services/sensorGuard/sensorGuard/flink_app/core/rules.py deleted file mode 100644 index 36f22d537..000000000 --- a/services/sensorGuard/sensorGuard/flink_app/core/rules.py +++ /dev/null @@ -1,159 +0,0 @@ -from typing import Optional, Dict, Any -from datetime import timezone -from .types import Event, Alert, DeviceState - -def _utc(dt): - """Return datetime with UTC tzinfo.""" - return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) - -def out_of_range(event: Event, cfg: Dict[str, Any]) -> Optional[Alert]: - """Check if sensor value is outside configured min/max.""" - if event.msg_type not in ["reading", "telemetry"] or event.value is None: - return None - rngs = (cfg or {}).get("ranges", {}) - lim = rngs.get(event.sensor_type, {}) - vmin, vmax = lim.get("min"), lim.get("max") - print(f"[RULES] out_of_range: sensor_type={event.sensor_type}, value={event.value}, lim={lim}, vmin={vmin}, vmax={vmax}") - if vmin is not None and event.value < vmin: - return Alert( - device_id=event.device_id, issue_type="out_of_range", - start_ts=_utc(event.ts), end_ts=None, severity="warn", - sensor_type=event.sensor_type, site_id=event.site_id, - details={"value": event.value, "min": vmin, "max": vmax} - ) - if vmax is not None and event.value > vmax: - return Alert( - device_id=event.device_id, issue_type="out_of_range", - start_ts=_utc(event.ts), end_ts=None, severity="warn", - sensor_type=event.sensor_type, site_id=event.site_id, - details={"value": event.value, "min": vmin, "max": vmax} - ) - return None - -def corrupted(event: Event, cfg: Dict[str, Any]) -> Optional[Alert]: - """Check if reading is invalid (null, non-numeric, bad quality).""" - if event.msg_type not in ["reading", "telemetry"]: - return None - if event.value is None: - return Alert( - device_id=event.device_id, issue_type="corrupted", - start_ts=_utc(event.ts), end_ts=None, severity="error", - sensor_type=event.sensor_type, site_id=event.site_id, - details={"reason": "null value"} - ) - - if not isinstance(event.value, (int, float)): - return Alert( - device_id=event.device_id, issue_type="corrupted", - start_ts=_utc(event.ts), end_ts=None, severity="error", - sensor_type=event.sensor_type, site_id=event.site_id, - details={"reason": "non-numeric"} - ) - - if event.quality and event.quality != "ok": - return Alert( - device_id=event.device_id, issue_type="corrupted", - start_ts=_utc(event.ts), end_ts=None, severity="error", - sensor_type=event.sensor_type, site_id=event.site_id, - details={"quality": event.quality} - ) - return None - -def stuck_sensor(event: Event, state: DeviceState, cfg) -> Alert | None: - """Check if sensor value is stuck (no change over time).""" - if event.msg_type not in ["reading", "telemetry"] or event.value is None: - state.last_seen_ts = _utc(event.ts) - return None - - eps = cfg.get("stuck", {}).get("epsilon", 0.1) - min_run = cfg.get("stuck", {}).get("min_run_length", 6) - min_dur = cfg.get("stuck", {}).get("min_duration_seconds", 1800) # Default to 1800 instead of 0! - - # Debug log - if state.last_value is None: - print(f"[STUCK_SENSOR] Config for {event.device_id}: eps={eps}, min_run={min_run}, min_dur={min_dur}") - - if state.last_value is None: - state.last_value = event.value - state.run_length = 1 - state.last_seen_ts = _utc(event.ts) - state.stuck_since_ts = None - return None - - if abs(event.value - state.last_value) < eps: - state.run_length += 1 - if state.stuck_since_ts is None: - state.stuck_since_ts = _utc(event.ts) - print(f"[STUCK_SENSOR] Device {event.device_id}: run_length={state.run_length}, value={event.value}, stuck_since={state.stuck_since_ts}") - else: - print(f"[STUCK_SENSOR] Device {event.device_id}: value changed {state.last_value} -> {event.value}, resetting run_length") - state.run_length = 1 - state.stuck_since_ts = None - state.last_value = event.value - - state.last_seen_ts = _utc(event.ts) - - if state.run_length >= min_run: - if min_dur <= 0: - print(f"[STUCK_SENSOR] Device {event.device_id}: ALERT triggered (no min_dur)") - return Alert( - device_id=event.device_id, issue_type="stuck_sensor", - start_ts=state.stuck_since_ts or _utc(event.ts), end_ts=None, severity="warn", - sensor_type=event.sensor_type, site_id=event.site_id, - details={"run_length": state.run_length, "epsilon": eps} - ) - else: - dur = (_utc(event.ts) - (state.stuck_since_ts or _utc(event.ts))).total_seconds() - print(f"[STUCK_SENSOR] Device {event.device_id}: run_length={state.run_length} >= {min_run}, dur={dur}s, min_dur={min_dur}s") - if dur >= min_dur: - print(f"[STUCK_SENSOR] Device {event.device_id}: ALERT triggered (dur >= min_dur)") - return Alert( - device_id=event.device_id, issue_type="stuck_sensor", - start_ts=state.stuck_since_ts, end_ts=None, severity="warn", - sensor_type=event.sensor_type, site_id=event.site_id, - details={"run_length": state.run_length, "duration_sec": int(dur), "epsilon": eps} - ) - return None - -def silence_checks(event: Event, state: DeviceState, cfg) -> list[Alert]: - """Check for missing keepalive or prolonged silence alerts.""" - alerts: list[Alert] = [] - now_ts = _utc(event.ts) - - expected = cfg.get("expected_interval_seconds", 60) - miss_factor = cfg.get("keepalive_miss_factor", 3) - silence_sec = cfg.get("prolonged_silence_seconds", 1800) - - if state.last_seen_ts is None: - state.last_seen_ts = now_ts - return alerts - - gap = (now_ts - state.last_seen_ts).total_seconds() - - if gap >= silence_sec and "prolonged_silence" not in state.open_alerts: - a = Alert(device_id=event.device_id, issue_type="prolonged_silence", - start_ts=state.last_seen_ts, end_ts=None, severity="error", - sensor_type=event.sensor_type, site_id=event.site_id, - details={"gap_sec": int(gap)}) - state.open_alerts["prolonged_silence"] = a - alerts.append(a) - elif gap < silence_sec and "prolonged_silence" in state.open_alerts: - a = state.open_alerts.pop("prolonged_silence") - a.end_ts = now_ts - alerts.append(a) - - miss_thr = miss_factor * expected - if gap >= miss_thr and gap < silence_sec and "missing_keepalive" not in state.open_alerts: - a = Alert(device_id=event.device_id, issue_type="missing_keepalive", - start_ts=state.last_seen_ts, end_ts=None, severity="critical", - sensor_type=event.sensor_type, site_id=event.site_id, - details={"gap_sec": int(gap), "expected": expected}) - state.open_alerts["missing_keepalive"] = a - alerts.append(a) - elif (gap < miss_thr or gap >= silence_sec) and "missing_keepalive" in state.open_alerts: - a = state.open_alerts.pop("missing_keepalive") - a.end_ts = now_ts - alerts.append(a) - - state.last_seen_ts = now_ts - return alerts diff --git a/services/sensorGuard/sensorGuard/flink_app/core/state.py b/services/sensorGuard/sensorGuard/flink_app/core/state.py deleted file mode 100644 index 5efa13420..000000000 --- a/services/sensorGuard/sensorGuard/flink_app/core/state.py +++ /dev/null @@ -1,32 +0,0 @@ -from .types import DeviceState -from typing import Dict, Set - -class StateStore: - def __init__(self): - self._devices: Dict[str, DeviceState] = {} - self._known_device_ids: Set[str] = set() - - @property - def devices(self): - """Expose internal devices dictionary (read-only).""" - return self._devices - - def add_device(self, device_id: str, sensor_type: str = None) -> None: - """Initialize state for a known device.""" - device_id = str(device_id) - self._known_device_ids.add(device_id) - if device_id not in self._devices: - self._devices[device_id] = DeviceState(device_id=device_id, sensor_type=sensor_type) - - def get(self, device_id: str) -> DeviceState: - """Return state for known device, or None if unknown.""" - device_id = str(device_id) - return self._devices.get(device_id) - - def is_known_device(self, device_id: str) -> bool: - """Check if device was loaded from API.""" - return str(device_id) in self._known_device_ids - - def all_states(self): - """Iterator over all registered device states.""" - return self._devices.items() diff --git a/services/sensorGuard/sensorGuard/flink_app/core/types.py b/services/sensorGuard/sensorGuard/flink_app/core/types.py deleted file mode 100644 index b256537fb..000000000 --- a/services/sensorGuard/sensorGuard/flink_app/core/types.py +++ /dev/null @@ -1,35 +0,0 @@ -from dataclasses import dataclass, field -from typing import Optional, Dict -from datetime import datetime - -@dataclass -class Event: - ts: datetime - device_id: str - sensor_type: str - site_id: Optional[str] - msg_type: str # "reading" | "keepalive" - value: Optional[float] - seq: Optional[int] - quality: Optional[str] # "ok"/"corrupted"/None - -@dataclass -class Alert: - device_id: str - issue_type: str - start_ts: datetime - end_ts: Optional[datetime] - severity: str - sensor_type: Optional[str] = None - site_id: Optional[str] = None - details: Dict = field(default_factory=dict) - -@dataclass -class DeviceState: - device_id: str - sensor_type: Optional[str] = None - last_seen_ts: Optional[datetime] = None - last_value: Optional[float] = None - run_length: int = 0 - stuck_since_ts: Optional[datetime] = None - open_alerts: Dict[str, "Alert"] = field(default_factory=dict) diff --git a/services/sensorGuard/sensorGuard/flink_app/io_mod/__init__.py b/services/sensorGuard/sensorGuard/flink_app/io_mod/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/services/sensorGuard/sensorGuard/flink_app/io_mod/writer_console.py b/services/sensorGuard/sensorGuard/flink_app/io_mod/writer_console.py deleted file mode 100644 index 2f62cb0d4..000000000 --- a/services/sensorGuard/sensorGuard/flink_app/io_mod/writer_console.py +++ /dev/null @@ -1,11 +0,0 @@ -from core.types import Alert - -class ConsoleWriter: - def write(self, alert: Alert) -> None: - end = alert.end_ts.isoformat() if alert.end_ts else "-" - print( - f"[ALERT] type={alert.issue_type} dev={alert.device_id} " - f"sensor={alert.sensor_type} value={alert.details.get('value')} " - f"range=[{alert.details.get('min')},{alert.details.get('max')}] " - f"ts={alert.start_ts.isoformat()} sev={alert.severity}" - ) diff --git a/services/sensorGuard/sensorGuard/flink_app/io_mod/writer_kafka.py b/services/sensorGuard/sensorGuard/flink_app/io_mod/writer_kafka.py deleted file mode 100644 index 2ffb6cfd4..000000000 --- a/services/sensorGuard/sensorGuard/flink_app/io_mod/writer_kafka.py +++ /dev/null @@ -1,123 +0,0 @@ -import os -import json -from datetime import datetime -from typing import Any, Dict, Optional - -from kafka import KafkaProducer -from core.types import Alert -from config.settings import settings - - -# Convert Alert dataclass to a stable dict for JSON serialization and downstream consumers. -def _alert_to_dict(alert: Alert) -> Dict[str, Any]: - """ - Convert Alert to an ordered, serialization-friendly dict. - Suitable for downstream consumers (DB / API / BI). - """ - details: Dict[str, Any] = getattr(alert, "details", {}) or {} - - def iso(dt: Optional[datetime]) -> Optional[str]: - return dt.isoformat() if dt else None - - d = { - "issue_type": getattr(alert, "issue_type", None), - "device_id": getattr(alert, "device_id", None), - "severity": getattr(alert, "severity", None), - "start_ts": iso(getattr(alert, "start_ts", None)), - "details": details, - } - - end_ts = getattr(alert, "end_ts", None) - if end_ts is not None: - d["end_ts"] = iso(end_ts) - - return d - - - - -# Kafka writer: send Alert objects as JSON value-only messages. -# Lazy-initialize producer to avoid early network/socket creation. -class KafkaWriter: - """ - Write Alerts to Kafka as JSON (value-only). - Defaults: - - topic from OUT_TOPIC env or 'dev-robot-telemetry-raw' - - brokers from KAFKA_BROKERS env or 'kafka:9092' - """ - def __init__( - self, - topic: str | None = None, - brokers: str | None = None, - linger_ms: int = 10, - acks: str = "all", - retries: int = 5, - ) -> None: - # Topic and bootstrap configuration. - self.topic = topic or settings.OUT_TOPIC - self.bootstrap = brokers or settings.KAFKA_BROKERS - # Producer tuning parameters. - self.linger_ms = linger_ms - self.acks = acks - self.retries = retries - # Producer instance created on first use. - self._producer: Optional[KafkaProducer] = None - - # Ensure a KafkaProducer exists; create it lazily with safe JSON serializer. - def _ensure_producer(self) -> None: - """Lazy-init KafkaProducer with JSON serializer and configured options.""" - if self._producer is None: - print(f"[KafkaWriter] Creating KafkaProducer: brokers={self.bootstrap}, topic={self.topic}") - try: - self._producer = KafkaProducer( - bootstrap_servers=self.bootstrap, - value_serializer=lambda v: json.dumps(v, ensure_ascii=False).encode("utf-8"), - linger_ms=self.linger_ms, - acks=self.acks, - retries=self.retries, - ) - print("[KafkaWriter] KafkaProducer created successfully.") - except Exception as e: - print(f"[KafkaWriter][ERROR] Failed to create KafkaProducer: {e!r}") - raise - - # Serialize and send an Alert to Kafka; log errors to stdout. - def write(self, alert: Alert) -> None: - """Send an Alert to Kafka (non-blocking send).""" - print(f"[KafkaWriter] write() called for alert: device={getattr(alert, 'device_id', None)}, issue={getattr(alert, 'issue_type', None)}") - try: - if getattr(alert, "issue_type", None) == "unknown_device": - print(f"[KafkaWriter] Skipping unknown device alert for {alert.device_id}") - return - - self._ensure_producer() - payload = _alert_to_dict(alert) - print(f"[KafkaWriter] Sending payload: {payload}") - self._producer.send(self.topic, payload) - - print( - f"[KafkaWriter] Alert sent → topic='{self.topic}', " - f"device='{alert.device_id}', issue='{alert.issue_type}', " - f"time={payload.get('start_ts')}" - ) - except Exception as e: - print(f"[KafkaWriter][ERROR] send failed: {e!r}") - - # Flush producer buffers if the producer exists. - def flush(self) -> None: - """Flush any buffered messages to Kafka.""" - try: - if self._producer: - self._producer.flush() - except Exception as e: - print(f"[KafkaWriter] flush failed: {e!r}") - - # Flush and close the underlying producer if present. - def close(self) -> None: - """Gracefully flush and close the Kafka producer.""" - try: - if self._producer: - self._producer.flush() - self._producer.close() - except Exception as e: - print(f"[KafkaWriter] close failed: {e!r}") diff --git a/services/sensorGuard/sensorGuard/flink_app/main.py b/services/sensorGuard/sensorGuard/flink_app/main.py deleted file mode 100644 index b5292b58e..000000000 --- a/services/sensorGuard/sensorGuard/flink_app/main.py +++ /dev/null @@ -1,221 +0,0 @@ -import os, json, yaml, threading, time -from datetime import datetime, timezone -from pathlib import Path - -from pyflink.datastream import StreamExecutionEnvironment -from pyflink.datastream.connectors.kafka import KafkaSource -from pyflink.common.serialization import SimpleStringSchema -from pyflink.common.watermark_strategy import WatermarkStrategy -from pyflink.common.typeinfo import Types -from pyflink.datastream.functions import MapFunction, ProcessFunction, RuntimeContext - -from core.engine import Engine -from core.types import Event -from io_mod.writer_console import ConsoleWriter -from io_mod.writer_kafka import KafkaWriter -from api.auth import get_access_token -from api.devices_client import list_active_sensors -from core.state import StateStore - - -DROP_INVALID = True - - -# ------------------------------------------------------------- -# Convert incoming Kafka message to Event -# ------------------------------------------------------------- -def to_event(obj: dict) -> Event | None: - if not isinstance(obj, dict): - print("[to_event] Invalid object type, expected dict.") - return None - - ts = datetime.now(timezone.utc) - device_id = obj.get("id") - sensor_type = obj.get("sensor_type") or obj.get("sensor_name", "unknown_sensor") - - if not device_id: - if DROP_INVALID: - print("[to_event] Dropping event due to missing device_id.") - return None - device_id = "unknown_device" - else: - device_id = str(device_id) - - value_str = obj.get("value") - try: - value = float(value_str) if value_str is not None else None - except ValueError: - print(f"[to_event] Invalid numeric value: {value_str}") - value = None - - print(f"[to_event] Parsed event: device_id={device_id}, sensor_type={sensor_type}") - - return Event( - ts=ts, - device_id=device_id, - sensor_type=sensor_type, - site_id=obj.get("site_id"), - msg_type=obj.get("msg_type", "reading"), - value=value, - seq=obj.get("seq"), - quality=obj.get("quality"), - ) - - -# ------------------------------------------------------------- -# Engine Mapper: applies Engine logic for each event -# ------------------------------------------------------------- -class EngineMapper(MapFunction): - def __init__(self, cfg_path: str, state: StateStore): - self.cfg_path = cfg_path - self.state = state - self.engine = None - - def open(self, runtime_context: RuntimeContext): - with open(self.cfg_path, "r", encoding="utf-8") as f: - cfg = yaml.safe_load(f) or {} - writers = [ConsoleWriter(), KafkaWriter()] - self.engine = Engine(cfg, writers, state=self.state) - - def map(self, ev: Event) -> str: - if ev is None: - return "" - print(f"[EngineMapper] Processing event for device_id={ev.device_id}") - self.engine.process_event(ev) - return ev.device_id or "" - - -# ------------------------------------------------------------- -# Silence Sweep Process (background thread) -# ------------------------------------------------------------- -class SilenceSweepProcess(ProcessFunction): - def __init__(self, cfg_path: str, interval_sec: int, state: StateStore): - self.cfg_path = cfg_path - self.interval_sec = interval_sec - self.state = state - self.engine = None - self._thread = None - self._stop = False - - def open(self, runtime_context: RuntimeContext): - print("[SilenceSweepProcess] Initializing background silence checker.") - with open(self.cfg_path, "r", encoding="utf-8") as f: - cfg = yaml.safe_load(f) or {} - - writers = [ConsoleWriter(), KafkaWriter()] - self.engine = Engine(cfg, writers, state=self.state) - - # Run silence sweep periodically in a separate thread - def loop(): - print(f"[SilenceSweep] Thread started! Interval={self.interval_sec}s") - time.sleep(self.interval_sec) - while not self._stop: - now = datetime.now(timezone.utc) - print(f"[SilenceSweep] Checking silence at {now.isoformat()}") - try: - self.engine.sweep_silence(now) - print("[SilenceSweep] Sweep completed.") - except Exception as e: - print(f"[SilenceSweep][ERROR] {e}") - time.sleep(self.interval_sec) - print("[SilenceSweep] Thread stopped.") - - self._thread = threading.Thread(target=loop, daemon=True) - self._thread.start() - - def close(self): - self._stop = True - if self._thread: - self._thread.join(timeout=2) - self._thread = None - - def process_element(self, value, ctx: 'ProcessFunction.Context'): - # No per-event logic needed - pass - - -# ------------------------------------------------------------- -# Main entry point -# ------------------------------------------------------------- -def main(): - print("=== STARTING FLINK APPLICATION ===") - base_dir = Path(__file__).resolve().parent - cfg_path = base_dir / "config" / "rules.yaml" - - # --- Load sensors from API --- - api_base = os.getenv("DEVICES_API_BASE", "http://host.docker.internal:8001") - print(f"[INIT] Fetching active sensors from {api_base}...") - - token = get_access_token(api_base) - print(f"[INIT] Token received: {'YES' if token else 'NO'}") - - shared_state = StateStore() - if token: - for device_id in list_active_sensors(api_base, token): - shared_state.add_device(device_id) - print(f"[INIT] Loaded {len(shared_state.devices)} active sensors.") - else: - print("[INIT][WARN] No token, running with empty device list.") - - # --- Flink Setup --- - bootstrap = os.getenv("KAFKA_BROKERS", "kafka:9092") - topic_in = os.getenv("IN_TOPIC", "sensors") - group_id = os.getenv("KAFKA_GROUP_ID", "flink-device-pipeline") - - env = StreamExecutionEnvironment.get_execution_environment() - env.set_parallelism(1) - env.enable_checkpointing(10_000) - - # Kafka source - source = ( - KafkaSource.builder() - .set_bootstrap_servers(bootstrap) - .set_topics(topic_in) - .set_group_id(group_id) - .set_value_only_deserializer(SimpleStringSchema()) - .build() - ) - - stream = env.from_source( - source, - WatermarkStrategy.no_watermarks(), - f"kafka-source:{topic_in}" - ) - - # --- Processing pipeline --- - def parse_json(s: str): - try: - parsed = json.loads(s) - print(f"[FLINK-KAFKA] Parsed JSON: {s[:100]}...") - return parsed - except Exception as e: - print(f"[FLINK-KAFKA] Parse error: {e}") - return None - - events = ( - stream.map(parse_json) - .filter(lambda e: e is not None) - .map(to_event) - .filter(lambda e: e is not None) - ) - - # --- Apply Engine --- - mapper = EngineMapper(str(cfg_path), shared_state) - mapped = events.map(mapper, output_type=Types.STRING()).name("engine-run") - mapped.print().name("debug-print") - - # --- Silence check background thread --- - with open(cfg_path, "r", encoding="utf-8") as f: - cfg = yaml.safe_load(f) or {} - interval = cfg.get("defaults", {}).get("silence_sweep_interval_seconds", 300) - - silence_checker = SilenceSweepProcess(str(cfg_path), interval, shared_state) - events.process(silence_checker).name("silence-sweeper") - - print("[INIT] Starting Flink job...") - env.execute("DevicePipeline-With-SilenceSweep") - - -if __name__ == "__main__": - print("=== FLINK MAIN.PY EXECUTED ===") - main() diff --git a/services/sensorGuard/sensorGuard/flink_app/resources/synthetic_stream.csv b/services/sensorGuard/sensorGuard/flink_app/resources/synthetic_stream.csv deleted file mode 100644 index 71ee492ee..000000000 --- a/services/sensorGuard/sensorGuard/flink_app/resources/synthetic_stream.csv +++ /dev/null @@ -1,523 +0,0 @@ -timestamp,id,sensor_type,site_id,msg_type,value,seq,quality -2025-09-01T08:00:00Z,hum-A2,humidity,field-01,keepalive,,181,ok -2025-09-01T08:00:00Z,hum-A2,humidity,field-01,reading,54.617,1,ok -2025-09-01T08:00:00Z,soil-A3,soil_moist,field-02,keepalive,,91,ok -2025-09-01T08:00:00Z,soil-A3,soil_moist,field-02,reading,35.313,1,ok -2025-09-01T08:00:00Z,temp-A1,temperature,field-01,keepalive,,151,ok -2025-09-01T08:00:00Z,temp-A1,temperature,field-01,reading,27.338,1,ok -2025-09-01T08:01:00Z,hum-A2,humidity,field-01,reading,55.572,2,ok -2025-09-01T08:01:00Z,temp-A1,temperature,field-01,reading,26.959,2,ok -2025-09-01T08:02:00Z,hum-A2,humidity,field-01,reading,55.767,3,ok -2025-09-01T08:02:00Z,soil-A3,soil_moist,field-02,reading,35.63,2,ok -2025-09-01T08:02:00Z,temp-A1,temperature,field-01,reading,27.112,3,ok -2025-09-01T08:03:00Z,hum-A2,humidity,field-01,reading,56.764,4,ok -2025-09-01T08:03:00Z,temp-A1,temperature,field-01,reading,27.239,4,ok -2025-09-01T08:04:00Z,hum-A2,humidity,field-01,reading,56.144,5,ok -2025-09-01T08:04:00Z,soil-A3,soil_moist,field-02,reading,35.011,3,ok -2025-09-01T08:04:00Z,temp-A1,temperature,field-01,reading,27.052,5,ok -2025-09-01T08:05:00Z,hum-A2,humidity,field-01,keepalive,,182,ok -2025-09-01T08:05:00Z,hum-A2,humidity,field-01,reading,54.829,6,ok -2025-09-01T08:05:00Z,soil-A3,soil_moist,field-02,keepalive,,92,ok -2025-09-01T08:05:00Z,temp-A1,temperature,field-01,keepalive,,152,ok -2025-09-01T08:05:00Z,temp-A1,temperature,field-01,reading,27.263,6,ok -2025-09-01T08:06:00Z,hum-A2,humidity,field-01,reading,55.477,7,ok -2025-09-01T08:06:00Z,soil-A3,soil_moist,field-02,reading,35.495,4,ok -2025-09-01T08:06:00Z,temp-A1,temperature,field-01,reading,27.314,7,ok -2025-09-01T08:07:00Z,hum-A2,humidity,field-01,reading,57.417,8,ok -2025-09-01T08:07:00Z,temp-A1,temperature,field-01,reading,27.016,8,ok -2025-09-01T08:08:00Z,hum-A2,humidity,field-01,reading,56.884,9,ok -2025-09-01T08:08:00Z,soil-A3,soil_moist,field-02,reading,35.178,5,ok -2025-09-01T08:08:00Z,temp-A1,temperature,field-01,reading,27.622,9,ok -2025-09-01T08:09:00Z,hum-A2,humidity,field-01,reading,58.258,10,ok -2025-09-01T08:09:00Z,temp-A1,temperature,field-01,reading,27.59,10,ok -2025-09-01T08:10:00Z,hum-A2,humidity,field-01,keepalive,,183,ok -2025-09-01T08:10:00Z,hum-A2,humidity,field-01,reading,57.821,11,ok -2025-09-01T08:10:00Z,soil-A3,soil_moist,field-02,keepalive,,93,ok -2025-09-01T08:10:00Z,soil-A3,soil_moist,field-02,reading,35.237,6,ok -2025-09-01T08:10:00Z,temp-A1,temperature,field-01,keepalive,,153,ok -2025-09-01T08:10:00Z,temp-A1,temperature,field-01,reading,27.395,11,ok -2025-09-01T08:11:00Z,hum-A2,humidity,field-01,reading,57.256,12,ok -2025-09-01T08:11:00Z,temp-A1,temperature,field-01,reading,27.537,12,ok -2025-09-01T08:12:00Z,hum-A2,humidity,field-01,reading,58.973,13,ok -2025-09-01T08:12:00Z,soil-A3,soil_moist,field-02,reading,35.794,7,ok -2025-09-01T08:12:00Z,temp-A1,temperature,field-01,reading,27.722,13,ok -2025-09-01T08:13:00Z,hum-A2,humidity,field-01,reading,57.71,14,ok -2025-09-01T08:13:00Z,temp-A1,temperature,field-01,reading,27.619,14,ok -2025-09-01T08:14:00Z,hum-A2,humidity,field-01,reading,56.961,15,ok -2025-09-01T08:14:00Z,soil-A3,soil_moist,field-02,reading,36.138,8,ok -2025-09-01T08:14:00Z,temp-A1,temperature,field-01,reading,27.672,15,ok -2025-09-01T08:15:00Z,hum-A2,humidity,field-01,keepalive,,184,ok -2025-09-01T08:15:00Z,hum-A2,humidity,field-01,reading,60.498,16,ok -2025-09-01T08:15:00Z,soil-A3,soil_moist,field-02,keepalive,,94,ok -2025-09-01T08:15:00Z,temp-A1,temperature,field-01,keepalive,,154,ok -2025-09-01T08:15:00Z,temp-A1,temperature,field-01,reading,27.479,16,ok -2025-09-01T08:16:00Z,hum-A2,humidity,field-01,reading,57.56,17,ok -2025-09-01T08:16:00Z,soil-A3,soil_moist,field-02,reading,35.716,9,ok -2025-09-01T08:16:00Z,temp-A1,temperature,field-01,reading,27.929,17,ok -2025-09-01T08:17:00Z,hum-A2,humidity,field-01,reading,59.837,18,ok -2025-09-01T08:17:00Z,temp-A1,temperature,field-01,reading,27.89,18,ok -2025-09-01T08:18:00Z,hum-A2,humidity,field-01,reading,60.314,19,ok -2025-09-01T08:18:00Z,soil-A3,soil_moist,field-02,reading,35.009,10,ok -2025-09-01T08:18:00Z,temp-A1,temperature,field-01,reading,27.968,19,ok -2025-09-01T08:19:00Z,hum-A2,humidity,field-01,reading,59.914,20,ok -2025-09-01T08:19:00Z,temp-A1,temperature,field-01,reading,27.654,20,ok -2025-09-01T08:20:00Z,hum-A2,humidity,field-01,keepalive,,185,ok -2025-09-01T08:20:00Z,hum-A2,humidity,field-01,reading,58.679,21,ok -2025-09-01T08:20:00Z,soil-A3,soil_moist,field-02,keepalive,,95,ok -2025-09-01T08:20:00Z,soil-A3,soil_moist,field-02,reading,130.0,11,corrupted -2025-09-01T08:20:00Z,temp-A1,temperature,field-01,keepalive,,155,ok -2025-09-01T08:20:00Z,temp-A1,temperature,field-01,reading,28.335,21,ok -2025-09-01T08:21:00Z,hum-A2,humidity,field-01,reading,61.063,22,ok -2025-09-01T08:21:00Z,temp-A1,temperature,field-01,reading,28.081,22,ok -2025-09-01T08:22:00Z,hum-A2,humidity,field-01,reading,59.32,23,ok -2025-09-01T08:22:00Z,soil-A3,soil_moist,field-02,reading,35.55,12,ok -2025-09-01T08:22:00Z,temp-A1,temperature,field-01,reading,28.017,23,ok -2025-09-01T08:23:00Z,hum-A2,humidity,field-01,reading,59.617,24,ok -2025-09-01T08:23:00Z,temp-A1,temperature,field-01,reading,28.544,24,ok -2025-09-01T08:24:00Z,hum-A2,humidity,field-01,reading,60.617,25,ok -2025-09-01T08:24:00Z,soil-A3,soil_moist,field-02,reading,36.521,13,ok -2025-09-01T08:24:00Z,temp-A1,temperature,field-01,reading,28.172,25,ok -2025-09-01T08:25:00Z,hum-A2,humidity,field-01,keepalive,,186,ok -2025-09-01T08:25:00Z,hum-A2,humidity,field-01,reading,61.747,26,ok -2025-09-01T08:25:00Z,soil-A3,soil_moist,field-02,keepalive,,96,ok -2025-09-01T08:25:00Z,temp-A1,temperature,field-01,keepalive,,156,ok -2025-09-01T08:25:00Z,temp-A1,temperature,field-01,reading,27.933,26,ok -2025-09-01T08:26:00Z,hum-A2,humidity,field-01,reading,61.563,27,ok -2025-09-01T08:26:00Z,soil-A3,soil_moist,field-02,reading,37.459,14,ok -2025-09-01T08:26:00Z,temp-A1,temperature,field-01,reading,28.183,27,ok -2025-09-01T08:27:00Z,hum-A2,humidity,field-01,reading,62.173,28,ok -2025-09-01T08:27:00Z,temp-A1,temperature,field-01,reading,27.847,28,ok -2025-09-01T08:28:00Z,hum-A2,humidity,field-01,reading,61.367,29,ok -2025-09-01T08:28:00Z,soil-A3,soil_moist,field-02,reading,36.668,15,ok -2025-09-01T08:28:00Z,temp-A1,temperature,field-01,reading,28.554,29,ok -2025-09-01T08:29:00Z,hum-A2,humidity,field-01,reading,61.777,30,ok -2025-09-01T08:29:00Z,temp-A1,temperature,field-01,reading,28.3,30,ok -2025-09-01T08:30:00Z,hum-A2,humidity,field-01,keepalive,,187,ok -2025-09-01T08:30:00Z,hum-A2,humidity,field-01,reading,61.192,31,ok -2025-09-01T08:30:00Z,soil-A3,soil_moist,field-02,keepalive,,97,ok -2025-09-01T08:30:00Z,soil-A3,soil_moist,field-02,reading,36.186,16,ok -2025-09-01T08:30:00Z,temp-A1,temperature,field-01,keepalive,,157,ok -2025-09-01T08:30:00Z,temp-A1,temperature,field-01,reading,28.272,31,ok -2025-09-01T08:31:00Z,hum-A2,humidity,field-01,reading,62.031,32,ok -2025-09-01T08:31:00Z,temp-A1,temperature,field-01,reading,28.671,32,ok -2025-09-01T08:32:00Z,hum-A2,humidity,field-01,reading,62.937,33,ok -2025-09-01T08:32:00Z,soil-A3,soil_moist,field-02,reading,36.151,17,ok -2025-09-01T08:32:00Z,temp-A1,temperature,field-01,reading,28.162,33,ok -2025-09-01T08:33:00Z,hum-A2,humidity,field-01,reading,62.395,34,ok -2025-09-01T08:33:00Z,temp-A1,temperature,field-01,reading,28.634,34,ok -2025-09-01T08:34:00Z,hum-A2,humidity,field-01,reading,63.183,35,ok -2025-09-01T08:34:00Z,soil-A3,soil_moist,field-02,reading,37.564,18,ok -2025-09-01T08:34:00Z,temp-A1,temperature,field-01,reading,28.148,35,ok -2025-09-01T08:35:00Z,hum-A2,humidity,field-01,keepalive,,188,ok -2025-09-01T08:35:00Z,hum-A2,humidity,field-01,reading,62.506,36,ok -2025-09-01T08:35:00Z,soil-A3,soil_moist,field-02,keepalive,,98,ok -2025-09-01T08:35:00Z,temp-A1,temperature,field-01,keepalive,,158,ok -2025-09-01T08:35:00Z,temp-A1,temperature,field-01,reading,28.46,36,ok -2025-09-01T08:36:00Z,hum-A2,humidity,field-01,reading,62.647,37,ok -2025-09-01T08:36:00Z,soil-A3,soil_moist,field-02,reading,37.064,19,ok -2025-09-01T08:36:00Z,temp-A1,temperature,field-01,reading,28.383,37,ok -2025-09-01T08:37:00Z,hum-A2,humidity,field-01,reading,63.303,38,ok -2025-09-01T08:37:00Z,temp-A1,temperature,field-01,reading,28.947,38,ok -2025-09-01T08:38:00Z,hum-A2,humidity,field-01,reading,63.261,39,ok -2025-09-01T08:38:00Z,soil-A3,soil_moist,field-02,reading,37.432,20,ok -2025-09-01T08:38:00Z,temp-A1,temperature,field-01,reading,29.037,39,ok -2025-09-01T08:39:00Z,hum-A2,humidity,field-01,reading,64.03,40,ok -2025-09-01T08:39:00Z,temp-A1,temperature,field-01,reading,28.645,40,ok -2025-09-01T08:40:00Z,hum-A2,humidity,field-01,keepalive,,189,ok -2025-09-01T08:40:00Z,hum-A2,humidity,field-01,reading,63.832,41,ok -2025-09-01T08:40:00Z,soil-A3,soil_moist,field-02,keepalive,,99,ok -2025-09-01T08:40:00Z,soil-A3,soil_moist,field-02,reading,37.711,21,ok -2025-09-01T08:40:00Z,temp-A1,temperature,field-01,keepalive,,159,ok -2025-09-01T08:40:00Z,temp-A1,temperature,field-01,reading,28.906,41,ok -2025-09-01T08:41:00Z,hum-A2,humidity,field-01,reading,65.209,42,ok -2025-09-01T08:41:00Z,temp-A1,temperature,field-01,reading,28.727,42,ok -2025-09-01T08:42:00Z,hum-A2,humidity,field-01,reading,63.762,43,ok -2025-09-01T08:42:00Z,soil-A3,soil_moist,field-02,reading,37.306,22,ok -2025-09-01T08:42:00Z,temp-A1,temperature,field-01,reading,28.901,43,ok -2025-09-01T08:43:00Z,hum-A2,humidity,field-01,reading,63.871,44,ok -2025-09-01T08:43:00Z,temp-A1,temperature,field-01,reading,28.66,44,ok -2025-09-01T08:44:00Z,hum-A2,humidity,field-01,reading,63.329,45,ok -2025-09-01T08:44:00Z,soil-A3,soil_moist,field-02,reading,37.254,23,ok -2025-09-01T08:44:00Z,temp-A1,temperature,field-01,reading,28.491,45,ok -2025-09-01T08:45:00Z,hum-A2,humidity,field-01,keepalive,,190,ok -2025-09-01T08:45:00Z,hum-A2,humidity,field-01,reading,64.282,46,ok -2025-09-01T08:45:00Z,soil-A3,soil_moist,field-02,keepalive,,100,ok -2025-09-01T08:45:00Z,temp-A1,temperature,field-01,keepalive,,160,ok -2025-09-01T08:45:00Z,temp-A1,temperature,field-01,reading,28.492,46,ok -2025-09-01T08:46:00Z,hum-A2,humidity,field-01,reading,64.906,47,ok -2025-09-01T08:46:00Z,soil-A3,soil_moist,field-02,reading,37.008,24,ok -2025-09-01T08:46:00Z,temp-A1,temperature,field-01,reading,28.949,47,ok -2025-09-01T08:47:00Z,hum-A2,humidity,field-01,reading,63.857,48,ok -2025-09-01T08:47:00Z,temp-A1,temperature,field-01,reading,29.339,48,ok -2025-09-01T08:48:00Z,hum-A2,humidity,field-01,reading,64.492,49,ok -2025-09-01T08:48:00Z,soil-A3,soil_moist,field-02,reading,36.01,25,ok -2025-09-01T08:48:00Z,temp-A1,temperature,field-01,reading,28.96,49,ok -2025-09-01T08:49:00Z,hum-A2,humidity,field-01,reading,65.183,50,ok -2025-09-01T08:49:00Z,temp-A1,temperature,field-01,reading,28.817,50,ok -2025-09-01T08:50:00Z,hum-A2,humidity,field-01,keepalive,,191,ok -2025-09-01T08:50:00Z,hum-A2,humidity,field-01,reading,64.576,51,ok -2025-09-01T08:50:00Z,soil-A3,soil_moist,field-02,keepalive,,101,ok -2025-09-01T08:50:00Z,soil-A3,soil_moist,field-02,reading,36.857,26,ok -2025-09-01T08:50:00Z,temp-A1,temperature,field-01,keepalive,,161,ok -2025-09-01T08:50:00Z,temp-A1,temperature,field-01,reading,29.318,51,ok -2025-09-01T08:51:00Z,hum-A2,humidity,field-01,reading,64.326,52,ok -2025-09-01T08:51:00Z,temp-A1,temperature,field-01,reading,28.996,52,ok -2025-09-01T08:52:00Z,hum-A2,humidity,field-01,reading,65.575,53,ok -2025-09-01T08:52:00Z,soil-A3,soil_moist,field-02,reading,37.641,27,ok -2025-09-01T08:52:00Z,temp-A1,temperature,field-01,reading,28.98,53,ok -2025-09-01T08:53:00Z,hum-A2,humidity,field-01,reading,64.924,54,ok -2025-09-01T08:53:00Z,temp-A1,temperature,field-01,reading,29.02,54,ok -2025-09-01T08:54:00Z,hum-A2,humidity,field-01,reading,65.206,55,ok -2025-09-01T08:54:00Z,soil-A3,soil_moist,field-02,reading,37.643,28,ok -2025-09-01T08:54:00Z,temp-A1,temperature,field-01,reading,28.951,55,ok -2025-09-01T08:55:00Z,hum-A2,humidity,field-01,keepalive,,192,ok -2025-09-01T08:55:00Z,hum-A2,humidity,field-01,reading,65.862,56,ok -2025-09-01T08:55:00Z,soil-A3,soil_moist,field-02,keepalive,,102,ok -2025-09-01T08:55:00Z,temp-A1,temperature,field-01,keepalive,,162,ok -2025-09-01T08:55:00Z,temp-A1,temperature,field-01,reading,28.923,56,ok -2025-09-01T08:56:00Z,hum-A2,humidity,field-01,reading,65.87,57,ok -2025-09-01T08:56:00Z,soil-A3,soil_moist,field-02,reading,37.982,29,ok -2025-09-01T08:56:00Z,temp-A1,temperature,field-01,reading,28.704,57,ok -2025-09-01T08:57:00Z,hum-A2,humidity,field-01,reading,64.698,58,ok -2025-09-01T08:57:00Z,temp-A1,temperature,field-01,reading,29.095,58,ok -2025-09-01T08:58:00Z,hum-A2,humidity,field-01,reading,64.57,59,ok -2025-09-01T08:58:00Z,soil-A3,soil_moist,field-02,reading,37.763,30,ok -2025-09-01T08:58:00Z,temp-A1,temperature,field-01,reading,28.979,59,ok -2025-09-01T08:59:00Z,hum-A2,humidity,field-01,reading,65.226,60,ok -2025-09-01T08:59:00Z,temp-A1,temperature,field-01,reading,29.238,60,ok -2025-09-01T09:00:00Z,hum-A2,humidity,field-01,keepalive,,193,ok -2025-09-01T09:00:00Z,hum-A2,humidity,field-01,reading,64.893,61,ok -2025-09-01T09:00:00Z,soil-A3,soil_moist,field-02,keepalive,,103,ok -2025-09-01T09:00:00Z,soil-A3,soil_moist,field-02,reading,37.946,31,ok -2025-09-01T09:01:00Z,hum-A2,humidity,field-01,reading,63.474,62,ok -2025-09-01T09:02:00Z,hum-A2,humidity,field-01,reading,66.001,63,ok -2025-09-01T09:02:00Z,soil-A3,soil_moist,field-02,reading,38.295,32,ok -2025-09-01T09:03:00Z,hum-A2,humidity,field-01,reading,64.977,64,ok -2025-09-01T09:04:00Z,hum-A2,humidity,field-01,reading,65.524,65,ok -2025-09-01T09:04:00Z,soil-A3,soil_moist,field-02,reading,37.488,33,ok -2025-09-01T09:05:00Z,hum-A2,humidity,field-01,keepalive,,194,ok -2025-09-01T09:05:00Z,hum-A2,humidity,field-01,reading,63.963,66,ok -2025-09-01T09:05:00Z,soil-A3,soil_moist,field-02,keepalive,,104,ok -2025-09-01T09:06:00Z,hum-A2,humidity,field-01,reading,65.892,67,ok -2025-09-01T09:06:00Z,soil-A3,soil_moist,field-02,reading,37.812,34,ok -2025-09-01T09:07:00Z,hum-A2,humidity,field-01,reading,64.344,68,ok -2025-09-01T09:08:00Z,hum-A2,humidity,field-01,reading,64.674,69,ok -2025-09-01T09:08:00Z,soil-A3,soil_moist,field-02,reading,37.171,35,ok -2025-09-01T09:09:00Z,hum-A2,humidity,field-01,reading,63.579,70,ok -2025-09-01T09:10:00Z,hum-A2,humidity,field-01,keepalive,,195,ok -2025-09-01T09:10:00Z,hum-A2,humidity,field-01,reading,64.499,71,ok -2025-09-01T09:10:00Z,soil-A3,soil_moist,field-02,keepalive,,105,ok -2025-09-01T09:10:00Z,soil-A3,soil_moist,field-02,reading,38.475,36,ok -2025-09-01T09:11:00Z,hum-A2,humidity,field-01,reading,64.374,72,ok -2025-09-01T09:12:00Z,hum-A2,humidity,field-01,reading,64.329,73,ok -2025-09-01T09:12:00Z,soil-A3,soil_moist,field-02,reading,38.016,37,ok -2025-09-01T09:13:00Z,hum-A2,humidity,field-01,reading,64.741,74,ok -2025-09-01T09:14:00Z,hum-A2,humidity,field-01,reading,64.345,75,ok -2025-09-01T09:14:00Z,soil-A3,soil_moist,field-02,reading,38.245,38,ok -2025-09-01T09:15:00Z,hum-A2,humidity,field-01,keepalive,,196,ok -2025-09-01T09:15:00Z,hum-A2,humidity,field-01,reading,64.977,76,ok -2025-09-01T09:15:00Z,soil-A3,soil_moist,field-02,keepalive,,106,ok -2025-09-01T09:16:00Z,hum-A2,humidity,field-01,reading,63.715,77,ok -2025-09-01T09:16:00Z,soil-A3,soil_moist,field-02,reading,38.769,39,ok -2025-09-01T09:17:00Z,hum-A2,humidity,field-01,reading,63.18,78,ok -2025-09-01T09:18:00Z,hum-A2,humidity,field-01,reading,63.842,79,ok -2025-09-01T09:18:00Z,soil-A3,soil_moist,field-02,reading,38.53,40,ok -2025-09-01T09:19:00Z,hum-A2,humidity,field-01,reading,64.235,80,ok -2025-09-01T09:20:00Z,hum-A2,humidity,field-01,keepalive,,197,ok -2025-09-01T09:20:00Z,hum-A2,humidity,field-01,reading,64.597,81,ok -2025-09-01T09:20:00Z,soil-A3,soil_moist,field-02,keepalive,,107,ok -2025-09-01T09:20:00Z,soil-A3,soil_moist,field-02,reading,37.947,41,ok -2025-09-01T09:21:00Z,hum-A2,humidity,field-01,reading,64.597,82,ok -2025-09-01T09:22:00Z,hum-A2,humidity,field-01,reading,64.597,83,ok -2025-09-01T09:22:00Z,soil-A3,soil_moist,field-02,reading,38.353,42,ok -2025-09-01T09:23:00Z,hum-A2,humidity,field-01,reading,64.597,84,ok -2025-09-01T09:24:00Z,hum-A2,humidity,field-01,reading,64.597,85,ok -2025-09-01T09:24:00Z,soil-A3,soil_moist,field-02,reading,37.219,43,ok -2025-09-01T09:25:00Z,hum-A2,humidity,field-01,keepalive,,198,ok -2025-09-01T09:25:00Z,hum-A2,humidity,field-01,reading,64.597,86,ok -2025-09-01T09:25:00Z,soil-A3,soil_moist,field-02,keepalive,,108,ok -2025-09-01T09:26:00Z,hum-A2,humidity,field-01,reading,64.597,87,ok -2025-09-01T09:26:00Z,soil-A3,soil_moist,field-02,reading,38.123,44,ok -2025-09-01T09:27:00Z,hum-A2,humidity,field-01,reading,64.597,88,ok -2025-09-01T09:28:00Z,hum-A2,humidity,field-01,reading,64.597,89,ok -2025-09-01T09:28:00Z,soil-A3,soil_moist,field-02,reading,37.942,45,ok -2025-09-01T09:29:00Z,hum-A2,humidity,field-01,reading,64.597,90,ok -2025-09-01T09:30:00Z,hum-A2,humidity,field-01,keepalive,,199,ok -2025-09-01T09:30:00Z,hum-A2,humidity,field-01,reading,64.597,91,ok -2025-09-01T09:30:00Z,soil-A3,soil_moist,field-02,keepalive,,109,ok -2025-09-01T09:30:00Z,soil-A3,soil_moist,field-02,reading,37.024,46,ok -2025-09-01T09:30:00Z,temp-A1,temperature,field-01,keepalive,,163,ok -2025-09-01T09:30:00Z,temp-A1,temperature,field-01,reading,28.505,61,ok -2025-09-01T09:31:00Z,hum-A2,humidity,field-01,reading,64.597,92,ok -2025-09-01T09:31:00Z,temp-A1,temperature,field-01,reading,28.493,62,ok -2025-09-01T09:32:00Z,hum-A2,humidity,field-01,reading,64.597,93,ok -2025-09-01T09:32:00Z,soil-A3,soil_moist,field-02,reading,38.457,47,ok -2025-09-01T09:32:00Z,temp-A1,temperature,field-01,reading,28.316,63,ok -2025-09-01T09:33:00Z,hum-A2,humidity,field-01,reading,64.597,94,ok -2025-09-01T09:33:00Z,temp-A1,temperature,field-01,reading,28.263,64,ok -2025-09-01T09:34:00Z,hum-A2,humidity,field-01,reading,64.597,95,ok -2025-09-01T09:34:00Z,soil-A3,soil_moist,field-02,reading,37.387,48,ok -2025-09-01T09:34:00Z,temp-A1,temperature,field-01,reading,28.102,65,ok -2025-09-01T09:35:00Z,hum-A2,humidity,field-01,keepalive,,200,ok -2025-09-01T09:35:00Z,hum-A2,humidity,field-01,reading,64.597,96,ok -2025-09-01T09:35:00Z,soil-A3,soil_moist,field-02,keepalive,,110,ok -2025-09-01T09:35:00Z,temp-A1,temperature,field-01,keepalive,,164,ok -2025-09-01T09:35:00Z,temp-A1,temperature,field-01,reading,28.184,66,ok -2025-09-01T09:36:00Z,hum-A2,humidity,field-01,reading,64.597,97,ok -2025-09-01T09:36:00Z,soil-A3,soil_moist,field-02,reading,38.027,49,ok -2025-09-01T09:36:00Z,temp-A1,temperature,field-01,reading,28.605,67,ok -2025-09-01T09:37:00Z,hum-A2,humidity,field-01,reading,64.597,98,ok -2025-09-01T09:37:00Z,temp-A1,temperature,field-01,reading,28.283,68,ok -2025-09-01T09:38:00Z,hum-A2,humidity,field-01,reading,64.597,99,ok -2025-09-01T09:38:00Z,soil-A3,soil_moist,field-02,reading,37.416,50,ok -2025-09-01T09:38:00Z,temp-A1,temperature,field-01,reading,27.997,69,ok -2025-09-01T09:39:00Z,hum-A2,humidity,field-01,reading,64.597,100,ok -2025-09-01T09:39:00Z,temp-A1,temperature,field-01,reading,27.926,70,ok -2025-09-01T09:40:00Z,hum-A2,humidity,field-01,keepalive,,201,ok -2025-09-01T09:40:00Z,hum-A2,humidity,field-01,reading,64.597,101,ok -2025-09-01T09:40:00Z,soil-A3,soil_moist,field-02,keepalive,,111,ok -2025-09-01T09:40:00Z,soil-A3,soil_moist,field-02,reading,-10.0,51,corrupted -2025-09-01T09:40:00Z,temp-A1,temperature,field-01,keepalive,,165,ok -2025-09-01T09:40:00Z,temp-A1,temperature,field-01,reading,27.802,71,ok -2025-09-01T09:41:00Z,hum-A2,humidity,field-01,reading,64.597,102,ok -2025-09-01T09:41:00Z,temp-A1,temperature,field-01,reading,28.003,72,ok -2025-09-01T09:42:00Z,hum-A2,humidity,field-01,reading,64.597,103,ok -2025-09-01T09:42:00Z,soil-A3,soil_moist,field-02,reading,38.15,52,ok -2025-09-01T09:42:00Z,temp-A1,temperature,field-01,reading,27.96,73,ok -2025-09-01T09:43:00Z,hum-A2,humidity,field-01,reading,64.597,104,ok -2025-09-01T09:43:00Z,temp-A1,temperature,field-01,reading,27.545,74,ok -2025-09-01T09:44:00Z,hum-A2,humidity,field-01,reading,64.597,105,ok -2025-09-01T09:44:00Z,soil-A3,soil_moist,field-02,reading,37.006,53,ok -2025-09-01T09:44:00Z,temp-A1,temperature,field-01,reading,27.883,75,ok -2025-09-01T09:45:00Z,hum-A2,humidity,field-01,keepalive,,202,ok -2025-09-01T09:45:00Z,hum-A2,humidity,field-01,reading,64.597,106,ok -2025-09-01T09:45:00Z,soil-A3,soil_moist,field-02,keepalive,,112,ok -2025-09-01T09:45:00Z,temp-A1,temperature,field-01,keepalive,,166,ok -2025-09-01T09:45:00Z,temp-A1,temperature,field-01,reading,27.623,76,ok -2025-09-01T09:46:00Z,hum-A2,humidity,field-01,reading,64.597,107,ok -2025-09-01T09:46:00Z,soil-A3,soil_moist,field-02,reading,36.578,54,ok -2025-09-01T09:46:00Z,temp-A1,temperature,field-01,reading,27.973,77,ok -2025-09-01T09:47:00Z,hum-A2,humidity,field-01,reading,64.597,108,ok -2025-09-01T09:47:00Z,temp-A1,temperature,field-01,reading,27.669,78,ok -2025-09-01T09:48:00Z,hum-A2,humidity,field-01,reading,64.597,109,ok -2025-09-01T09:48:00Z,soil-A3,soil_moist,field-02,reading,37.196,55,ok -2025-09-01T09:48:00Z,temp-A1,temperature,field-01,reading,27.931,79,ok -2025-09-01T09:49:00Z,hum-A2,humidity,field-01,reading,64.597,110,ok -2025-09-01T09:49:00Z,temp-A1,temperature,field-01,reading,27.446,80,ok -2025-09-01T09:50:00Z,hum-A2,humidity,field-01,keepalive,,203,ok -2025-09-01T09:50:00Z,hum-A2,humidity,field-01,reading,64.597,111,ok -2025-09-01T09:50:00Z,soil-A3,soil_moist,field-02,keepalive,,113,ok -2025-09-01T09:50:00Z,soil-A3,soil_moist,field-02,reading,37.601,56,ok -2025-09-01T09:50:00Z,temp-A1,temperature,field-01,keepalive,,167,ok -2025-09-01T09:50:00Z,temp-A1,temperature,field-01,reading,27.429,81,ok -2025-09-01T09:51:00Z,hum-A2,humidity,field-01,reading,64.597,112,ok -2025-09-01T09:51:00Z,temp-A1,temperature,field-01,reading,27.495,82,ok -2025-09-01T09:52:00Z,hum-A2,humidity,field-01,reading,64.597,113,ok -2025-09-01T09:52:00Z,soil-A3,soil_moist,field-02,reading,36.902,57,ok -2025-09-01T09:52:00Z,temp-A1,temperature,field-01,reading,27.595,83,ok -2025-09-01T09:53:00Z,hum-A2,humidity,field-01,reading,64.597,114,ok -2025-09-01T09:53:00Z,temp-A1,temperature,field-01,reading,27.445,84,ok -2025-09-01T09:54:00Z,hum-A2,humidity,field-01,reading,64.597,115,ok -2025-09-01T09:54:00Z,soil-A3,soil_moist,field-02,reading,36.687,58,ok -2025-09-01T09:54:00Z,temp-A1,temperature,field-01,reading,27.033,85,ok -2025-09-01T09:55:00Z,hum-A2,humidity,field-01,keepalive,,204,ok -2025-09-01T09:55:00Z,hum-A2,humidity,field-01,reading,64.597,116,ok -2025-09-01T09:55:00Z,soil-A3,soil_moist,field-02,keepalive,,114,ok -2025-09-01T09:55:00Z,temp-A1,temperature,field-01,keepalive,,168,ok -2025-09-01T09:55:00Z,temp-A1,temperature,field-01,reading,27.264,86,ok -2025-09-01T09:56:00Z,hum-A2,humidity,field-01,reading,64.597,117,ok -2025-09-01T09:56:00Z,soil-A3,soil_moist,field-02,reading,37.142,59,ok -2025-09-01T09:56:00Z,temp-A1,temperature,field-01,reading,27.18,87,ok -2025-09-01T09:57:00Z,hum-A2,humidity,field-01,reading,64.597,118,ok -2025-09-01T09:57:00Z,temp-A1,temperature,field-01,reading,27.037,88,ok -2025-09-01T09:58:00Z,hum-A2,humidity,field-01,reading,64.597,119,ok -2025-09-01T09:58:00Z,soil-A3,soil_moist,field-02,reading,35.417,60,ok -2025-09-01T09:58:00Z,temp-A1,temperature,field-01,reading,26.941,89,ok -2025-09-01T09:59:00Z,hum-A2,humidity,field-01,reading,64.597,120,ok -2025-09-01T09:59:00Z,temp-A1,temperature,field-01,reading,27.367,90,ok -2025-09-01T10:00:00Z,hum-A2,humidity,field-01,keepalive,,205,ok -2025-09-01T10:00:00Z,hum-A2,humidity,field-01,reading,55.088,121,ok -2025-09-01T10:00:00Z,soil-A3,soil_moist,field-02,keepalive,,115,ok -2025-09-01T10:00:00Z,soil-A3,soil_moist,field-02,reading,36.362,61,ok -2025-09-01T10:00:00Z,temp-A1,temperature,field-01,keepalive,,169,ok -2025-09-01T10:00:00Z,temp-A1,temperature,field-01,reading,26.887,91,ok -2025-09-01T10:01:00Z,hum-A2,humidity,field-01,reading,54.422,122,ok -2025-09-01T10:01:00Z,temp-A1,temperature,field-01,reading,26.743,92,ok -2025-09-01T10:02:00Z,hum-A2,humidity,field-01,reading,53.028,123,ok -2025-09-01T10:02:00Z,soil-A3,soil_moist,field-02,reading,35.896,62,ok -2025-09-01T10:02:00Z,temp-A1,temperature,field-01,reading,26.987,93,ok -2025-09-01T10:03:00Z,hum-A2,humidity,field-01,reading,54.243,124,ok -2025-09-01T10:03:00Z,temp-A1,temperature,field-01,reading,26.833,94,ok -2025-09-01T10:04:00Z,hum-A2,humidity,field-01,reading,54.521,125,ok -2025-09-01T10:04:00Z,soil-A3,soil_moist,field-02,reading,36.626,63,ok -2025-09-01T10:04:00Z,temp-A1,temperature,field-01,reading,26.74,95,ok -2025-09-01T10:05:00Z,hum-A2,humidity,field-01,keepalive,,206,ok -2025-09-01T10:05:00Z,hum-A2,humidity,field-01,reading,53.395,126,ok -2025-09-01T10:05:00Z,soil-A3,soil_moist,field-02,keepalive,,116,ok -2025-09-01T10:05:00Z,temp-A1,temperature,field-01,keepalive,,170,ok -2025-09-01T10:05:00Z,temp-A1,temperature,field-01,reading,26.859,96,ok -2025-09-01T10:06:00Z,hum-A2,humidity,field-01,reading,53.198,127,ok -2025-09-01T10:06:00Z,soil-A3,soil_moist,field-02,reading,35.999,64,ok -2025-09-01T10:06:00Z,temp-A1,temperature,field-01,reading,26.749,97,ok -2025-09-01T10:07:00Z,hum-A2,humidity,field-01,reading,54.11,128,ok -2025-09-01T10:07:00Z,temp-A1,temperature,field-01,reading,26.672,98,ok -2025-09-01T10:08:00Z,hum-A2,humidity,field-01,reading,51.738,129,ok -2025-09-01T10:08:00Z,soil-A3,soil_moist,field-02,reading,35.695,65,ok -2025-09-01T10:08:00Z,temp-A1,temperature,field-01,reading,26.74,99,ok -2025-09-01T10:09:00Z,hum-A2,humidity,field-01,reading,51.284,130,ok -2025-09-01T10:09:00Z,temp-A1,temperature,field-01,reading,26.553,100,ok -2025-09-01T10:10:00Z,hum-A2,humidity,field-01,keepalive,,207,ok -2025-09-01T10:10:00Z,hum-A2,humidity,field-01,reading,51.705,131,ok -2025-09-01T10:10:00Z,soil-A3,soil_moist,field-02,keepalive,,117,ok -2025-09-01T10:10:00Z,soil-A3,soil_moist,field-02,reading,35.959,66,ok -2025-09-01T10:10:00Z,temp-A1,temperature,field-01,keepalive,,171,ok -2025-09-01T10:10:00Z,temp-A1,temperature,field-01,reading,26.322,101,ok -2025-09-01T10:11:00Z,hum-A2,humidity,field-01,reading,51.019,132,ok -2025-09-01T10:11:00Z,temp-A1,temperature,field-01,reading,26.323,102,ok -2025-09-01T10:12:00Z,hum-A2,humidity,field-01,reading,52.804,133,ok -2025-09-01T10:12:00Z,soil-A3,soil_moist,field-02,reading,35.283,67,ok -2025-09-01T10:12:00Z,temp-A1,temperature,field-01,reading,26.241,103,ok -2025-09-01T10:13:00Z,hum-A2,humidity,field-01,reading,51.727,134,ok -2025-09-01T10:13:00Z,temp-A1,temperature,field-01,reading,26.338,104,ok -2025-09-01T10:14:00Z,hum-A2,humidity,field-01,reading,50.543,135,ok -2025-09-01T10:14:00Z,soil-A3,soil_moist,field-02,reading,35.89,68,ok -2025-09-01T10:14:00Z,temp-A1,temperature,field-01,reading,26.031,105,ok -2025-09-01T10:15:00Z,hum-A2,humidity,field-01,keepalive,,208,ok -2025-09-01T10:15:00Z,hum-A2,humidity,field-01,reading,50.5,136,ok -2025-09-01T10:15:00Z,soil-A3,soil_moist,field-02,keepalive,,118,ok -2025-09-01T10:15:00Z,temp-A1,temperature,field-01,keepalive,,172,ok -2025-09-01T10:15:00Z,temp-A1,temperature,field-01,reading,25.832,106,ok -2025-09-01T10:16:00Z,hum-A2,humidity,field-01,reading,53.041,137,ok -2025-09-01T10:16:00Z,soil-A3,soil_moist,field-02,reading,35.434,69,ok -2025-09-01T10:16:00Z,temp-A1,temperature,field-01,reading,26.168,107,ok -2025-09-01T10:17:00Z,hum-A2,humidity,field-01,reading,50.027,138,ok -2025-09-01T10:17:00Z,temp-A1,temperature,field-01,reading,25.836,108,ok -2025-09-01T10:18:00Z,hum-A2,humidity,field-01,reading,49.672,139,ok -2025-09-01T10:18:00Z,soil-A3,soil_moist,field-02,reading,35.463,70,ok -2025-09-01T10:18:00Z,temp-A1,temperature,field-01,reading,25.666,109,ok -2025-09-01T10:19:00Z,hum-A2,humidity,field-01,reading,50.294,140,ok -2025-09-01T10:19:00Z,temp-A1,temperature,field-01,reading,26.085,110,ok -2025-09-01T10:20:00Z,hum-A2,humidity,field-01,keepalive,,209,ok -2025-09-01T10:20:00Z,hum-A2,humidity,field-01,reading,50.334,141,ok -2025-09-01T10:20:00Z,soil-A3,soil_moist,field-02,keepalive,,119,ok -2025-09-01T10:20:00Z,soil-A3,soil_moist,field-02,reading,35.168,71,ok -2025-09-01T10:20:00Z,temp-A1,temperature,field-01,keepalive,,173,ok -2025-09-01T10:20:00Z,temp-A1,temperature,field-01,reading,25.823,111,ok -2025-09-01T10:21:00Z,hum-A2,humidity,field-01,reading,49.778,142,ok -2025-09-01T10:21:00Z,temp-A1,temperature,field-01,reading,26.019,112,ok -2025-09-01T10:22:00Z,hum-A2,humidity,field-01,reading,48.654,143,ok -2025-09-01T10:22:00Z,soil-A3,soil_moist,field-02,reading,35.857,72,ok -2025-09-01T10:22:00Z,temp-A1,temperature,field-01,reading,25.77,113,ok -2025-09-01T10:23:00Z,hum-A2,humidity,field-01,reading,48.237,144,ok -2025-09-01T10:23:00Z,temp-A1,temperature,field-01,reading,25.609,114,ok -2025-09-01T10:24:00Z,hum-A2,humidity,field-01,reading,49.43,145,ok -2025-09-01T10:24:00Z,soil-A3,soil_moist,field-02,reading,35.221,73,ok -2025-09-01T10:24:00Z,temp-A1,temperature,field-01,reading,25.542,115,ok -2025-09-01T10:25:00Z,hum-A2,humidity,field-01,keepalive,,210,ok -2025-09-01T10:25:00Z,hum-A2,humidity,field-01,reading,48.702,146,ok -2025-09-01T10:25:00Z,soil-A3,soil_moist,field-02,keepalive,,120,ok -2025-09-01T10:25:00Z,temp-A1,temperature,field-01,keepalive,,174,ok -2025-09-01T10:25:00Z,temp-A1,temperature,field-01,reading,25.646,116,ok -2025-09-01T10:26:00Z,hum-A2,humidity,field-01,reading,47.229,147,ok -2025-09-01T10:26:00Z,soil-A3,soil_moist,field-02,reading,35.41,74,ok -2025-09-01T10:26:00Z,temp-A1,temperature,field-01,reading,25.654,117,ok -2025-09-01T10:27:00Z,hum-A2,humidity,field-01,reading,49.28,148,ok -2025-09-01T10:27:00Z,temp-A1,temperature,field-01,reading,25.504,118,ok -2025-09-01T10:28:00Z,hum-A2,humidity,field-01,reading,48.77,149,ok -2025-09-01T10:28:00Z,soil-A3,soil_moist,field-02,reading,35.261,75,ok -2025-09-01T10:28:00Z,temp-A1,temperature,field-01,reading,25.574,119,ok -2025-09-01T10:29:00Z,hum-A2,humidity,field-01,reading,47.767,150,ok -2025-09-01T10:29:00Z,temp-A1,temperature,field-01,reading,25.285,120,ok -2025-09-01T10:30:00Z,hum-A2,humidity,field-01,keepalive,,211,ok -2025-09-01T10:30:00Z,hum-A2,humidity,field-01,reading,47.567,151,ok -2025-09-01T10:30:00Z,soil-A3,soil_moist,field-02,keepalive,,121,ok -2025-09-01T10:30:00Z,soil-A3,soil_moist,field-02,reading,34.491,76,ok -2025-09-01T10:30:00Z,temp-A1,temperature,field-01,keepalive,,175,ok -2025-09-01T10:30:00Z,temp-A1,temperature,field-01,reading,25.558,121,ok -2025-09-01T10:31:00Z,hum-A2,humidity,field-01,reading,47.781,152,ok -2025-09-01T10:31:00Z,temp-A1,temperature,field-01,reading,25.16,122,ok -2025-09-01T10:32:00Z,hum-A2,humidity,field-01,reading,46.872,153,ok -2025-09-01T10:32:00Z,soil-A3,soil_moist,field-02,reading,35.052,77,ok -2025-09-01T10:32:00Z,temp-A1,temperature,field-01,reading,25.446,123,ok -2025-09-01T10:33:00Z,hum-A2,humidity,field-01,reading,46.174,154,ok -2025-09-01T10:33:00Z,temp-A1,temperature,field-01,reading,25.59,124,ok -2025-09-01T10:34:00Z,hum-A2,humidity,field-01,reading,47.347,155,ok -2025-09-01T10:34:00Z,soil-A3,soil_moist,field-02,reading,34.815,78,ok -2025-09-01T10:34:00Z,temp-A1,temperature,field-01,reading,25.636,125,ok -2025-09-01T10:35:00Z,hum-A2,humidity,field-01,keepalive,,212,ok -2025-09-01T10:35:00Z,hum-A2,humidity,field-01,reading,45.779,156,ok -2025-09-01T10:35:00Z,soil-A3,soil_moist,field-02,keepalive,,122,ok -2025-09-01T10:35:00Z,temp-A1,temperature,field-01,keepalive,,176,ok -2025-09-01T10:35:00Z,temp-A1,temperature,field-01,reading,25.729,126,ok -2025-09-01T10:36:00Z,hum-A2,humidity,field-01,reading,47.09,157,ok -2025-09-01T10:36:00Z,soil-A3,soil_moist,field-02,reading,34.672,79,ok -2025-09-01T10:36:00Z,temp-A1,temperature,field-01,reading,25.044,127,ok -2025-09-01T10:37:00Z,hum-A2,humidity,field-01,reading,45.478,158,ok -2025-09-01T10:37:00Z,temp-A1,temperature,field-01,reading,25.478,128,ok -2025-09-01T10:38:00Z,hum-A2,humidity,field-01,reading,46.41,159,ok -2025-09-01T10:38:00Z,soil-A3,soil_moist,field-02,reading,34.191,80,ok -2025-09-01T10:38:00Z,temp-A1,temperature,field-01,reading,25.539,129,ok -2025-09-01T10:39:00Z,hum-A2,humidity,field-01,reading,46.246,160,ok -2025-09-01T10:39:00Z,temp-A1,temperature,field-01,reading,25.467,130,ok -2025-09-01T10:40:00Z,hum-A2,humidity,field-01,keepalive,,213,ok -2025-09-01T10:40:00Z,hum-A2,humidity,field-01,reading,47.651,161,ok -2025-09-01T10:40:00Z,soil-A3,soil_moist,field-02,keepalive,,123,ok -2025-09-01T10:40:00Z,soil-A3,soil_moist,field-02,reading,34.053,81,ok -2025-09-01T10:40:00Z,temp-A1,temperature,field-01,keepalive,,177,ok -2025-09-01T10:40:00Z,temp-A1,temperature,field-01,reading,25.059,131,ok -2025-09-01T10:41:00Z,hum-A2,humidity,field-01,reading,45.25,162,ok -2025-09-01T10:41:00Z,temp-A1,temperature,field-01,reading,25.303,132,ok -2025-09-01T10:42:00Z,hum-A2,humidity,field-01,reading,44.853,163,ok -2025-09-01T10:42:00Z,soil-A3,soil_moist,field-02,reading,34.537,82,ok -2025-09-01T10:42:00Z,temp-A1,temperature,field-01,reading,25.569,133,ok -2025-09-01T10:43:00Z,hum-A2,humidity,field-01,reading,45.357,164,ok -2025-09-01T10:43:00Z,temp-A1,temperature,field-01,reading,24.875,134,ok -2025-09-01T10:44:00Z,hum-A2,humidity,field-01,reading,44.871,165,ok -2025-09-01T10:44:00Z,soil-A3,soil_moist,field-02,reading,35.114,83,ok -2025-09-01T10:44:00Z,temp-A1,temperature,field-01,reading,25.249,135,ok -2025-09-01T10:45:00Z,hum-A2,humidity,field-01,keepalive,,214,ok -2025-09-01T10:45:00Z,hum-A2,humidity,field-01,reading,45.2,166,ok -2025-09-01T10:45:00Z,soil-A3,soil_moist,field-02,keepalive,,124,ok -2025-09-01T10:45:00Z,temp-A1,temperature,field-01,keepalive,,178,ok -2025-09-01T10:45:00Z,temp-A1,temperature,field-01,reading,25.357,136,ok -2025-09-01T10:46:00Z,hum-A2,humidity,field-01,reading,45.917,167,ok -2025-09-01T10:46:00Z,soil-A3,soil_moist,field-02,reading,33.488,84,ok -2025-09-01T10:46:00Z,temp-A1,temperature,field-01,reading,24.977,137,ok -2025-09-01T10:47:00Z,hum-A2,humidity,field-01,reading,46.112,168,ok -2025-09-01T10:47:00Z,temp-A1,temperature,field-01,reading,25.216,138,ok -2025-09-01T10:48:00Z,hum-A2,humidity,field-01,reading,46.744,169,ok -2025-09-01T10:48:00Z,soil-A3,soil_moist,field-02,reading,34.053,85,ok -2025-09-01T10:48:00Z,temp-A1,temperature,field-01,reading,24.869,139,ok -2025-09-01T10:49:00Z,hum-A2,humidity,field-01,reading,44.755,170,ok -2025-09-01T10:49:00Z,temp-A1,temperature,field-01,reading,24.906,140,ok -2025-09-01T10:50:00Z,hum-A2,humidity,field-01,keepalive,,215,ok -2025-09-01T10:50:00Z,hum-A2,humidity,field-01,reading,47.39,171,ok -2025-09-01T10:50:00Z,soil-A3,soil_moist,field-02,keepalive,,125,ok -2025-09-01T10:50:00Z,soil-A3,soil_moist,field-02,reading,33.488,86,ok -2025-09-01T10:50:00Z,temp-A1,temperature,field-01,keepalive,,179,ok -2025-09-01T10:50:00Z,temp-A1,temperature,field-01,reading,24.892,141,ok -2025-09-01T10:51:00Z,hum-A2,humidity,field-01,reading,46.807,172,ok -2025-09-01T10:51:00Z,temp-A1,temperature,field-01,reading,25.083,142,ok -2025-09-01T10:52:00Z,hum-A2,humidity,field-01,reading,45.007,173,ok -2025-09-01T10:52:00Z,soil-A3,soil_moist,field-02,reading,33.951,87,ok -2025-09-01T10:52:00Z,temp-A1,temperature,field-01,reading,25.016,143,ok -2025-09-01T10:53:00Z,hum-A2,humidity,field-01,reading,43.291,174,ok -2025-09-01T10:53:00Z,temp-A1,temperature,field-01,reading,25.216,144,ok -2025-09-01T10:54:00Z,hum-A2,humidity,field-01,reading,45.02,175,ok -2025-09-01T10:54:00Z,soil-A3,soil_moist,field-02,reading,33.667,88,ok -2025-09-01T10:54:00Z,temp-A1,temperature,field-01,reading,24.829,145,ok -2025-09-01T10:55:00Z,hum-A2,humidity,field-01,keepalive,,216,ok -2025-09-01T10:55:00Z,hum-A2,humidity,field-01,reading,45.113,176,ok -2025-09-01T10:55:00Z,soil-A3,soil_moist,field-02,keepalive,,126,ok -2025-09-01T10:55:00Z,temp-A1,temperature,field-01,keepalive,,180,ok -2025-09-01T10:55:00Z,temp-A1,temperature,field-01,reading,24.985,146,ok -2025-09-01T10:56:00Z,hum-A2,humidity,field-01,reading,43.192,177,ok -2025-09-01T10:56:00Z,soil-A3,soil_moist,field-02,reading,33.871,89,ok -2025-09-01T10:56:00Z,temp-A1,temperature,field-01,reading,25.198,147,ok -2025-09-01T10:57:00Z,hum-A2,humidity,field-01,reading,45.073,178,ok -2025-09-01T10:57:00Z,temp-A1,temperature,field-01,reading,25.115,148,ok -2025-09-01T10:58:00Z,hum-A2,humidity,field-01,reading,44.843,179,ok -2025-09-01T10:58:00Z,soil-A3,soil_moist,field-02,reading,31.839,90,ok -2025-09-01T10:58:00Z,temp-A1,temperature,field-01,reading,24.736,149,ok -2025-09-01T10:59:00Z,hum-A2,humidity,field-01,reading,44.371,180,ok -2025-09-01T10:59:00Z,temp-A1,temperature,field-01,reading,25.133,150,ok diff --git a/services/sensorGuard/sensorGuard/pytest.ini b/services/sensorGuard/sensorGuard/pytest.ini deleted file mode 100644 index 678c989ca..000000000 --- a/services/sensorGuard/sensorGuard/pytest.ini +++ /dev/null @@ -1,16 +0,0 @@ -[tool:pytest] -testpaths = tests -python_files = test_*.py -python_classes = Test* -python_functions = test_* -addopts = - --verbose - --tb=short - --cov=flink_app - --cov-report=html:coverage_html - --cov-report=term-missing - --cov-fail-under=70 -markers = - unit: Unit tests - integration: Integration tests - slow: Slow tests \ No newline at end of file diff --git a/services/sensorGuard/sensorGuard/requirements.txt b/services/sensorGuard/sensorGuard/requirements.txt deleted file mode 100644 index e70d76a76..000000000 --- a/services/sensorGuard/sensorGuard/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -pyyaml -kafka-python>=2.0.2 -requests -urllib3 -grpcio -avro-python3==1.10.2 -protobuf==3.20.3 \ No newline at end of file diff --git a/services/sensorGuard/sensorGuard/test-requirements.txt b/services/sensorGuard/sensorGuard/test-requirements.txt deleted file mode 100644 index 59cd202e4..000000000 --- a/services/sensorGuard/sensorGuard/test-requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -pytest>=7.0.0 -pytest-cov>=4.0.0 -pytest-mock>=3.10.0 \ No newline at end of file diff --git a/services/sensorGuard/sensorGuard/tests/__init__.py b/services/sensorGuard/sensorGuard/tests/__init__.py deleted file mode 100644 index 1d342c1a8..000000000 --- a/services/sensorGuard/sensorGuard/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Test package init \ No newline at end of file diff --git a/services/sensorGuard/sensorGuard/tests/test_engine.py b/services/sensorGuard/sensorGuard/tests/test_engine.py deleted file mode 100644 index ba34aaaab..000000000 --- a/services/sensorGuard/sensorGuard/tests/test_engine.py +++ /dev/null @@ -1,195 +0,0 @@ -import pytest -from unittest.mock import Mock, patch -from datetime import datetime, timezone -import sys -import os - -# Add the flink_app to path for imports -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'flink_app')) - -from core.engine import Engine -from core.types import Event, Alert -from core.state import StateStore - - -@pytest.fixture(autouse=True) -def mock_engine_dependencies(): - """Mock external dependencies for Engine class""" - with patch('core.engine.get_access_token', return_value='test_token'), \ - patch('core.engine.update_device_last_seen'): - yield - - -class TestEngine: - """Test Engine class public methods""" - - def setup_method(self): - """Arrange - Set up test fixtures""" - self.mock_writer = Mock() - self.mock_cfg = { - 'features': {'out_of_range': True, 'stuck_sensor': True}, - 'ranges': {'temperature': {'min': 0, 'max': 50}}, - 'stuck': {'min_duration_seconds': 1800} - } - self.state_store = StateStore() - self.engine = Engine(self.mock_cfg, self.mock_writer, self.state_store) - - def test_engine_initialization(self): - """Test engine initializes correctly""" - # Assert - assert self.engine.cfg == self.mock_cfg - assert len(self.engine.writers) == 1 - assert self.engine.state is self.state_store - - def test_process_event_unknown_device(self): - """Test processing event for unknown device""" - # Arrange - event = Event( - ts=datetime.now(timezone.utc), - device_id="unknown_device", - sensor_type="temperature", - site_id=None, - msg_type="reading", - value=25.0, - seq=None, - quality="ok" - ) - - # Act - self.engine.process_event(event) - - # Assert - no alerts should be emitted for unknown devices - self.mock_writer.write.assert_not_called() - - def test_process_event_known_device_normal_value(self): - """Test processing normal value for known device""" - # Arrange - self.state_store.add_device("device_1", "temperature") - event = Event( - ts=datetime.now(timezone.utc), - device_id="device_1", - sensor_type="temperature", - site_id=None, - msg_type="reading", - value=25.0, - seq=None, - quality="ok" - ) - - # Act - self.engine.process_event(event) - - # Assert - device state should be updated - device_state = self.state_store.get("device_1") - assert device_state.last_seen_ts == event.ts - assert device_state.last_value == 25.0 - - def test_emit_alert_calls_all_writers(self): - """Test _emit calls write on all writers""" - # Arrange - writer1 = Mock() - writer2 = Mock() - engine = Engine(self.mock_cfg, [writer1, writer2], self.state_store) - alert = Alert( - device_id="device_1", - issue_type="test_alert", - severity="error", - start_ts=datetime.now(timezone.utc), - end_ts=None - ) - - # Act - engine._emit(alert) - - # Assert - writer1.write.assert_called_once_with(alert) - writer2.write.assert_called_once_with(alert) - - def test_open_once_new_alert(self): - """Test opening new alert when none exists""" - # Arrange - self.state_store.add_device("device_1", "temperature") - device_state = self.state_store.get("device_1") - alert = Alert( - device_id="device_1", - issue_type="out_of_range", - severity="error", - start_ts=datetime.now(timezone.utc), - end_ts=None - ) - - # Act - self.engine._open_once(device_state, alert) - - # Assert - assert "out_of_range" in device_state.open_alerts - self.mock_writer.write.assert_called_once_with(alert) - - def test_open_once_existing_alert(self): - """Test not opening duplicate alert""" - # Arrange - self.state_store.add_device("device_1", "temperature") - device_state = self.state_store.get("device_1") - - # Pre-existing alert - existing_alert = Alert( - device_id="device_1", - issue_type="out_of_range", - severity="error", - start_ts=datetime.now(timezone.utc), - end_ts=None - ) - device_state.open_alerts["out_of_range"] = existing_alert - - new_alert = Alert( - device_id="device_1", - issue_type="out_of_range", - severity="error", - start_ts=datetime.now(timezone.utc), - end_ts=None - ) - - # Act - self.engine._open_once(device_state, new_alert) - - # Assert - should not emit new alert - self.mock_writer.write.assert_not_called() - assert device_state.open_alerts["out_of_range"] is existing_alert - - def test_close_if_open_existing_alert(self): - """Test closing existing alert""" - # Arrange - self.state_store.add_device("device_1", "temperature") - device_state = self.state_store.get("device_1") - - alert = Alert( - device_id="device_1", - issue_type="out_of_range", - severity="error", - start_ts=datetime.now(timezone.utc), - end_ts=None - ) - device_state.open_alerts["out_of_range"] = alert - close_ts = datetime.now(timezone.utc) - - # Act - self.engine._close_if_open(device_state, "out_of_range", close_ts) - - # Assert - assert "out_of_range" not in device_state.open_alerts - assert alert.end_ts == close_ts - self.mock_writer.write.assert_called_once_with(alert) - - def test_close_if_open_nonexistent_alert(self): - """Test closing non-existent alert does nothing""" - # Arrange - self.state_store.add_device("device_1", "temperature") - device_state = self.state_store.get("device_1") - close_ts = datetime.now(timezone.utc) - - # Act - self.engine._close_if_open(device_state, "out_of_range", close_ts) - - # Assert - nothing should happen - self.mock_writer.write.assert_not_called() - assert len(device_state.open_alerts) == 0 \ No newline at end of file diff --git a/services/sensorGuard/sensorGuard/tests/test_engine_quality.py b/services/sensorGuard/sensorGuard/tests/test_engine_quality.py deleted file mode 100644 index 6f6c7e9e1..000000000 --- a/services/sensorGuard/sensorGuard/tests/test_engine_quality.py +++ /dev/null @@ -1,683 +0,0 @@ -""" -Professional, comprehensive tests for the Engine class. -These tests verify REAL business logic, not just code coverage. -""" -import pytest -import time -from unittest.mock import Mock, MagicMock, patch -from datetime import datetime, timezone, timedelta - -import sys -import os -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'flink_app')) - -from core.engine import Engine -from core.types import Event, Alert, DeviceState -from core.state import StateStore - - -@pytest.fixture(autouse=True) -def mock_engine_dependencies(): - """Mock external dependencies for Engine class""" - with patch('core.engine.get_access_token', return_value='test_token'), \ - patch('core.engine.update_device_last_seen'), \ - patch('core.engine.get_sensors_last_seen', return_value=[]): - yield - - -class TestEngineBusinessLogic: - """Tests that verify the actual sensor monitoring business rules.""" - - def setup_method(self): - """Set up test environment with realistic configuration.""" - self.mock_writer = Mock() - self.cfg = { - "features": { - "corrupted": True, - "out_of_range": True, - "stuck_sensor": True, - "silence": True - }, - "prolonged_silence_seconds": 300, # 5 minutes - "ranges": { - "temperature": {"min": -40, "max": 85}, - "humidity": {"min": 0, "max": 100} - }, - "stuck": { - "temperature": {"tolerance": 0.1, "count": 5} - } - } - self.state_store = StateStore() - self.engine = Engine(self.cfg, self.mock_writer, self.state_store) - - def create_real_sensor_message(self, device_id, sensor_type, value, msg_type="telemetry", **extra): - """Create a message in the EXACT format that comes from real sensors.""" - return { - "sid": f"sensor-{device_id}", - "id": int(device_id), - "timestamp": datetime.now(timezone.utc).isoformat(), - "msg_type": msg_type, - "value": value, - "sensor": sensor_type, - "plant_id": 123, - "temperature": value if sensor_type == "temperature" else 25.0, - "humidity": value if sensor_type == "humidity" else 65.0, - "ph": 7.0, - "n": 50.0, - "p": 30.0, - "k": 40.0, - **extra - } - - def to_event(self, sensor_message): - """Convert sensor message to Event using EXACT logic from main.py.""" - if not isinstance(sensor_message, dict): - return None - ts = datetime.now(timezone.utc) - device_id = sensor_message.get("id") - sensor_type = sensor_message.get("sensor_type") or sensor_message.get("sensor", "unknown_sensor") - if not device_id: - device_id = "unknown_device" - else: - device_id = str(device_id) - - return Event( - ts=ts, - device_id=device_id, - sensor_type=sensor_type, - site_id=sensor_message.get("site_id"), - msg_type=sensor_message.get("msg_type", "reading"), - value=sensor_message.get("value"), - seq=sensor_message.get("seq"), - quality=sensor_message.get("quality"), - ) - - def test_new_unknown_device_is_ignored_completely(self): - """CRITICAL: Unknown devices should be completely ignored - no alerts, no state changes.""" - # Arrange: Create REAL sensor message from unknown device (not in state_store) - unknown_message = self.create_real_sensor_message( - device_id="999", # Device not added to state store - sensor_type="temperature", - value=25.0 - ) - - # Act: Convert and process like real system does - event = self.to_event(unknown_message) - assert event is not None, "Valid message should convert to event" - self.engine.process_event(event) - - # Assert: No alerts should be generated for unknown devices - assert self.mock_writer.write.call_count == 0 - - # Assert: Device should not be considered known - assert not self.state_store.is_known_device("999") - - # Assert: No state should exist for this device - result = self.state_store.get("999") - assert result is None, "Unknown device should return None from state store" - - def test_sensor_comeback_closes_keepalive_alerts_immediately(self): - """CRITICAL: When sensor sends data after being silent, missing_keepalive alert must close. - NOTE: prolonged_silence alerts are NOT closed by process_event - only by sweep_silence.""" - # Arrange: Add device and simulate missing keepalive alert - device_id = "5" - self.state_store.add_device(device_id, "temperature") - device_state = self.state_store.get(device_id) - - # Manually create open missing_keepalive alert (simulating sweep_silence) - now = datetime.now(timezone.utc) - missing_alert = Alert( - device_id=device_id, - issue_type="missing_keepalive", - start_ts=now - timedelta(minutes=10), - end_ts=None, - severity="error", - sensor_type="temperature", - site_id=None, - details={"reason": "no data received"} - ) - - device_state.open_alerts["missing_keepalive"] = missing_alert - - # Act: Sensor sends REAL comeback message - comeback_message = self.create_real_sensor_message( - device_id="5", - sensor_type="temperature", - value=23.5 - ) - - comeback_event = self.to_event(comeback_message) - assert comeback_event is not None - self.engine.process_event(comeback_event) - - # Assert: missing_keepalive alert should be closed (emitted with end_ts) - assert self.mock_writer.write.call_count >= 1, "Expected at least 1 alert emission" - - # Verify the closed alert has end_ts set - calls = self.mock_writer.write.call_args_list - closed_alerts = [call[0][0] for call in calls] - - keepalive_closed = any(alert.issue_type == "missing_keepalive" and alert.end_ts is not None - for alert in closed_alerts) - - assert keepalive_closed, "missing_keepalive alert should be closed with end_ts" - - # Assert: missing_keepalive alert should no longer be open - assert "missing_keepalive" not in device_state.open_alerts - - def test_out_of_range_alert_opens_and_closes_correctly(self): - """CRITICAL: Out-of-range alerts must open when value is invalid, close when valid.""" - # Arrange: Add device - device_id = "5" - self.state_store.add_device(device_id, "temperature") - - # Act 1: Send out-of-range value using REAL message format - bad_message = self.create_real_sensor_message( - device_id="5", - sensor_type="temperature", - value=150.0 # Way above max of 85 - ) - - bad_event = self.to_event(bad_message) - assert bad_event is not None, "Valid message should convert to event" - - self.engine.process_event(bad_event) - - # Assert: Out-of-range alert should be opened - device_state = self.state_store.get(device_id) - assert "out_of_range" in device_state.open_alerts - - # Find the out-of-range alert that was emitted - calls = self.mock_writer.write.call_args_list - out_of_range_alerts = [call[0][0] for call in calls if call[0][0].issue_type == "out_of_range"] - assert len(out_of_range_alerts) == 1 - assert out_of_range_alerts[0].end_ts is None # Should be open - - # Reset mock for next assertion - self.mock_writer.reset_mock() - - # Act 2: Send valid value using REAL message format - good_message = self.create_real_sensor_message( - device_id="5", - sensor_type="temperature", - value=22.0 # Within valid range - ) - - good_event = self.to_event(good_message) - assert good_event is not None, "Valid message should convert to event" - - self.engine.process_event(good_event) - - # Assert: Out-of-range alert should be closed - assert "out_of_range" not in device_state.open_alerts - - # Verify alert was closed (emitted with end_ts) - closing_calls = self.mock_writer.write.call_args_list - if closing_calls: # Alert closure generates emission - closed_alert = closing_calls[0][0][0] - assert closed_alert.issue_type == "out_of_range" - assert closed_alert.end_ts is not None - - def test_complete_alert_lifecycle_with_multiple_bad_then_good_messages(self): - """COMPREHENSIVE: Test complete alert lifecycle - multiple bad messages, then recovery.""" - # Arrange: Add device - device_id = "10" - self.state_store.add_device(device_id, "temperature") - device_state = self.state_store.get(device_id) - - # Act 1: Send first bad message (should open alert) - bad_message_1 = self.create_real_sensor_message( - device_id="10", - sensor_type="temperature", - value=200.0 # Way out of range - ) - - self.engine.process_event(self.to_event(bad_message_1)) - - # Assert: Alert should be open - assert "out_of_range" in device_state.open_alerts - assert self.mock_writer.write.call_count == 1 # One alert opened - - # Act 2: Send second bad message (should NOT open duplicate alert) - self.mock_writer.reset_mock() - bad_message_2 = self.create_real_sensor_message( - device_id="10", - sensor_type="temperature", - value=250.0 # Still out of range - ) - - self.engine.process_event(self.to_event(bad_message_2)) - - # Assert: Still same alert, no new alert created - assert "out_of_range" in device_state.open_alerts - assert self.mock_writer.write.call_count == 0 # No new alerts - - # Act 3: Send good message (should close alert) - self.mock_writer.reset_mock() - good_message = self.create_real_sensor_message( - device_id="10", - sensor_type="temperature", - value=25.0 # Back to normal - ) - - self.engine.process_event(self.to_event(good_message)) - - # Assert: Alert should be closed completely - assert "out_of_range" not in device_state.open_alerts - assert self.mock_writer.write.call_count == 1 # Alert closure emitted - - # Verify the closed alert has proper end_ts - closed_alert = self.mock_writer.write.call_args[0][0] - assert closed_alert.issue_type == "out_of_range" - assert closed_alert.device_id == device_id - assert closed_alert.end_ts is not None - assert closed_alert.end_ts > closed_alert.start_ts # End after start - - # Act 4: Send another good message (should NOT close anything) - self.mock_writer.reset_mock() - another_good = self.create_real_sensor_message( - device_id="10", - sensor_type="temperature", - value=30.0 - ) - - self.engine.process_event(self.to_event(another_good)) - - # Assert: No alert activity (no open alerts to close) - assert len(device_state.open_alerts) == 0 - assert self.mock_writer.write.call_count == 0 - - def test_multiple_alert_types_independence(self): - """CRITICAL: Different alert types should open/close independently.""" - # Arrange - device_id = "11" - self.state_store.add_device(device_id, "temperature") - device_state = self.state_store.get(device_id) - - # Act 1: Trigger out_of_range alert - bad_temp_message = self.create_real_sensor_message( - device_id="11", - sensor_type="temperature", - value=200.0 - ) - self.engine.process_event(self.to_event(bad_temp_message)) - - # Act 2: Manually add a missing_keepalive alert (simulating silence) - keepalive_alert = Alert( - device_id=device_id, - issue_type="missing_keepalive", - start_ts=datetime.now(timezone.utc), - end_ts=None, - severity="error", - sensor_type="temperature" - ) - device_state.open_alerts["missing_keepalive"] = keepalive_alert - - # Assert: Both alerts should be open - assert "out_of_range" in device_state.open_alerts - assert "missing_keepalive" in device_state.open_alerts - assert len(device_state.open_alerts) == 2 - - # Act 3: Send good temperature (should close out_of_range but not missing_keepalive) - self.mock_writer.reset_mock() - good_temp_message = self.create_real_sensor_message( - device_id="11", - sensor_type="temperature", - value=25.0 - ) - self.engine.process_event(self.to_event(good_temp_message)) - - # Assert: out_of_range closed, missing_keepalive should be closed by _close_all_keepalive_alerts - assert "out_of_range" not in device_state.open_alerts - assert "missing_keepalive" not in device_state.open_alerts # This should be closed by comeback - - # Should have emitted 2 alerts: out_of_range closure + missing_keepalive closure - assert self.mock_writer.write.call_count == 2 - - def test_corrupted_message_overrides_out_of_range_alerts(self): - """CRITICAL: Corrupted message should close out_of_range and stuck_sensor alerts.""" - # Arrange - device_id = "12" - self.state_store.add_device(device_id, "humidity") - device_state = self.state_store.get(device_id) - - # Act 1: Create out_of_range alert first - out_of_range_message = self.create_real_sensor_message( - device_id="12", - sensor_type="humidity", - value=150.0 # Above max of 100 - ) - self.engine.process_event(self.to_event(out_of_range_message)) - - assert "out_of_range" in device_state.open_alerts - first_alert_count = self.mock_writer.write.call_count - - # Act 2: Send corrupted message (None value) - self.mock_writer.reset_mock() - corrupted_message = self.create_real_sensor_message( - device_id="12", - sensor_type="humidity", - value=None # This triggers corrupted alert - ) - self.engine.process_event(self.to_event(corrupted_message)) - - # Assert: Corrupted should open, out_of_range should be closed - assert "corrupted" in device_state.open_alerts - assert "out_of_range" not in device_state.open_alerts - - # Should emit: 1 corrupted open + 1 out_of_range close - assert self.mock_writer.write.call_count == 2 - - def test_sensor_value_oscillation_alert_behavior(self): - """COMPLEX: Test alert behavior when sensor oscillates between good/bad values.""" - # Arrange - device_id = "13" - self.state_store.add_device(device_id, "temperature") - device_state = self.state_store.get(device_id) - - # Scenario: bad -> good -> bad -> good (should open/close/open/close) - test_sequence = [ - (100.0, True), # Bad (should open alert) - (25.0, False), # Good (should close alert) - (200.0, True), # Bad again (should open new alert) - (30.0, False) # Good again (should close alert) - ] - - total_opens = 0 - total_closes = 0 - - for i, (value, should_be_bad) in enumerate(test_sequence): - self.mock_writer.reset_mock() - - message = self.create_real_sensor_message( - device_id="13", - sensor_type="temperature", - value=value - ) - self.engine.process_event(self.to_event(message)) - - if should_be_bad: - # Should have alert open - assert "out_of_range" in device_state.open_alerts - if self.mock_writer.write.call_count > 0: - total_opens += 1 - else: - # Should NOT have alert open - assert "out_of_range" not in device_state.open_alerts - if self.mock_writer.write.call_count > 0: - total_closes += 1 - - # Should have opened 2 times and closed 2 times - assert total_opens == 2, f"Expected 2 opens, got {total_opens}" - assert total_closes == 2, f"Expected 2 closes, got {total_closes}" - - def test_sweep_silence_detects_missing_devices_correctly(self): - """CRITICAL: sweep_silence must detect devices that haven't been seen for too long. - This tests the REAL business logic: sweep_silence fetches from API and compares timestamps.""" - # Arrange: Add device to state store - device_id = "never_seen_device" - self.state_store.add_device(device_id, "humidity") - device_state = self.state_store.get(device_id) - - # Mock API to return this sensor with old last_seen timestamp - old_timestamp = (datetime.now(timezone.utc) - timedelta(minutes=10)).isoformat() - mock_sensors_from_api = [ - { - "id": device_id, - "sensor_type": "humidity", - "last_seen": old_timestamp - } - ] - - # Act: Run silence sweep with mocked API response - with patch('core.engine.get_sensors_last_seen', return_value=mock_sensors_from_api): - now = datetime.now(timezone.utc) - self.engine.sweep_silence(now) - - # Assert: missing_keepalive alert should be created (gap > threshold) - assert "missing_keepalive" in device_state.open_alerts, \ - f"Expected missing_keepalive alert for device silent for 10 minutes (threshold is ~3 minutes)" - - # Verify alert was emitted - assert self.mock_writer.write.call_count >= 1, "Expected alert emission" - emitted_alert = self.mock_writer.write.call_args[0][0] - assert emitted_alert.issue_type == "missing_keepalive" - assert emitted_alert.device_id == device_id - assert emitted_alert.end_ts is None, "Alert should be open" - - def test_sweep_silence_detects_prolonged_silence_correctly(self): - """CRITICAL: sweep_silence must detect devices silent for too long and close alerts when they resume. - This tests REAL business logic: comparing API timestamps against threshold.""" - # Arrange: Add device to state store - device_id = "old_device" - self.state_store.add_device(device_id, "temperature") - device_state = self.state_store.get(device_id) - - # Mock API to return sensor with old last_seen (10 minutes ago - exceeds 3 min threshold) - old_timestamp = (datetime.now(timezone.utc) - timedelta(minutes=10)).isoformat() - mock_sensors_from_api = [ - { - "id": device_id, - "sensor_type": "temperature", - "last_seen": old_timestamp - } - ] - - # Act: Run silence sweep with mocked API - should open missing_keepalive alert - with patch('core.engine.get_sensors_last_seen', return_value=mock_sensors_from_api): - now = datetime.now(timezone.utc) - self.engine.sweep_silence(now) - - # Assert: missing_keepalive alert should be created (device silent > threshold) - assert "missing_keepalive" in device_state.open_alerts, \ - "Expected missing_keepalive alert for device silent for 10 minutes" - - # Verify alert was emitted - assert self.mock_writer.write.call_count >= 1 - first_alert = self.mock_writer.write.call_args[0][0] - assert first_alert.issue_type == "missing_keepalive" - assert first_alert.device_id == device_id - assert first_alert.end_ts is None, "Initial alert should be open" - - # Reset mock for next phase - self.mock_writer.reset_mock() - - # Act 2: Run sweep again with RECENT timestamp - should close the alert - recent_timestamp = (datetime.now(timezone.utc) - timedelta(seconds=30)).isoformat() - mock_sensors_recent = [ - { - "id": device_id, - "sensor_type": "temperature", - "last_seen": recent_timestamp - } - ] - - with patch('core.engine.get_sensors_last_seen', return_value=mock_sensors_recent): - now = datetime.now(timezone.utc) - self.engine.sweep_silence(now) - - # Assert: Alert should be closed (gap now < threshold) - assert "missing_keepalive" not in device_state.open_alerts, \ - "Alert should be closed when device is no longer silent" - - # Verify closing alert was emitted - assert self.mock_writer.write.call_count >= 1 - closing_alert = self.mock_writer.write.call_args[0][0] - assert closing_alert.issue_type == "missing_keepalive" - assert closing_alert.end_ts is not None, "Closing alert should have end_ts" - - def test_multiple_writers_receive_all_alerts(self): - """CRITICAL: When multiple writers are configured, all must receive every alert.""" - # Arrange: Setup engine with multiple writers - writer1 = Mock() - writer2 = Mock() - writer3 = Mock() - - engine = Engine(self.cfg, [writer1, writer2, writer3], self.state_store) - - device_id = "7" - self.state_store.add_device(device_id, "temperature") - - # Act: Generate an alert using REAL message format - bad_message = self.create_real_sensor_message( - device_id="7", - sensor_type="temperature", - value=-100.0 # Out of range (below min of -40) - ) - - bad_event = self.to_event(bad_message) - assert bad_event is not None - engine.process_event(bad_event) - - # Assert: All writers should receive the alert - assert writer1.write.call_count == 1 - assert writer2.write.call_count == 1 - assert writer3.write.call_count == 1 - - # Verify they all got the same alert - alert1 = writer1.write.call_args[0][0] - alert2 = writer2.write.call_args[0][0] - alert3 = writer3.write.call_args[0][0] - - assert alert1.issue_type == alert2.issue_type == alert3.issue_type == "out_of_range" - assert alert1.device_id == alert2.device_id == alert3.device_id == device_id - - def test_device_state_updates_correctly_during_processing(self): - """CRITICAL: Device state must be updated properly with each event.""" - # Arrange - device_id = "8" - self.state_store.add_device(device_id, "humidity") - - initial_state = self.state_store.get(device_id) - assert initial_state.last_seen_ts is None - assert initial_state.last_value is None - - # Act: Process first event using REAL message format - first_message = self.create_real_sensor_message( - device_id="8", - sensor_type="humidity", - value=45.2 - ) - - first_event = self.to_event(first_message) - assert first_event is not None - first_process_time = first_event.ts # Capture the actual timestamp used - - self.engine.process_event(first_event) - - # Assert: State should be updated - updated_state = self.state_store.get(device_id) - assert updated_state.last_seen_ts == first_process_time - assert updated_state.last_value == 45.2 - assert updated_state.sensor_type == "humidity" - - # Act: Process second event with different values - time.sleep(0.1) # Ensure different timestamp - second_message = self.create_real_sensor_message( - device_id="8", - sensor_type="humidity", - value=67.8 - ) - - second_event = self.to_event(second_message) - assert second_event is not None - second_process_time = second_event.ts - - self.engine.process_event(second_event) - - # Assert: State should reflect latest values - final_state = self.state_store.get(device_id) - assert final_state.last_seen_ts == second_process_time - assert final_state.last_value == 67.8 - assert final_state.sensor_type == "humidity" - - -class TestEngineEdgeCases: - """Tests for edge cases and error scenarios.""" - - def setup_method(self): - self.mock_writer = Mock() - self.cfg = { - "features": {"corrupted": True, "out_of_range": True, "stuck_sensor": True, "silence": True}, - "prolonged_silence_seconds": 300 - } - self.engine = Engine(self.cfg, self.mock_writer) - - def create_real_sensor_message(self, device_id, sensor_type, value, msg_type="telemetry", **extra): - """Create a message in the EXACT format that comes from real sensors.""" - return { - "sid": f"sensor-{device_id}", - "id": int(device_id), - "timestamp": datetime.now(timezone.utc).isoformat(), - "msg_type": msg_type, - "value": value, - "sensor": sensor_type, - "plant_id": 123, - "temperature": value if sensor_type == "temperature" else 25.0, - "humidity": value if sensor_type == "humidity" else 65.0, - "ph": 7.0, - "n": 50.0, - "p": 30.0, - "k": 40.0, - **extra - } - - def to_event(self, sensor_message): - """Convert sensor message to Event using EXACT logic from main.py.""" - if not isinstance(sensor_message, dict): - return None - ts = datetime.now(timezone.utc) - device_id = sensor_message.get("id") - sensor_type = sensor_message.get("sensor_type") or sensor_message.get("sensor", "unknown_sensor") - if not device_id: - device_id = "unknown_device" - else: - device_id = str(device_id) - - return Event( - ts=ts, - device_id=device_id, - sensor_type=sensor_type, - site_id=sensor_message.get("site_id"), - msg_type=sensor_message.get("msg_type", "reading"), - value=sensor_message.get("value"), - seq=sensor_message.get("seq"), - quality=sensor_message.get("quality"), - ) - - def test_engine_handles_none_values_gracefully(self): - """Edge case: Engine should handle None/null values without crashing.""" - # Arrange: Add device - device_id = "9" - self.engine.state.add_device(device_id, "temperature") - - # Act: Send REAL message with None value (like corrupted sensor reading) - null_message = self.create_real_sensor_message( - device_id="9", - sensor_type="temperature", - value=None # Corrupted/missing sensor reading - ) - - null_event = self.to_event(null_message) - assert null_event is not None - - # This should not raise an exception - try: - self.engine.process_event(null_event) - except Exception as e: - pytest.fail(f"Engine crashed with None value: {e}") - - def test_sweep_silence_on_empty_state_store(self): - """Edge case: sweep_silence should handle empty state store.""" - # Act: Run sweep on empty state (should not crash) - try: - self.engine.sweep_silence(datetime.now(timezone.utc)) - except Exception as e: - pytest.fail(f"sweep_silence crashed on empty state: {e}") - - # Assert: No alerts should be generated - assert self.mock_writer.write.call_count == 0 - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/services/sensorGuard/sensorGuard/tests/test_state.py b/services/sensorGuard/sensorGuard/tests/test_state.py deleted file mode 100644 index bb2e385fa..000000000 --- a/services/sensorGuard/sensorGuard/tests/test_state.py +++ /dev/null @@ -1,136 +0,0 @@ -import pytest -from unittest.mock import Mock -import sys -import os - -# Add the flink_app to path for imports -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'flink_app')) - -from core.state import StateStore -from core.types import DeviceState - - -class TestStateStore: - """Test StateStore class public methods""" - - def setup_method(self): - """Arrange - Set up test fixtures""" - self.state_store = StateStore() - - def test_add_device_new(self): - """Test adding new device to state store""" - # Act - self.state_store.add_device("device_1", "temperature") - - # Assert - assert self.state_store.is_known_device("device_1") - device_state = self.state_store.get("device_1") - assert device_state.device_id == "device_1" - assert device_state.sensor_type == "temperature" - - def test_add_device_existing(self): - """Test adding existing device doesn't overwrite""" - # Arrange - self.state_store.add_device("device_1", "temperature") - original_state = self.state_store.get("device_1") - - # Act - Try to add same device with different sensor type - self.state_store.add_device("device_1", "humidity") - - # Assert - Original state preserved - current_state = self.state_store.get("device_1") - assert current_state is original_state - assert current_state.sensor_type == "temperature" # Not changed - - def test_is_known_device_true(self): - """Test is_known_device returns True for existing device""" - # Arrange - self.state_store.add_device("device_1", "temperature") - - # Act & Assert - assert self.state_store.is_known_device("device_1") is True - - def test_is_known_device_false(self): - """Test is_known_device returns False for non-existing device""" - # Act & Assert - assert self.state_store.is_known_device("unknown_device") is False - - def test_get_device_existing(self): - """Test getting existing device state""" - # Arrange - self.state_store.add_device("device_1", "temperature") - - # Act - device_state = self.state_store.get("device_1") - - # Assert - assert device_state is not None - assert device_state.device_id == "device_1" - assert device_state.sensor_type == "temperature" - - def test_get_device_nonexistent(self): - """Test getting non-existent device returns None""" - # Act - device_state = self.state_store.get("unknown_device") - - # Assert - assert device_state is None - - def test_all_states_empty(self): - """Test getting all devices when store is empty""" - # Act - all_devices = list(self.state_store.all_states()) - - # Assert - assert len(all_devices) == 0 - - def test_all_states_multiple(self): - """Test getting all devices with multiple devices""" - # Arrange - self.state_store.add_device("device_1", "temperature") - self.state_store.add_device("device_2", "humidity") - self.state_store.add_device("device_3", "pressure") - - # Act - all_states = list(self.state_store.all_states()) - - # Assert - assert len(all_states) == 3 - device_ids = [device_id for device_id, state in all_states] - assert "device_1" in device_ids - assert "device_2" in device_ids - assert "device_3" in device_ids - - def test_state_persistence(self): - """Test that device state persists across operations""" - # Arrange - self.state_store.add_device("device_1", "temperature") - device_state = self.state_store.get("device_1") - - # Act - Modify state - from datetime import datetime, timezone - ts = datetime.now(timezone.utc) - device_state.last_seen_ts = ts - device_state.last_value = 25.0 - - # Assert - Changes persist when retrieving again - retrieved_state = self.state_store.get("device_1") - assert retrieved_state.last_seen_ts == ts - assert retrieved_state.last_value == 25.0 - - def test_multiple_devices_independence(self): - """Test that multiple devices maintain independent state""" - # Arrange - self.state_store.add_device("device_1", "temperature") - self.state_store.add_device("device_2", "humidity") - - device_1 = self.state_store.get("device_1") - device_2 = self.state_store.get("device_2") - - # Act - Modify only device_1 - device_1.last_value = 25.0 - - # Assert - device_2 not affected - assert device_1.last_value == 25.0 - assert device_2.last_value is None - assert device_1 is not device_2 \ No newline at end of file diff --git a/services/sensorGuard/sensorGuard/tests/test_state_fixed.py b/services/sensorGuard/sensorGuard/tests/test_state_fixed.py deleted file mode 100644 index bb2e385fa..000000000 --- a/services/sensorGuard/sensorGuard/tests/test_state_fixed.py +++ /dev/null @@ -1,136 +0,0 @@ -import pytest -from unittest.mock import Mock -import sys -import os - -# Add the flink_app to path for imports -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'flink_app')) - -from core.state import StateStore -from core.types import DeviceState - - -class TestStateStore: - """Test StateStore class public methods""" - - def setup_method(self): - """Arrange - Set up test fixtures""" - self.state_store = StateStore() - - def test_add_device_new(self): - """Test adding new device to state store""" - # Act - self.state_store.add_device("device_1", "temperature") - - # Assert - assert self.state_store.is_known_device("device_1") - device_state = self.state_store.get("device_1") - assert device_state.device_id == "device_1" - assert device_state.sensor_type == "temperature" - - def test_add_device_existing(self): - """Test adding existing device doesn't overwrite""" - # Arrange - self.state_store.add_device("device_1", "temperature") - original_state = self.state_store.get("device_1") - - # Act - Try to add same device with different sensor type - self.state_store.add_device("device_1", "humidity") - - # Assert - Original state preserved - current_state = self.state_store.get("device_1") - assert current_state is original_state - assert current_state.sensor_type == "temperature" # Not changed - - def test_is_known_device_true(self): - """Test is_known_device returns True for existing device""" - # Arrange - self.state_store.add_device("device_1", "temperature") - - # Act & Assert - assert self.state_store.is_known_device("device_1") is True - - def test_is_known_device_false(self): - """Test is_known_device returns False for non-existing device""" - # Act & Assert - assert self.state_store.is_known_device("unknown_device") is False - - def test_get_device_existing(self): - """Test getting existing device state""" - # Arrange - self.state_store.add_device("device_1", "temperature") - - # Act - device_state = self.state_store.get("device_1") - - # Assert - assert device_state is not None - assert device_state.device_id == "device_1" - assert device_state.sensor_type == "temperature" - - def test_get_device_nonexistent(self): - """Test getting non-existent device returns None""" - # Act - device_state = self.state_store.get("unknown_device") - - # Assert - assert device_state is None - - def test_all_states_empty(self): - """Test getting all devices when store is empty""" - # Act - all_devices = list(self.state_store.all_states()) - - # Assert - assert len(all_devices) == 0 - - def test_all_states_multiple(self): - """Test getting all devices with multiple devices""" - # Arrange - self.state_store.add_device("device_1", "temperature") - self.state_store.add_device("device_2", "humidity") - self.state_store.add_device("device_3", "pressure") - - # Act - all_states = list(self.state_store.all_states()) - - # Assert - assert len(all_states) == 3 - device_ids = [device_id for device_id, state in all_states] - assert "device_1" in device_ids - assert "device_2" in device_ids - assert "device_3" in device_ids - - def test_state_persistence(self): - """Test that device state persists across operations""" - # Arrange - self.state_store.add_device("device_1", "temperature") - device_state = self.state_store.get("device_1") - - # Act - Modify state - from datetime import datetime, timezone - ts = datetime.now(timezone.utc) - device_state.last_seen_ts = ts - device_state.last_value = 25.0 - - # Assert - Changes persist when retrieving again - retrieved_state = self.state_store.get("device_1") - assert retrieved_state.last_seen_ts == ts - assert retrieved_state.last_value == 25.0 - - def test_multiple_devices_independence(self): - """Test that multiple devices maintain independent state""" - # Arrange - self.state_store.add_device("device_1", "temperature") - self.state_store.add_device("device_2", "humidity") - - device_1 = self.state_store.get("device_1") - device_2 = self.state_store.get("device_2") - - # Act - Modify only device_1 - device_1.last_value = 25.0 - - # Assert - device_2 not affected - assert device_1.last_value == 25.0 - assert device_2.last_value is None - assert device_1 is not device_2 \ No newline at end of file diff --git a/services/sensorGuard/sensorGuard/tests/test_state_quality.py b/services/sensorGuard/sensorGuard/tests/test_state_quality.py deleted file mode 100644 index a068af528..000000000 --- a/services/sensorGuard/sensorGuard/tests/test_state_quality.py +++ /dev/null @@ -1,319 +0,0 @@ -""" -Professional, comprehensive tests for StateStore class. -These tests verify data integrity and state management correctness. -""" -import pytest -from datetime import datetime, timezone, timedelta - -import sys -import os -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'flink_app')) - -from core.state import StateStore -from core.types import DeviceState, Alert - - -class TestStateStoreDataIntegrity: - """Tests that verify data integrity and correct state management.""" - - def setup_method(self): - """Set up fresh StateStore for each test.""" - self.state_store = StateStore() - - def test_add_device_creates_proper_state_structure(self): - """CRITICAL: Adding device must create correct internal state structure.""" - # Arrange - device_id = "sensor_001" - sensor_type = "temperature" - - # Act: Add device - self.state_store.add_device(device_id, sensor_type) - - # Assert: Device should be in known devices - assert self.state_store.is_known_device(device_id) - - # Assert: Device state should be properly initialized - device_state = self.state_store.get(device_id) - assert device_state is not None - assert device_state.device_id == device_id - assert device_state.sensor_type == sensor_type - assert device_state.last_seen_ts is None # Should start as None - assert device_state.last_value is None # Should start as None - assert device_state.open_alerts == {} # Should start empty - - def test_get_unknown_device_returns_none_safely(self): - """CRITICAL: Getting unknown device must return None, not crash.""" - # Act: Try to get device that was never added - result = self.state_store.get("nonexistent_device") - - # Assert: Should return None safely - assert result is None - - # Assert: Should not be considered known - assert not self.state_store.is_known_device("nonexistent_device") - - def test_device_state_persistence_across_operations(self): - """CRITICAL: Device state changes must persist correctly.""" - # Arrange: Add device and get its state - device_id = "persistent_device" - self.state_store.add_device(device_id, "humidity") - device_state = self.state_store.get(device_id) - - # Act: Modify device state - test_time = datetime.now(timezone.utc) - test_value = 42.7 - test_alert = Alert( - issue_type="test_alert", - device_id=device_id, - sensor_type="humidity", - site_id="test_site", - severity="warning", - start_ts=test_time, - end_ts=None, - details={"test": "data"} - ) - - device_state.last_seen_ts = test_time - device_state.last_value = test_value - device_state.open_alerts["test_alert"] = test_alert - - # Assert: Changes should persist when retrieving again - retrieved_state = self.state_store.get(device_id) - assert retrieved_state.last_seen_ts == test_time - assert retrieved_state.last_value == test_value - assert "test_alert" in retrieved_state.open_alerts - assert retrieved_state.open_alerts["test_alert"].issue_type == "test_alert" - - def test_multiple_devices_maintain_separate_states(self): - """CRITICAL: Multiple devices must have completely separate states.""" - # Arrange: Add multiple devices - device1 = "temp_sensor_01" - device2 = "humid_sensor_02" - device3 = "pressure_sensor_03" - - self.state_store.add_device(device1, "temperature") - self.state_store.add_device(device2, "humidity") - self.state_store.add_device(device3, "pressure") - - # Act: Set different values for each device - time1 = datetime.now(timezone.utc) - time2 = time1 + timedelta(minutes=1) - time3 = time1 + timedelta(minutes=2) - - state1 = self.state_store.get(device1) - state2 = self.state_store.get(device2) - state3 = self.state_store.get(device3) - - state1.last_seen_ts = time1 - state1.last_value = 25.5 - - state2.last_seen_ts = time2 - state2.last_value = 65.8 - - state3.last_seen_ts = time3 - state3.last_value = 1013.2 - - # Assert: Each device maintains its own independent state - retrieved1 = self.state_store.get(device1) - retrieved2 = self.state_store.get(device2) - retrieved3 = self.state_store.get(device3) - - assert retrieved1.last_seen_ts == time1 - assert retrieved1.last_value == 25.5 - assert retrieved1.sensor_type == "temperature" - - assert retrieved2.last_seen_ts == time2 - assert retrieved2.last_value == 65.8 - assert retrieved2.sensor_type == "humidity" - - assert retrieved3.last_seen_ts == time3 - assert retrieved3.last_value == 1013.2 - assert retrieved3.sensor_type == "pressure" - - def test_all_states_returns_correct_iterator(self): - """CRITICAL: all_states() must return all devices and their current states.""" - # Arrange: Add several devices with different states - devices_data = [ - ("device_a", "temperature", 22.1), - ("device_b", "humidity", 58.3), - ("device_c", "pressure", 1015.7) - ] - - for device_id, sensor_type, value in devices_data: - self.state_store.add_device(device_id, sensor_type) - state = self.state_store.get(device_id) - state.last_value = value - - # Act: Get all states - all_states = dict(self.state_store.all_states()) - - # Assert: Should contain exactly the devices we added - assert len(all_states) == 3 - assert "device_a" in all_states - assert "device_b" in all_states - assert "device_c" in all_states - - # Assert: States should contain correct data - assert all_states["device_a"].sensor_type == "temperature" - assert all_states["device_a"].last_value == 22.1 - - assert all_states["device_b"].sensor_type == "humidity" - assert all_states["device_b"].last_value == 58.3 - - assert all_states["device_c"].sensor_type == "pressure" - assert all_states["device_c"].last_value == 1015.7 - - def test_device_id_type_conversion_consistency(self): - """CRITICAL: Device IDs must be consistently converted to strings.""" - # Act: Add devices with different ID types - self.state_store.add_device(123, "temperature") # Integer - self.state_store.add_device("456", "humidity") # String - self.state_store.add_device(789.0, "pressure") # Float - - # Assert: All should be accessible as strings - assert self.state_store.is_known_device("123") - assert self.state_store.is_known_device("456") - assert self.state_store.is_known_device("789.0") - - # Assert: States should be retrievable with string IDs - assert self.state_store.get("123") is not None - assert self.state_store.get("456") is not None - assert self.state_store.get("789.0") is not None - - # Assert: Original types should also work (converted internally) - assert self.state_store.is_known_device(123) - assert self.state_store.is_known_device(456) - assert self.state_store.is_known_device(789.0) - - def test_duplicate_device_addition_is_safe(self): - """CRITICAL: Adding same device multiple times must be safe.""" - # Arrange: Add device and modify its state - device_id = "duplicate_test_device" - self.state_store.add_device(device_id, "temperature") - - original_state = self.state_store.get(device_id) - test_time = datetime.now(timezone.utc) - original_state.last_seen_ts = test_time - original_state.last_value = 99.9 - - # Act: Add same device again (should not overwrite existing state) - self.state_store.add_device(device_id, "humidity") # Different sensor type - - # Assert: Original state should be preserved - current_state = self.state_store.get(device_id) - assert current_state.last_seen_ts == test_time - assert current_state.last_value == 99.9 - assert current_state.sensor_type == "temperature" # Should keep original - - -class TestStateStoreEdgeCases: - """Tests for edge cases and boundary conditions.""" - - def setup_method(self): - self.state_store = StateStore() - - def test_empty_state_store_operations(self): - """Edge case: Operations on empty state store should be safe.""" - # Assert: Empty state store behaves correctly - assert not self.state_store.is_known_device("any_device") - assert self.state_store.get("any_device") is None - - # all_states should return empty iterator - all_states = list(self.state_store.all_states()) - assert len(all_states) == 0 - - def test_none_and_empty_device_ids(self): - """Edge case: None/empty device IDs should be handled gracefully.""" - # Act/Assert: These should not crash - try: - result1 = self.state_store.is_known_device("") - result2 = self.state_store.get("") - # Empty string is valid, should return False/None - assert result1 is False - assert result2 is None - except Exception as e: - pytest.fail(f"Empty string device ID caused crash: {e}") - - def test_very_large_number_of_devices(self): - """Performance test: Should handle many devices efficiently.""" - # Arrange: Add many devices - num_devices = 1000 - for i in range(num_devices): - device_id = f"device_{i:04d}" - self.state_store.add_device(device_id, f"sensor_type_{i % 10}") - - # Act/Assert: Should be able to access all devices - for i in range(num_devices): - device_id = f"device_{i:04d}" - assert self.state_store.is_known_device(device_id) - state = self.state_store.get(device_id) - assert state is not None - assert state.device_id == device_id - - # Should return correct count - all_states = list(self.state_store.all_states()) - assert len(all_states) == num_devices - - -class TestStateStoreBusinessRules: - """Tests that verify business logic around state management.""" - - def setup_method(self): - self.state_store = StateStore() - - def test_alert_lifecycle_management(self): - """Business rule: Alert lifecycle should be managed correctly in device state.""" - # Arrange: Add device - device_id = "alert_lifecycle_device" - self.state_store.add_device(device_id, "temperature") - device_state = self.state_store.get(device_id) - - # Act: Simulate alert lifecycle - now = datetime.now(timezone.utc) - - # 1. Open alert - alert1 = Alert( - issue_type="out_of_range", - device_id=device_id, - sensor_type="temperature", - site_id="site1", - severity="warning", - start_ts=now, - end_ts=None, - details={"value": 150.0, "max": 85.0} - ) - device_state.open_alerts["out_of_range"] = alert1 - - # 2. Open second alert - alert2 = Alert( - issue_type="stuck_sensor", - device_id=device_id, - sensor_type="temperature", - site_id="site1", - severity="error", - start_ts=now + timedelta(minutes=1), - end_ts=None, - details={"repeated_value": 150.0} - ) - device_state.open_alerts["stuck_sensor"] = alert2 - - # Assert: Both alerts should be tracked - assert len(device_state.open_alerts) == 2 - assert "out_of_range" in device_state.open_alerts - assert "stuck_sensor" in device_state.open_alerts - - # 3. Close first alert - closed_alert = device_state.open_alerts.pop("out_of_range") - closed_alert.end_ts = now + timedelta(minutes=2) - - # Assert: Only one alert should remain open - assert len(device_state.open_alerts) == 1 - assert "out_of_range" not in device_state.open_alerts - assert "stuck_sensor" in device_state.open_alerts - - # Assert: Closed alert should have end_ts - assert closed_alert.end_ts is not None - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/services/sensorGuard/sensorGuard/tests/test_types.py b/services/sensorGuard/sensorGuard/tests/test_types.py deleted file mode 100644 index d049f6da3..000000000 --- a/services/sensorGuard/sensorGuard/tests/test_types.py +++ /dev/null @@ -1,170 +0,0 @@ -import pytest -from datetime import datetime, timezone -import sys -import os - -# Add the flink_app to path for imports -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'flink_app')) - -from core.types import Event, Alert, DeviceState - - -class TestEvent: - """Test Event class""" - - def test_event_creation(self): - """Test creating Event with valid data""" - # Arrange - ts = datetime.now(timezone.utc) - - # Act - event = Event( - ts=ts, - device_id="sensor_1", - sensor_type="temperature", - site_id=None, - msg_type="telemetry", - value=25.5, - seq=1, - quality="ok" - ) - - # Assert - assert event.ts == ts - assert event.device_id == "sensor_1" - assert event.sensor_type == "temperature" - assert event.value == 25.5 - assert event.msg_type == "telemetry" - assert event.seq == 1 - assert event.quality == "ok" - - def test_event_with_minimal_data(self): - """Test creating Event with minimal required data""" - # Arrange & Act - ts = datetime.now(timezone.utc) - event = Event( - ts=ts, - device_id="sensor_1", - sensor_type="temperature", - site_id=None, - msg_type="reading", - value=None, - seq=None, - quality=None - ) - - # Assert - assert event.device_id == "sensor_1" - assert event.sensor_type == "temperature" - assert event.value is None - - -class TestAlert: - """Test Alert class""" - - def test_alert_creation(self): - """Test creating Alert with valid data""" - # Arrange - start_ts = datetime.now(timezone.utc) - - # Act - alert = Alert( - device_id="sensor_1", - issue_type="out_of_range", - severity="error", - start_ts=start_ts, - end_ts=None, - sensor_type="temperature" - ) - - # Assert - assert alert.issue_type == "out_of_range" - assert alert.device_id == "sensor_1" - assert alert.severity == "error" - assert alert.start_ts == start_ts - assert alert.end_ts is None - assert alert.sensor_type == "temperature" - - def test_alert_with_end_time(self): - """Test alert with both start and end times""" - # Arrange - start_ts = datetime.now(timezone.utc) - end_ts = datetime.now(timezone.utc) - - # Act - alert = Alert( - device_id="sensor_2", - issue_type="missing_keepalive", - severity="critical", - start_ts=start_ts, - end_ts=end_ts - ) - - # Assert - assert alert.start_ts == start_ts - assert alert.end_ts == end_ts - - -class TestDeviceState: - """Test DeviceState class""" - - def test_device_state_creation(self): - """Test creating DeviceState""" - # Arrange & Act - device_state = DeviceState( - device_id="sensor_1", - sensor_type="temperature" - ) - - # Assert - assert device_state.device_id == "sensor_1" - assert device_state.sensor_type == "temperature" - assert device_state.last_seen_ts is None - assert device_state.last_value is None - assert len(device_state.open_alerts) == 0 - - def test_device_state_update(self): - """Test updating device state""" - # Arrange - device_state = DeviceState( - device_id="sensor_1", - sensor_type="temperature" - ) - ts = datetime.now(timezone.utc) - - # Act - device_state.last_seen_ts = ts - device_state.last_value = 25.0 - - # Assert - assert device_state.last_seen_ts == ts - assert device_state.last_value == 25.0 - - def test_device_state_alerts_management(self): - """Test managing alerts in device state""" - # Arrange - device_state = DeviceState( - device_id="sensor_1", - sensor_type="temperature" - ) - alert = Alert( - device_id="sensor_1", - issue_type="out_of_range", - severity="error", - start_ts=datetime.now(timezone.utc), - end_ts=None - ) - - # Act - Add alert - device_state.open_alerts["out_of_range"] = alert - - # Assert - assert "out_of_range" in device_state.open_alerts - assert device_state.open_alerts["out_of_range"] == alert - - # Act - Remove alert - removed_alert = device_state.open_alerts.pop("out_of_range") - - # Assert - assert removed_alert == alert - assert len(device_state.open_alerts) == 0 \ No newline at end of file diff --git a/services/sensorGuard/sensorGuard/tests/test_types_fixed.py b/services/sensorGuard/sensorGuard/tests/test_types_fixed.py deleted file mode 100644 index d049f6da3..000000000 --- a/services/sensorGuard/sensorGuard/tests/test_types_fixed.py +++ /dev/null @@ -1,170 +0,0 @@ -import pytest -from datetime import datetime, timezone -import sys -import os - -# Add the flink_app to path for imports -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'flink_app')) - -from core.types import Event, Alert, DeviceState - - -class TestEvent: - """Test Event class""" - - def test_event_creation(self): - """Test creating Event with valid data""" - # Arrange - ts = datetime.now(timezone.utc) - - # Act - event = Event( - ts=ts, - device_id="sensor_1", - sensor_type="temperature", - site_id=None, - msg_type="telemetry", - value=25.5, - seq=1, - quality="ok" - ) - - # Assert - assert event.ts == ts - assert event.device_id == "sensor_1" - assert event.sensor_type == "temperature" - assert event.value == 25.5 - assert event.msg_type == "telemetry" - assert event.seq == 1 - assert event.quality == "ok" - - def test_event_with_minimal_data(self): - """Test creating Event with minimal required data""" - # Arrange & Act - ts = datetime.now(timezone.utc) - event = Event( - ts=ts, - device_id="sensor_1", - sensor_type="temperature", - site_id=None, - msg_type="reading", - value=None, - seq=None, - quality=None - ) - - # Assert - assert event.device_id == "sensor_1" - assert event.sensor_type == "temperature" - assert event.value is None - - -class TestAlert: - """Test Alert class""" - - def test_alert_creation(self): - """Test creating Alert with valid data""" - # Arrange - start_ts = datetime.now(timezone.utc) - - # Act - alert = Alert( - device_id="sensor_1", - issue_type="out_of_range", - severity="error", - start_ts=start_ts, - end_ts=None, - sensor_type="temperature" - ) - - # Assert - assert alert.issue_type == "out_of_range" - assert alert.device_id == "sensor_1" - assert alert.severity == "error" - assert alert.start_ts == start_ts - assert alert.end_ts is None - assert alert.sensor_type == "temperature" - - def test_alert_with_end_time(self): - """Test alert with both start and end times""" - # Arrange - start_ts = datetime.now(timezone.utc) - end_ts = datetime.now(timezone.utc) - - # Act - alert = Alert( - device_id="sensor_2", - issue_type="missing_keepalive", - severity="critical", - start_ts=start_ts, - end_ts=end_ts - ) - - # Assert - assert alert.start_ts == start_ts - assert alert.end_ts == end_ts - - -class TestDeviceState: - """Test DeviceState class""" - - def test_device_state_creation(self): - """Test creating DeviceState""" - # Arrange & Act - device_state = DeviceState( - device_id="sensor_1", - sensor_type="temperature" - ) - - # Assert - assert device_state.device_id == "sensor_1" - assert device_state.sensor_type == "temperature" - assert device_state.last_seen_ts is None - assert device_state.last_value is None - assert len(device_state.open_alerts) == 0 - - def test_device_state_update(self): - """Test updating device state""" - # Arrange - device_state = DeviceState( - device_id="sensor_1", - sensor_type="temperature" - ) - ts = datetime.now(timezone.utc) - - # Act - device_state.last_seen_ts = ts - device_state.last_value = 25.0 - - # Assert - assert device_state.last_seen_ts == ts - assert device_state.last_value == 25.0 - - def test_device_state_alerts_management(self): - """Test managing alerts in device state""" - # Arrange - device_state = DeviceState( - device_id="sensor_1", - sensor_type="temperature" - ) - alert = Alert( - device_id="sensor_1", - issue_type="out_of_range", - severity="error", - start_ts=datetime.now(timezone.utc), - end_ts=None - ) - - # Act - Add alert - device_state.open_alerts["out_of_range"] = alert - - # Assert - assert "out_of_range" in device_state.open_alerts - assert device_state.open_alerts["out_of_range"] == alert - - # Act - Remove alert - removed_alert = device_state.open_alerts.pop("out_of_range") - - # Assert - assert removed_alert == alert - assert len(device_state.open_alerts) == 0 \ No newline at end of file diff --git a/services/sensorGuard/sensorGuard/tests/test_types_quality.py b/services/sensorGuard/sensorGuard/tests/test_types_quality.py deleted file mode 100644 index d72e23ce8..000000000 --- a/services/sensorGuard/sensorGuard/tests/test_types_quality.py +++ /dev/null @@ -1,439 +0,0 @@ -""" -Professional, comprehensive tests for Types (dataclasses). -These tests verify data validation, business rules, and type safety. -""" -import pytest -from datetime import datetime, timezone, timedelta -from copy import deepcopy - -import sys -import os -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'flink_app')) - -from core.types import Event, Alert, DeviceState - - -class TestEventDataIntegrity: - """Tests that verify Event dataclass behavior and business rules.""" - - def test_event_creation_with_all_required_fields(self): - """CRITICAL: Event must be creatable with all required fields.""" - # Arrange - test_time = datetime.now(timezone.utc) - - # Act: Create event with all fields - event = Event( - ts=test_time, - device_id="sensor_123", - sensor_type="temperature", - site_id="greenhouse_a", - msg_type="reading", - value=24.7, - seq=42, - quality="ok" - ) - - # Assert: All fields should be set correctly - assert event.ts == test_time - assert event.device_id == "sensor_123" - assert event.sensor_type == "temperature" - assert event.site_id == "greenhouse_a" - assert event.msg_type == "reading" - assert event.value == 24.7 - assert event.seq == 42 - assert event.quality == "ok" - - def test_event_with_none_optional_fields(self): - """Business rule: Event should handle None values for optional fields.""" - # Act: Create event with minimal required fields - event = Event( - ts=datetime.now(timezone.utc), - device_id="minimal_device", - sensor_type="humidity", - site_id=None, # Optional - msg_type="keepalive", - value=None, # Optional (e.g., keepalive messages) - seq=None, # Optional - quality=None # Optional - ) - - # Assert: Should handle None values gracefully - assert event.site_id is None - assert event.value is None - assert event.seq is None - assert event.quality is None - - def test_event_immutability_after_creation(self): - """Data integrity: Event fields should be modifiable after creation.""" - # Arrange: Create event - original_time = datetime.now(timezone.utc) - event = Event( - ts=original_time, - device_id="test_device", - sensor_type="temperature", - site_id="site1", - msg_type="reading", - value=25.0, - seq=1, - quality="ok" - ) - - # Act: Modify fields (should be allowed for dataclass) - new_time = original_time + timedelta(seconds=30) - event.ts = new_time - event.value = 26.5 - event.quality = "corrupted" - - # Assert: Changes should be reflected - assert event.ts == new_time - assert event.value == 26.5 - assert event.quality == "corrupted" - - def test_event_different_msg_types_validation(self): - """Business rule: Different msg_type values should be handled correctly.""" - base_params = { - "ts": datetime.now(timezone.utc), - "device_id": "msg_type_test", - "sensor_type": "temperature", - "site_id": "site1" - } - - # Test reading message - reading_event = Event( - **base_params, - msg_type="reading", - value=23.4, - seq=1, - quality="ok" - ) - assert reading_event.msg_type == "reading" - assert reading_event.value is not None - - # Test keepalive message (typically no sensor value) - keepalive_event = Event( - **base_params, - msg_type="keepalive", - value=None, # Keepalives usually don't have sensor values - seq=2, - quality=None - ) - assert keepalive_event.msg_type == "keepalive" - assert keepalive_event.value is None - - -class TestAlertDataIntegrity: - """Tests that verify Alert dataclass behavior and lifecycle management.""" - - def test_alert_creation_with_required_fields(self): - """CRITICAL: Alert must be creatable with all required fields.""" - # Arrange - start_time = datetime.now(timezone.utc) - - # Act: Create alert - alert = Alert( - device_id="alert_test_device", - issue_type="out_of_range", - start_ts=start_time, - end_ts=None, # Open alert - severity="warning", - sensor_type="temperature", - site_id="greenhouse_b", - details={"value": 150.0, "max_allowed": 85.0} - ) - - # Assert: All fields should be set correctly - assert alert.device_id == "alert_test_device" - assert alert.issue_type == "out_of_range" - assert alert.start_ts == start_time - assert alert.end_ts is None - assert alert.severity == "warning" - assert alert.sensor_type == "temperature" - assert alert.site_id == "greenhouse_b" - assert alert.details["value"] == 150.0 - assert alert.details["max_allowed"] == 85.0 - - def test_alert_lifecycle_open_to_closed(self): - """Business rule: Alert should properly transition from open to closed state.""" - # Arrange: Create open alert - start_time = datetime.now(timezone.utc) - alert = Alert( - device_id="lifecycle_test", - issue_type="stuck_sensor", - start_ts=start_time, - end_ts=None, # Initially open - severity="error" - ) - - # Verify initially open - assert alert.end_ts is None - - # Act: Close the alert - end_time = start_time + timedelta(minutes=5) - alert.end_ts = end_time - - # Assert: Should be properly closed - assert alert.end_ts == end_time - duration = alert.end_ts - alert.start_ts - assert duration.total_seconds() == 300 # 5 minutes - - def test_alert_different_severities(self): - """Business rule: Different severity levels should be supported.""" - base_params = { - "device_id": "severity_test", - "issue_type": "test_issue", - "start_ts": datetime.now(timezone.utc), - "end_ts": None - } - - # Test different severity levels - warning_alert = Alert(**base_params, severity="warning") - error_alert = Alert(**base_params, severity="error") - critical_alert = Alert(**base_params, severity="critical") - - assert warning_alert.severity == "warning" - assert error_alert.severity == "error" - assert critical_alert.severity == "critical" - - def test_alert_details_dictionary_flexibility(self): - """Business rule: Alert details should support flexible data structures.""" - # Act: Create alert with complex details - alert = Alert( - device_id="details_test", - issue_type="complex_issue", - start_ts=datetime.now(timezone.utc), - end_ts=None, - severity="warning", - details={ - "sensor_readings": [22.1, 22.1, 22.1, 22.1], - "threshold": 0.1, - "consecutive_count": 4, - "metadata": { - "location": "field_section_3", - "operator": "automated_system" - }, - "numeric_value": 42.7, - "boolean_flag": True - } - ) - - # Assert: Complex details should be preserved - assert len(alert.details["sensor_readings"]) == 4 - assert alert.details["threshold"] == 0.1 - assert alert.details["metadata"]["location"] == "field_section_3" - assert alert.details["numeric_value"] == 42.7 - assert alert.details["boolean_flag"] is True - - -class TestDeviceStateDataIntegrity: - """Tests that verify DeviceState dataclass behavior and state management.""" - - def test_device_state_creation_with_minimal_params(self): - """CRITICAL: DeviceState should initialize with sensible defaults.""" - # Act: Create device state with minimal parameters - device_state = DeviceState(device_id="minimal_device") - - # Assert: Should have proper defaults - assert device_state.device_id == "minimal_device" - assert device_state.sensor_type is None - assert device_state.last_seen_ts is None - assert device_state.last_value is None - assert device_state.run_length == 0 - assert device_state.stuck_since_ts is None - assert device_state.open_alerts == {} - - def test_device_state_alert_management(self): - """Business rule: DeviceState should properly manage multiple open alerts.""" - # Arrange: Create device state - device_state = DeviceState( - device_id="multi_alert_device", - sensor_type="temperature" - ) - - # Act: Add multiple alerts - now = datetime.now(timezone.utc) - - alert1 = Alert( - device_id="multi_alert_device", - issue_type="out_of_range", - start_ts=now, - end_ts=None, - severity="warning" - ) - - alert2 = Alert( - device_id="multi_alert_device", - issue_type="stuck_sensor", - start_ts=now + timedelta(minutes=1), - end_ts=None, - severity="error" - ) - - device_state.open_alerts["out_of_range"] = alert1 - device_state.open_alerts["stuck_sensor"] = alert2 - - # Assert: Both alerts should be tracked - assert len(device_state.open_alerts) == 2 - assert "out_of_range" in device_state.open_alerts - assert "stuck_sensor" in device_state.open_alerts - - # Assert: Alerts should maintain their properties - assert device_state.open_alerts["out_of_range"].severity == "warning" - assert device_state.open_alerts["stuck_sensor"].severity == "error" - - def test_device_state_evolution_over_time(self): - """Business rule: DeviceState should track sensor data evolution correctly.""" - # Arrange: Create device state - device_state = DeviceState( - device_id="evolution_test", - sensor_type="humidity" - ) - - # Act: Simulate sensor data evolution - time1 = datetime.now(timezone.utc) - time2 = time1 + timedelta(minutes=1) - time3 = time1 + timedelta(minutes=2) - - # First reading - device_state.last_seen_ts = time1 - device_state.last_value = 45.2 - device_state.run_length = 1 - - # Second reading (same value - potential stuck sensor) - device_state.last_seen_ts = time2 - device_state.last_value = 45.2 # Same value - device_state.run_length = 2 - device_state.stuck_since_ts = time1 # Started being stuck from first occurrence - - # Third reading (different value - unstuck) - device_state.last_seen_ts = time3 - device_state.last_value = 46.8 # Different value - device_state.run_length = 1 # Reset - device_state.stuck_since_ts = None # No longer stuck - - # Assert: State evolution should be properly tracked - assert device_state.last_seen_ts == time3 - assert device_state.last_value == 46.8 - assert device_state.run_length == 1 - assert device_state.stuck_since_ts is None - - def test_device_state_deep_copy_independence(self): - """Data integrity: DeviceState copies should be independent.""" - # Arrange: Create device state with complex data - original_time = datetime.now(timezone.utc) - original_state = DeviceState( - device_id="copy_test", - sensor_type="temperature", - last_seen_ts=original_time, - last_value=25.0, - run_length=3 - ) - - # Add an alert - alert = Alert( - device_id="copy_test", - issue_type="test_alert", - start_ts=original_time, - end_ts=None, - severity="warning" - ) - original_state.open_alerts["test_alert"] = alert - - # Act: Create deep copy - copied_state = deepcopy(original_state) - - # Modify original - new_time = original_time + timedelta(minutes=1) - original_state.last_seen_ts = new_time - original_state.last_value = 26.0 - original_state.open_alerts["test_alert"].severity = "error" - - # Assert: Copy should remain unchanged - assert copied_state.last_seen_ts == original_time - assert copied_state.last_value == 25.0 - assert copied_state.open_alerts["test_alert"].severity == "warning" - - -class TestTypesBusinessRules: - """Tests that verify business rules across all types.""" - - def test_event_to_alert_data_consistency(self): - """Business rule: Alerts generated from Events should maintain data consistency.""" - # Arrange: Create event - event_time = datetime.now(timezone.utc) - event = Event( - ts=event_time, - device_id="consistency_test", - sensor_type="pressure", - site_id="factory_floor_2", - msg_type="reading", - value=999.9, # Out of range value - seq=15, - quality="ok" - ) - - # Act: Create alert based on event (simulating engine behavior) - alert = Alert( - device_id=event.device_id, # Must match - issue_type="out_of_range", - start_ts=event.ts, # Must use event timestamp - end_ts=None, - severity="error", - sensor_type=event.sensor_type, # Must match - site_id=event.site_id, # Must match - details={ - "trigger_value": event.value, # Include event data - "trigger_seq": event.seq, - "trigger_quality": event.quality - } - ) - - # Assert: Data consistency between event and alert - assert alert.device_id == event.device_id - assert alert.start_ts == event.ts - assert alert.sensor_type == event.sensor_type - assert alert.site_id == event.site_id - assert alert.details["trigger_value"] == event.value - assert alert.details["trigger_seq"] == event.seq - assert alert.details["trigger_quality"] == event.quality - - def test_timestamp_ordering_consistency(self): - """Business rule: Timestamps should maintain logical ordering.""" - # Arrange: Create time sequence - base_time = datetime.now(timezone.utc) - event_time = base_time - alert_start = base_time + timedelta(seconds=1) - alert_end = base_time + timedelta(minutes=5) - - # Act: Create objects with time sequence - event = Event( - ts=event_time, - device_id="timing_test", - sensor_type="temperature", - site_id="test_site", - msg_type="reading", - value=25.0, - seq=1, - quality="ok" - ) - - alert = Alert( - device_id="timing_test", - issue_type="test_issue", - start_ts=alert_start, - end_ts=alert_end, - severity="warning" - ) - - device_state = DeviceState( - device_id="timing_test", - last_seen_ts=event_time - ) - - # Assert: Timestamp relationships should be logical - assert event.ts <= alert.start_ts # Event should trigger alert - assert alert.start_ts < alert.end_ts # Alert start before end - assert device_state.last_seen_ts == event.ts # State reflects event time - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/simulators/docker-compose.yml b/simulators/docker-compose.yml index 0dee1f194..30d1d6f31 100644 --- a/simulators/docker-compose.yml +++ b/simulators/docker-compose.yml @@ -8,32 +8,32 @@ services: - ./data/air/metadata:/data/metadata:ro command: ["python", "-u", "/app/data_publisher.py"] - sound-publisher: - build: . - container_name: sound-publisher - env_file: .env.sound - volumes: - - ./data/sound/sounds:/data/sound/sounds:ro - - ./data/sound/metadata:/data/sound/metadata:ro - command: ["python", "-u", "/app/data_publisher.py"] + # sound-publisher: + # build: . + # container_name: sound-publisher + # env_file: .env.sound + # volumes: + # - ./data/sound/sounds:/data/sound/sounds:ro + # - ./data/sound/metadata:/data/sound/metadata:ro + # command: ["python", "-u", "/app/data_publisher.py"] - ultra-sound-publisher: - build: . - container_name: ultra-sound-publisher - env_file: .env.ultra - volumes: - - ./data/ultra-sound/sounds:/data/ultra-sound/sounds:ro - - ./data/ultra-sound/metadata:/data/ultra-sound/metadata:ro - command: ["python", "-u", "/app/data_publisher.py"] + # ultra-sound-publisher: + # build: . + # container_name: ultra-sound-publisher + # env_file: .env.ultra + # volumes: + # - ./data/ultra-sound/sounds:/data/ultra-sound/sounds:ro + # - ./data/ultra-sound/metadata:/data/ultra-sound/metadata:ro + # command: ["python", "-u", "/app/data_publisher.py"] - fruit-publisher: - build: . - container_name: fruit-publisher - env_file: .env.fruit - volumes: - - ./data/fruit/images:/data/fruit/images:ro - - ./data/fruit/metadata:/data/metadata:ro - command: ["python", "-u", "/app/data_publisher.py"] + # fruit-publisher: + # build: . + # container_name: fruit-publisher + # env_file: .env.fruit + # volumes: + # - ./data/fruit/images:/data/fruit/images:ro + # - ./data/fruit/metadata:/data/metadata:ro + # command: ["python", "-u", "/app/data_publisher.py"] networks: default: external: true diff --git a/storage_with_mqtt/mqtt_images/mqtt_ingest/app.py b/storage_with_mqtt/mqtt_images/mqtt_ingest/app.py index 7fd26d5bd..ddb8eb925 100644 --- a/storage_with_mqtt/mqtt_images/mqtt_ingest/app.py +++ b/storage_with_mqtt/mqtt_images/mqtt_ingest/app.py @@ -114,33 +114,27 @@ def normalize_content_type(ctype: str, filename: str) -> str: return ctype guess, _ = mimetypes.guess_type(filename) return guess or "application/octet-stream" - def parse_topic(topic: str) -> dict: parts = [p for p in topic.split("/") if p] parts_lower = [p.lower() for p in parts] now = now_ms() result = { - "camera": DEFAULT_PREFIX, + "camera": DEFAULT_PREFIX, "publish_ts_ms": now, "content_type": "application/octet-stream", "filename": f"{now}.bin", "media_type": "image", } - # --- detect namespace and offsets --- ns = None idx = -1 - # for cand in ("imagery", "sounds"): - # if cand in parts: - # ns, idx = cand, parts.index(cand) - # break if "imagery" in parts_lower: ns, idx = "imagery", parts_lower.index("imagery") - elif any(p.startswith("sounds_ultra") for p in parts_lower): + elif any(p.startswith("sounds_ultra") for p in parts_lower): ns, idx = "sounds_ultra", next(i for i, p in enumerate(parts_lower) if p.startswith("sounds_ultra")) elif "sounds" in parts_lower: ns, idx = "sounds", parts_lower.index("sounds") - + # --- parse imagery or sounds --- if ns == "imagery": tail = parts[-3:] if len(parts) >= 3 else parts head = parts[idx + 1 : len(parts) - 3] if len(parts) > idx + 4 else parts[idx + 1 : idx + 2] @@ -153,7 +147,6 @@ def parse_topic(topic: str) -> dict: prefix = "/".join(head[:-1]) else: prefix = "" - print(f"[DEBUG] result['camera']={result['camera']}, prefix={prefix}, FORCE_DEVICE_ID={FORCE_DEVICE_ID}", flush=True) try: result["publish_ts_ms"] = int(tail[0]) @@ -164,19 +157,14 @@ def parse_topic(topic: str) -> dict: if len(tail) >= 3: result["filename"] = tail[2] - # --- DEBUG + try to detect device from filename --- + # --- detect device from filename --- filename_base = os.path.splitext(result["filename"])[0] if "_" in filename_base: possible_device = filename_base.split("_")[0] - print(f"[DEBUG] filename_base={filename_base}, possible_device={possible_device}", flush=True) if possible_device.lower().startswith("fruit") or possible_device.lower().startswith("camera"): result["camera"] = possible_device - print(f"[DEBUG] DETECTED device from filename -> {result['camera']}", flush=True) - else: - print(f"[DEBUG] filename does not match expected pattern", flush=True) - else: - print(f"[DEBUG] filename_base has no '-': {filename_base}", flush=True) result["extra_prefix"] = prefix + elif ns in ("sounds", "sounds_ultra"): if len(parts) > idx + 1 and parts[idx + 1]: try: @@ -189,8 +177,7 @@ def parse_topic(topic: str) -> dict: result["content_type"] = parts[idx + 2].replace("_", "/") if len(parts) > idx + 3 and parts[idx + 3]: result["filename"] = parts[idx + 3] - - # normalize + media type detect + # --- normalize + media type detect --- result["content_type"] = normalize_content_type(result["content_type"], result["filename"]) ctype = result["content_type"].lower() if ctype.startswith("image/"): @@ -207,12 +194,11 @@ def parse_topic(topic: str) -> dict: result["media_type"] = "sounds" else: result["media_type"] = "image" - date_part = datetime.fromtimestamp(result["publish_ts_ms"] / 1000, tz=timezone.utc).strftime("%Y-%m-%d") device_id = result["camera"] if FORCE_DEVICE_ID: - device_id = FORCE_DEVICE_ID - + device_id = FORCE_DEVICE_ID + # --- build device_name --- if result["media_type"] == "sounds": if device_id.startswith(f"{CAMERA_PREFIX}-"): device_name = device_id.replace(f"{CAMERA_PREFIX}-", f"{MICROPHONE_PREFIX}-", 1) @@ -227,23 +213,23 @@ def parse_topic(topic: str) -> dict: device_name = device_id.replace(f"{MICROPHONE_PREFIX}-", f"{CAMERA_PREFIX}-", 1) else: device_name = f"{CAMERA_PREFIX}-{device_id}" - - is_ultra = ns == "sounds_ultra" + is_ultra = ns == "sounds_ultra" topdir = ULTRA_DIR_PREFIX if is_ultra else result["media_type"] - if ns == "imagery": + # --- SPECIAL FRUIT ROUTING OVERRIDE --- + + if result['filename'].lower().startswith("fruit"): subpath = "/".join(parts[idx + 1 : -3]) - if subpath: + if subpath: key = f"{subpath}/{device_name}/{date_part}/{result['publish_ts_ms']}/{result['filename']}" - else: - key = f"{topdir}/{device_name}/{date_part}/{result['publish_ts_ms']}/{result['filename']}" + result["key"] = key else: - key = f"{topdir}/{device_name}/{date_part}/{result['publish_ts_ms']}/{result['filename']}" - result["key"] = key + key = f"{topdir}/{device_name}/{date_part}/{result['publish_ts_ms']}/{result['filename']}" + result["key"] = key result["device_id"] = device_name result["image_id"] = stem(result["filename"]) or uuid.uuid4().hex result["capture_ts_iso"] = iso_utc(result["publish_ts_ms"]) return result - + # ---------- Uploader ---------- def upload_bytes(key: str, data: bytes, content_type: str) -> str: checksum = sha256_hex(data) diff --git a/storage_with_mqtt/storage/Lifecycle_rules/minio-bootstrap/Dockerfile b/storage_with_mqtt/storage/Lifecycle_rules/minio-bootstrap/Dockerfile index d5b4d94f9..362870915 100644 --- a/storage_with_mqtt/storage/Lifecycle_rules/minio-bootstrap/Dockerfile +++ b/storage_with_mqtt/storage/Lifecycle_rules/minio-bootstrap/Dockerfile @@ -1,27 +1,38 @@ # ============================ -# Stage 1: copy mc binary +# Stage 1: get mc binary # ============================ FROM minio/mc:latest AS mc-source + # ============================ # Stage 2: main image # ============================ FROM alpine:3.19 -# ===== Install dependencies ===== -# include dos2unix so line endings are normalized automatically -RUN apk add --no-cache bash curl ca-certificates netcat-openbsd dos2unix && \ + +# Install base tools +RUN apk add --no-cache \ + bash \ + curl \ + ca-certificates \ + netcat-openbsd \ + dos2unix && \ update-ca-certificates + # ===== Add NetFree CA ===== -COPY certs/*.crt /usr/local/share/ca-certificates/ -RUN update-ca-certificates -# ===== Copy mc from the official image ===== +RUN mkdir -p /usr/local/share/ca-certificates +COPY certs/ /usr/local/share/ca-certificates/ +RUN update-ca-certificates || true + +# ---------------------------- +# Install mc (MinIO client) +# ---------------------------- COPY --from=mc-source /usr/bin/mc /usr/local/bin/mc RUN chmod +x /usr/local/bin/mc -# ===== Create directories ===== + +# ---------------------------- +# Entrypoint script +# ---------------------------- RUN mkdir -p /entrypoint /config -# ===== Copy init script ===== COPY entrypoint/init.sh /entrypoint/init.sh -# ===== Normalize and ensure execution permissions ===== -# (this guarantees LF endings + execute permission for everyone) RUN dos2unix /entrypoint/init.sh && chmod 755 /entrypoint/init.sh -# ===== Entry point ===== + CMD ["/entrypoint/init.sh"] diff --git a/storage_with_mqtt/storage/Lifecycle_rules/minio-bootstrap/entrypoint/init.sh b/storage_with_mqtt/storage/Lifecycle_rules/minio-bootstrap/entrypoint/init.sh index 35a454f5f..9b988eeb3 100644 --- a/storage_with_mqtt/storage/Lifecycle_rules/minio-bootstrap/entrypoint/init.sh +++ b/storage_with_mqtt/storage/Lifecycle_rules/minio-bootstrap/entrypoint/init.sh @@ -21,6 +21,11 @@ echo "[bootstrap] Waiting for COLD (${COLD_ENDPOINT})..." until mc ls "${MC_ALIAS_COLD}" >/dev/null 2>&1; do sleep 2; done echo "[bootstrap] Creating buckets..." + +# Allow anonymous download on all HOT buckets +mc anonymous set download "${MC_ALIAS_HOT}/sound" || true +mc anonymous set download "${MC_ALIAS_HOT}/imagery" || true + mc mb "${MC_ALIAS_HOT}/imagery" || true mc mb "${MC_ALIAS_HOT}/sound" || true mc mb "${MC_ALIAS_COLD}/imagery" || true