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