diff --git a/GUI/src/vast/desktop/Dockerfile b/GUI/src/vast/desktop/Dockerfile
index b9357c05c..d60b2dc24 100644
--- a/GUI/src/vast/desktop/Dockerfile
+++ b/GUI/src/vast/desktop/Dockerfile
@@ -71,7 +71,7 @@ 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/*
-
+RUN pip install psycopg2-binary
COPY src/vast /app/src/vast
COPY src/vast/desktop/start.sh /app/start.sh
RUN sed -i 's/\r$//' /app/start.sh && chmod +x /app/start.sh && chown -R appuser:appuser /app
diff --git a/GUI/src/vast/dsl/builder.py b/GUI/src/vast/dsl/builder.py
index 8636ddf6c..cf17cf985 100644
--- a/GUI/src/vast/dsl/builder.py
+++ b/GUI/src/vast/dsl/builder.py
@@ -28,11 +28,7 @@ class SQLState:
"select", "from", "where", "group_by", "having", "order_by", "limit", "offset"
])
- # Helper methods to avoid importing Clause classes in ops
- def add_select(self, columns: List[str]) -> None:
- self.clauses["select"].append(SelectClause(columns))
- def add_where(self, cond) -> None:
- self.clauses["where"].append(WhereClause(cond))
+
def add_clause(self, clause: Clause) -> None:
self.clauses[clause.phase].append(clause)
@@ -92,4 +88,4 @@ def compile(self, plan: Plan | Dict[str, Any]) -> tuple[str, List[Any]]:
# Pass all keys except "op" as kwargs
Op.registry[op_type](**{k: v for k, v in op.items() if k != "op"}).apply(st)
- return st.build()
\ No newline at end of file
+ return st.build()
diff --git a/GUI/src/vast/dsl/clauses.py b/GUI/src/vast/dsl/clauses.py
index cc7cfddc9..d2f2ecf48 100644
--- a/GUI/src/vast/dsl/clauses.py
+++ b/GUI/src/vast/dsl/clauses.py
@@ -94,10 +94,8 @@ def joiner(self): return ", "
def fragment(self, ctx):
def quote_or_passthrough(col: str) -> str:
# Skip quoting if it's clearly an SQL expression or aggregate
- if any(token in col.upper() for token in (
- "(", ")", " AS ", "COUNT", "AVG", "SUM", "MAX", "MIN"
- )):
- return col # leave expressions as-is
+ if "(" in col or ")" in col:
+ return col
return ctx.dialect.quote_ident(col)
cols = [quote_or_passthrough(c) for c in self.columns]
diff --git a/GUI/src/vast/dsl/dialects.py b/GUI/src/vast/dsl/dialects.py
index 36bcce02b..b83ce6c9f 100644
--- a/GUI/src/vast/dsl/dialects.py
+++ b/GUI/src/vast/dsl/dialects.py
@@ -34,24 +34,6 @@ def normalize_bool(self, v: Any) -> Any:
def placeholder(self, idx: int) -> str:
return "?" # qmark style
-# class PostgresDialect(Dialect):
-# def __init__(self, style: str = "psycopg"):
-# """style:
-# - 'psycopg' → %s style placeholders (psycopg2/3)
-# - 'numeric' → $1, $2, ... style placeholders (asyncpg)
-# """
-
-# if style not in ("psycopg", "numeric"):
-# raise ValueError("PostgresDialect.style must be 'psycopg' or 'numeric'")
-# self.style = style
-# def quote_ident(self, name: str) -> str:
-# parts = name.split(".")
-# return ".".join('"' + p.replace('"', '""') + '"' for p in parts)
-# def normalize_bool(self, v: Any) -> Any:
-# return v # PostgreSQL has a real boolean type
-# def placeholder(self, idx: int) -> str:
-# return "%s" if self.style == "psycopg" else f"${idx}"
-# dialects.py
class PostgresDialect(Dialect):
def __init__(self, style: str = "named"):
"""
diff --git a/GUI/src/vast/dsl/expr.py b/GUI/src/vast/dsl/expr.py
index 4e9cffc70..d9c41aff3 100644
--- a/GUI/src/vast/dsl/expr.py
+++ b/GUI/src/vast/dsl/expr.py
@@ -62,6 +62,22 @@ def compile(self, ctx: CompileCtx) -> str:
def to_ir(self) -> Dict[str, Any]:
return {"literal": self.value}
+@dataclass
+class Func(Expr):
+ name: str
+ args: list[Expr]
+
+ def compile(self, ctx: CompileCtx) -> str:
+ compiled = ", ".join(arg.compile(ctx) for arg in self.args)
+ return f"{self.name.upper()}({compiled})"
+
+ def to_ir(self) -> Dict[str, Any]:
+ return {
+ "func": self.name,
+ "args": [arg.to_ir() for arg in self.args]
+ }
+
+
def ensure_expr(x: Any) -> Expr:
"""Coerce Python values to Literal, leave Expr as-is."""
@@ -85,7 +101,7 @@ class Predicate(Cond):
op: BinOp
right: Expr
def __post_init__(self):
- if not isinstance(self.left, (Col, Literal)) or not isinstance(self.right, (Col, Literal)):
+ if not isinstance(self.left, (Col, Literal,Func)) or not isinstance(self.right, (Col, Literal,Func)):
raise TypeError("Predicate must compare columns and/or literals only")
def compile(self, ctx: CompileCtx) -> str:
return f"({self.left.compile(ctx)} {self.op.value} {self.right.compile(ctx)})"
@@ -109,12 +125,25 @@ def to_ir(self) -> Dict[str, Any]: return {"any": [p.to_ir() for p in self.parts
# ---- Strict IR decoding ----
def expr_from_ir(d: Dict[str, Any]) -> Expr:
- """Decode a strict Expr IR object into Expr."""
- if not isinstance(d, dict): raise TypeError("Expr leaf must be an object")
+ if not isinstance(d, dict):
+ raise TypeError("Expr leaf must be an object")
+
keys = set(d.keys())
- if keys == {"col"}: return Col(d["col"])
- if keys == {"literal"}: return Literal(d["literal"])
- raise ValueError("Expr leaf must be either {\"col\": name} or {\"literal\": value}")
+
+ if keys == {"col"}:
+ return Col(d["col"])
+
+ if keys == {"literal"}:
+ return Literal(d["literal"])
+
+ if keys == {"func", "args"}:
+ return Func(
+ d["func"],
+ [expr_from_ir(arg) for arg in d["args"]]
+ )
+
+ raise ValueError(f"Invalid Expr IR: {d}")
+
def cond_from_ir(d: Dict[str, Any]) -> Cond:
@@ -168,4 +197,4 @@ def cond_from_ir(d: Dict[str, Any]) -> Cond:
# Convenience aliases
AND = All
-OR = Any
\ No newline at end of file
+OR = Any
diff --git a/GUI/src/vast/dsl/ops.py b/GUI/src/vast/dsl/ops.py
index f92ca0528..cc66239c0 100644
--- a/GUI/src/vast/dsl/ops.py
+++ b/GUI/src/vast/dsl/ops.py
@@ -28,18 +28,19 @@ def apply(self, st: "SQLState") -> None:
class SelectOp(Op):
op_type = "select"
def apply(self, st: "SQLState") -> None:
- cols = self.payload.get("columns")
- st.add_select(cols or [])
+ cols = self.payload.get("columns", [])
+ st.add_clause(SelectClause(cols))
+
class WhereOp(Op):
op_type = "where"
def apply(self, st: "SQLState") -> None:
cond_ir = self.payload.get("cond")
if not isinstance(cond_ir, dict):
- raise TypeError(
- f"Invalid WHERE condition: expected dict, got {type(cond_ir).__name__} → {cond_ir}"
- )
- st.add_where(cond_from_ir(cond_ir))
+ raise TypeError("Invalid WHERE condition")
+ expr = cond_from_ir(cond_ir)
+ st.add_clause(WhereClause(expr))
+
class HavingOp(Op):
diff --git a/GUI/src/vast/main_window.py b/GUI/src/vast/main_window.py
index 34310e234..726b129c6 100644
--- a/GUI/src/vast/main_window.py
+++ b/GUI/src/vast/main_window.py
@@ -931,6 +931,9 @@
+# === Irrigation imports ===
+from views.irrigation.irrigation_view import IrrigationView
+
class MainWindow(QMainWindow):
logoutRequested = pyqtSignal()
@@ -1138,7 +1141,7 @@ def reposition_badge():
for name in [
"Home", "Sensors", "Sound", "Ground Image",
- "Aerial Image", "Fruits", "Security", "Settings", "Notifications"
+ "Aerial Image", "Fruits", "Security", "Settings", "Notifications", "Irrigation"
]:
QListWidgetItem(f" {name}", self.nav_list)
@@ -1175,6 +1178,21 @@ def reposition_badge():
self.notification_view = NotificationView(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.sensors_status_summary = SensorsStatusSummary(api, self)
+ self.sensors_health = SensorsView(api, self)
+ self.sensors_main = SensorsMainView(api, self)
+ print("[DEBUG] Creating IrrigationView...")
+ try:
+ self.irrigation_view = IrrigationView(api, self)
+ print("[DEBUG] IrrigationView created successfully")
+ except Exception as e:
+ print(f"[ERROR] Failed to create IrrigationView: {e}")
+ import traceback
+ traceback.print_exc()
+ self.irrigation_view = QWidget() # Fallback empty widget
self.stack = QStackedWidget()
self.setCentralWidget(self.stack)
@@ -1185,6 +1203,9 @@ def reposition_badge():
"Notifications": self.notification_view,
"Security": self.security_view,
"Fruits": self.fruits_view,
+ "Ground Image": self.ground_view,
+ "Irrigation": self.irrigation_view,
+ "Auth": self.auth_status
}
for view in self.views.values():
@@ -1242,16 +1263,23 @@ def toggle_alert_panel(self):
# ───────────────────────────────
def _on_nav_change(self, row: int) -> None:
name = self.nav_list.item(row).text().strip()
+ print(f"[DEBUG] _on_nav_change: row={row}, name='{name}'")
+ print(f"[DEBUG] Available views: {list(self.views.keys())}")
if name in self.views:
+ print(f"[DEBUG] Navigating to '{name}'")
self.navigate_to(self.views[name])
else:
+ print(f"[DEBUG] Section '{name}' not found in views")
self.statusBar().showMessage(f"Section '{name}' not implemented yet.")
def navigate_to(self, widget):
+ print(f"[DEBUG] navigate_to called with widget: {widget.__class__.__name__}")
current = self.stack.currentWidget()
if current not in self.history:
self.history.append(current)
+ print(f"[DEBUG] Setting current widget to: {widget.__class__.__name__}")
self.stack.setCurrentWidget(widget)
+ print(f"[DEBUG] Current widget is now: {self.stack.currentWidget().__class__.__name__}")
def go_back(self):
if self.history:
diff --git a/GUI/src/vast/orthophoto_canvas/ui/fields.png b/GUI/src/vast/orthophoto_canvas/ui/fields.png
new file mode 100644
index 000000000..a279cc08d
Binary files /dev/null and b/GUI/src/vast/orthophoto_canvas/ui/fields.png differ
diff --git a/GUI/src/vast/orthophoto_canvas/ui/viewer.py b/GUI/src/vast/orthophoto_canvas/ui/viewer.py
index 34f0fc4a2..ef2c122c9 100644
--- a/GUI/src/vast/orthophoto_canvas/ui/viewer.py
+++ b/GUI/src/vast/orthophoto_canvas/ui/viewer.py
@@ -12,7 +12,7 @@
from PyQt6.QtWidgets import (
QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QGraphicsRectItem
)
-
+from PyQt6.QtGui import QPixmap, QTransform
# ==== Tunables ====
TILE_SIZE = 512
TARGET_TILE_PX_FOR_LOD = 512.0
@@ -25,18 +25,7 @@ class OrthophotoViewer(QGraphicsView):
def __init__(self, tiles: Union[TileStore, str, Path]) -> None:
super().__init__()
- # ─────────────────────────────
- # Load tiles
- # ─────────────────────────────
- # if isinstance(tiles, TileStore):
- # self.ts = tiles
- # else:
- # self.ts = TileStore(Path(tiles))
-
- # self.min_zoom_fs = self.ts.min_zoom
- # self.max_zoom_fs = self.ts.max_zoom
- # self.z_ranges = self.ts.z_ranges
- # self.is_tms = self.ts.is_tms
+
# ─────────────────────────────
# Load tiles
# ─────────────────────────────
@@ -125,8 +114,58 @@ def __init__(self, tiles: Union[TileStore, str, Path]) -> None:
# ─────────────────────────────
# Initial tile rendering
# ─────────────────────────────
+ self._custom_bg_item: Optional[QGraphicsPixmapItem] = None
+ self._tiles_visible: bool = True
self.update_tiles()
-
+
+ def _apply_tile_visibility(self):
+ """Apply visibility/opacity preference to existing tile items."""
+ for item in self.tile_items.values():
+ # You can choose to hide or fade; here we just hide/show them.
+ item.setVisible(self._tiles_visible)
+ # If you prefer fading:
+ # item.setOpacity(0.2 if not self._tiles_visible else 1.0)
+
+
+
+ def set_custom_background_image(self, path: str, hide_tiles: bool = False):
+ """
+ Place a single static image as the map background, scaled to the scene extents.
+ It will zoom & pan together with all other items.
+ """
+ pix = QPixmap(path)
+ p = Path(path)
+ print("[OrthophotoViewer] Exists?", p.exists())
+ if pix.isNull():
+ print(f"[OrthophotoViewer] ❌ Failed to load background image: {path}")
+ return
+
+ # Remove previous bg if exists
+ if self._custom_bg_item is not None:
+ self.scene.removeItem(self._custom_bg_item)
+ self._custom_bg_item = None
+
+ scene_rect = self.scene.sceneRect()
+ width = scene_rect.width()
+ height = scene_rect.height()
+
+ item = QGraphicsPixmapItem(pix)
+ item.setZValue(-1000) # behind tiles, regions, sensors
+
+ # Scale to fill the entire scene rect
+ sx = width / pix.width() if pix.width() > 0 else 1.0
+ sy = height / pix.height() if pix.height() > 0 else 1.0
+ item.setTransform(QTransform().scale(sx, sy))
+
+ # Position at the scene rect origin (you use a small margin, so respect that)
+ item.setPos(scene_rect.left(), scene_rect.top())
+
+ self.scene.addItem(item)
+ self._custom_bg_item = item
+
+ if hide_tiles:
+ self._tiles_visible = False
+ self._apply_tile_visibility()
# ─────────────────────────────
# Scene geometry
@@ -228,6 +267,9 @@ def update_tiles(self) -> None:
if key not in want:
self.scene.removeItem(self.tile_items.pop(key))
+ # 🔹 Ensure visibility style is applied to all tiles (including new ones)
+ self._apply_tile_visibility()
+
# ─────────────────────────────
# Tile placement / upgrade
# ─────────────────────────────
diff --git a/GUI/src/vast/views/SimilarPeriodsSensors.py b/GUI/src/vast/views/SimilarPeriodsSensors.py
new file mode 100644
index 000000000..8c0a0c84d
--- /dev/null
+++ b/GUI/src/vast/views/SimilarPeriodsSensors.py
@@ -0,0 +1,345 @@
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame, QComboBox,
+ QPushButton, QGridLayout
+)
+from PyQt6.QtWebEngineWidgets import QWebEngineView
+from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QColor
+import traceback
+
+
+class SimilarPeriodsTab(QWidget):
+ def __init__(self, api, parent=None):
+ super().__init__(parent)
+ self.api = api
+
+ # vector service internal Docker endpoint
+ self.vector_base = "http://vector_service:8000"
+
+ # ======= MAIN LAYOUT (compact) =======
+ main = QVBoxLayout(self)
+ main.setContentsMargins(20, 20, 20, 20)
+ main.setSpacing(12)
+
+ # ================================
+ # HEADER
+ # ================================
+ title = QLabel("🌾 Similar Sensors Search")
+ title.setStyleSheet("""
+ QLabel {
+ font-family: 'Inter';
+ font-size: 26px;
+ font-weight: 800;
+ color: #1a1a1a;
+ margin-bottom: 2px;
+ }
+ """)
+
+ subtitle = QLabel("Find sensors with similar characteristics")
+ subtitle.setStyleSheet("""
+ QLabel {
+ font-family: 'Inter';
+ font-size: 13px;
+ font-weight: 400;
+ color: #6B7280;
+ margin-bottom: 8px;
+ }
+ """)
+
+ header_layout = QVBoxLayout()
+ header_layout.setSpacing(3)
+ header_layout.addWidget(title)
+ header_layout.addWidget(subtitle)
+ main.addLayout(header_layout)
+
+ # ================================
+ # SENSOR SELECTOR
+ # ================================
+ sensor_row = QHBoxLayout()
+ sensor_row.setSpacing(8)
+
+ lbl = QLabel("Sensor:")
+ lbl.setStyleSheet("font-size: 14px; font-weight: 600; color:#374151;")
+
+ self.sensor_dropdown = QComboBox()
+ self.sensor_dropdown.setMinimumWidth(230)
+ self.sensor_dropdown.setStyleSheet("""
+ QComboBox {
+ background: #ffffff;
+ border: 1px solid #d1d5db;
+ border-radius: 8px;
+ padding: 6px 10px;
+ font-size: 13px;
+ font-family: 'Inter';
+ }
+ """)
+
+ self._load_sensor_list()
+
+ sensor_row.addWidget(lbl)
+ sensor_row.addWidget(self.sensor_dropdown)
+ sensor_row.addStretch()
+ main.addLayout(sensor_row)
+
+ # ================================
+ # COMPACT FILTER CARDS (GREEN)
+ # ================================
+ cards_row = QHBoxLayout()
+ cards_row.setSpacing(8)
+
+ self.card_same_status = self._create_filter_card("Same Status", "●")
+ self.card_same_type = self._create_filter_card("Same Type", "●")
+ self.card_same_day = self._create_filter_card("Same Install Day", "●")
+
+ cards_row.addWidget(self.card_same_status)
+ cards_row.addWidget(self.card_same_type)
+ cards_row.addWidget(self.card_same_day)
+ main.addLayout(cards_row)
+
+ # ================================
+ # DATE FILTER (Compact card)
+ # ================================
+ time_box = QFrame()
+ time_box.setStyleSheet("""
+ QFrame {
+ background: #ffffff;
+ border-radius: 10px;
+ border: 1px solid #e5e7eb;
+ padding: 10px;
+ }
+ """)
+
+ time_layout = QVBoxLayout(time_box)
+ time_layout.setSpacing(4)
+
+ time_label = QLabel("Date Filter:")
+ time_label.setStyleSheet("font-size: 14px; font-weight:600; color:#374151;")
+
+ self.date_dropdown = QComboBox()
+ self.date_dropdown.setStyleSheet("""
+ QComboBox {
+ background: white;
+ border: 1px solid #d1d5db;
+ padding: 6px 10px;
+ border-radius: 8px;
+ font-size: 13px;
+ font-family: 'Inter';
+ }
+ """)
+
+ self.date_dropdown.addItem("— None —", None)
+ self.date_dropdown.addItem("Today", "today")
+ self.date_dropdown.addItem("Yesterday", "yesterday")
+
+ weekdays = ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"]
+ for w in weekdays:
+ self.date_dropdown.addItem(w, w.lower())
+ for w in weekdays:
+ self.date_dropdown.addItem("Last " + w, "last_" + w.lower())
+
+ time_layout.addWidget(time_label)
+ time_layout.addWidget(self.date_dropdown)
+
+ main.addWidget(time_box)
+
+ # ================================
+ # SEARCH BUTTON (compact)
+ # ================================
+ self.btn = QPushButton("🔍 Search")
+ self.btn.setFixedHeight(40)
+ self.btn.setStyleSheet("""
+ QPushButton {
+ background: #3B82F6;
+ color: white;
+ font-size: 15px;
+ font-weight: 600;
+ border-radius: 10px;
+ font-family: 'Inter';
+ }
+ QPushButton:hover { background:#2563EB; }
+ """)
+ self.btn.clicked.connect(self._on_search)
+ main.addWidget(self.btn)
+
+ # ================================
+ # RESULTS VIEW — FULL HEIGHT
+ # ================================
+ self.web = QWebEngineView()
+ self.web.setMinimumHeight(350) # pushes table to full size
+ main.addWidget(self.web)
+
+ self._placeholder()
+
+ # Background like dashboard
+ self.setStyleSheet("""
+ QWidget {
+ background: qlineargradient(x1:0,y1:0,x2:0,y2:1,
+ stop:0 #F8FAFC, stop:1 #F1F5F9);
+ }
+ """)
+
+ # ========= FILTER CARD (compact) =========
+ def _create_filter_card(self, title, icon):
+ card = QFrame()
+ card.setProperty("active", False)
+
+ card.setStyleSheet("""
+ QFrame {
+ background: #F0FDF4;
+ border-radius: 12px;
+ border: 1px solid #D1FAE5;
+ }
+ QFrame[active="true"] {
+ border: 2px solid #10B981;
+ }
+ """)
+
+ layout = QHBoxLayout(card)
+ layout.setContentsMargins(12, 8, 12, 8)
+ layout.setSpacing(8)
+
+ icon_label = QLabel(icon)
+ icon_label.setStyleSheet("""
+ QLabel {
+ font-size: 22px;
+ color: #10B981;
+ font-weight: 900;
+ }
+ """)
+
+ text_label = QLabel(title)
+ text_label.setStyleSheet("""
+ QLabel {
+ font-size: 13px;
+ font-weight: 600;
+ color: #374151;
+ font-family:'Inter';
+ }
+ """)
+
+ layout.addWidget(icon_label)
+ layout.addWidget(text_label)
+
+ card.mousePressEvent = lambda e: self._toggle_card(card)
+ return card
+
+ def _toggle_card(self, card):
+ state = not card.property("active")
+ card.setProperty("active", state)
+ card.style().unpolish(card)
+ card.style().polish(card)
+ card.update()
+
+ # ========= PLACEHOLDER =========
+ def _placeholder(self):
+ self.web.setHtml("""
+
+ Select filters and search for similar sensors.
+
+ """)
+
+ # ========= LOAD SENSOR LIST =========
+ def _load_sensor_list(self):
+ try:
+ r = self.api.http.get(f"{self.api.base}/api/tables/sensors")
+ sensors = r.json().get("rows", [])
+ self.sensor_dropdown.addItem("-- Select Sensor --", None)
+
+ for s in sensors:
+ sid = s.get("sensor_id")
+ name = s.get("sensor_name", "")
+ self.sensor_dropdown.addItem(f"{sid} – {name}", sid)
+
+ except Exception as e:
+ print("Failed loading sensors:", e)
+
+ # ========= BUILD PARAMS =========
+ def _build_query_params(self):
+ params = {}
+ if self.card_same_status.property("active"):
+ params["same_status"] = "true"
+ if self.card_same_type.property("active"):
+ params["same_type"] = "true"
+ if self.card_same_day.property("active"):
+ params["same_day"] = "true"
+
+ selected = self.date_dropdown.currentData()
+ if selected:
+ params["date_filter"] = selected
+
+ return params
+
+ # ========= SEARCH =========
+ def _on_search(self):
+ sid = self.sensor_dropdown.currentData()
+ if not sid:
+ self.web.setHtml("Select a sensor.
")
+ return
+
+ params = self._build_query_params()
+
+ if not params:
+ url = f"{self.vector_base}/similar_sensors/{sid}"
+ else:
+ q = "&".join([f"{k}={v}" for k, v in params.items()])
+ url = f"{self.vector_base}/similar_sensors_advanced?sensor_id={sid}&{q}"
+
+ try:
+ data = self.api.http.get(url).json()
+ self._render_results(data)
+ except Exception as e:
+ traceback.print_exc()
+ self.web.setHtml(f"Error: {e}
")
+
+ # ========= RENDER RESULTS (full-size table) =========
+ def _render_results(self, data):
+ sims = data.get("similar_sensors", [])
+
+ if not sims:
+ self.web.setHtml("No results found.
")
+ return
+
+ rows = ""
+ for s in sims:
+ status = s.get("status", "").lower()
+ active = (status == "active")
+ status_display = (
+ "● ONLINE"
+ if active else
+ "● OFFLINE"
+ )
+
+ rows += f"""
+
+ | {s.get('sensor_id','—')} |
+ {s.get('sensor_name','—')} |
+ {s.get('sensor_type','—')} |
+ {s.get('install_date','—')} |
+ {status_display} |
+ {round(s.get('distance',0),3)} |
+
+ """
+
+ html = f"""
+
+
+
+
+
+ | ID |
+ Name |
+ Type |
+ Install Date |
+ Active |
+ Distance |
+
+
+
+ {rows}
+
+
+
+
+ """
+
+ self.web.setHtml(html)
diff --git a/GUI/src/vast/views/irrigation/__init__.py b/GUI/src/vast/views/irrigation/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/GUI/src/vast/views/irrigation/dashboard_bar.py b/GUI/src/vast/views/irrigation/dashboard_bar.py
new file mode 100644
index 000000000..9c27077f4
--- /dev/null
+++ b/GUI/src/vast/views/irrigation/dashboard_bar.py
@@ -0,0 +1,127 @@
+from PyQt6.QtWidgets import QWidget, QHBoxLayout, QLabel, QVBoxLayout, QFrame
+from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QFont
+from datetime import datetime
+
+class DashboardBar(QWidget):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ # Clean minimalistic style: no background, only bottom border
+ self.setStyleSheet("""
+ QWidget {
+ background: transparent;
+ border-bottom: 1px solid #e5e7eb;
+ }
+ QLabel.title {
+ color: #6b7280;
+ font-size: 10pt;
+ font-weight: 500;
+ }
+ QLabel.value {
+ color: #111827;
+ font-size: 18pt;
+ font-weight: 600;
+ }
+ """)
+
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(20, 16, 20, 16)
+ layout.setSpacing(32)
+
+ # Helper: metric block
+ def metric(title_text, value_text, big=True):
+ container = QWidget()
+ v = QVBoxLayout(container)
+ v.setContentsMargins(0, 0, 0, 0)
+ v.setSpacing(2)
+
+ title = QLabel(title_text)
+ title.setProperty("class", "title")
+
+ value = QLabel(value_text)
+ value.setProperty("class", "value" if big else "title")
+
+ v.addWidget(title)
+ v.addWidget(value)
+ return container, value
+
+ # Metric 1
+ block, self.active_value = metric("Active Sprinklers", "0")
+ layout.addWidget(block)
+
+ layout.addWidget(self._separator())
+
+ # Metric 2
+ block, self.area_value = metric("Irrigated Area", "0%")
+ layout.addWidget(block)
+
+ layout.addWidget(self._separator())
+
+ # Metric 3
+ block, self.time_value = metric("Last Update", "--", big=False)
+ layout.addWidget(block)
+
+ layout.addStretch()
+
+ # Status container (clean version)
+ self.status_container = QWidget()
+ s = QHBoxLayout(self.status_container)
+ s.setContentsMargins(0, 0, 0, 0)
+ s.setSpacing(6)
+
+ self.status_indicator = QLabel("●")
+ self.status_indicator.setStyleSheet("color: #dc2626; font-size: 12px;")
+
+ self.status_text = QLabel("Disconnected")
+ self.status_text.setFont(QFont('Segoe UI', 10))
+ self.status_text.setStyleSheet("color: #dc2626; font-weight: 600;")
+
+ s.addWidget(self.status_indicator)
+ s.addWidget(self.status_text)
+ layout.addWidget(self.status_container)
+
+ def _separator(self):
+ sep = QFrame()
+ sep.setFrameShape(QFrame.Shape.VLine)
+ sep.setStyleSheet("color: #e5e7eb;")
+ sep.setFixedWidth(1)
+ return sep
+
+ def update_status(self, active_count, area_percent, last_update, healthy):
+ self.active_value.setText(str(active_count))
+ self.area_value.setText(f"{area_percent:.1f}%")
+
+ if last_update:
+ try:
+ if isinstance(last_update, str):
+ last_dt = datetime.fromisoformat(last_update.replace('Z', '+00:00'))
+ else:
+ last_dt = last_update
+
+ now = datetime.now(last_dt.tzinfo) if last_dt.tzinfo else datetime.now()
+ delta = now - last_dt
+ seconds = int(delta.total_seconds())
+
+ if seconds < 60:
+ t = f"{seconds}s ago"
+ elif seconds < 3600:
+ t = f"{seconds // 60}m ago"
+ else:
+ t = f"{seconds // 3600}h ago"
+
+ self.time_value.setText(t)
+ except:
+ self.time_value.setText("--")
+ else:
+ self.time_value.setText("--")
+
+ # Status indicator styling
+ if healthy:
+ self.status_indicator.setStyleSheet("color: #16a34a; font-size: 12px;")
+ self.status_text.setText("Connected")
+ self.status_text.setStyleSheet("color: #16a34a; font-weight: 600;")
+ else:
+ self.status_indicator.setStyleSheet("color: #dc2626; font-size: 12px;")
+ self.status_text.setText("Disconnected")
+ self.status_text.setStyleSheet("color: #dc2626; font-weight: 600;")
diff --git a/GUI/src/vast/views/irrigation/history_panel.py b/GUI/src/vast/views/irrigation/history_panel.py
new file mode 100644
index 000000000..e5556a911
--- /dev/null
+++ b/GUI/src/vast/views/irrigation/history_panel.py
@@ -0,0 +1,34 @@
+from PyQt6.QtWidgets import QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem, QLabel, QHeaderView
+from PyQt6.QtCore import Qt
+
+class HistoryPanel(QWidget):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setWindowFlags(Qt.WindowType.Widget)
+ self.setFixedWidth(400)
+ layout = QVBoxLayout(self)
+ self.title = QLabel("Sprinkler History")
+ self.title.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ layout.addWidget(self.title)
+ self.table = QTableWidget(0, 6)
+ self.table.setHorizontalHeaderLabels(['Timestamp','Device','Decision','Confidence','Dry Ratio','Image'])
+ # self.table.horizontalHeader().setSectionResizeMode(QTableWidget.ResizeMode.Stretch)
+ self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
+ self.table.setAlternatingRowColors(True)
+ self.table.setStyleSheet('alternate-background-color: #f9fafb; background: white;')
+ self.table.verticalHeader().hide()
+ self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
+ layout.addWidget(self.table)
+
+ def show_history(self, records):
+ self.table.setRowCount(0)
+ for row in records:
+ r = self.table.rowCount()
+ self.table.insertRow(r)
+ self.table.setItem(r,0, QTableWidgetItem(str(row['ts'])))
+ self.table.setItem(r,1, QTableWidgetItem(row['device_id']))
+ dec_item = QTableWidgetItem(row['decision'])
+ self.table.setItem(r,2, dec_item)
+ self.table.setItem(r,3, QTableWidgetItem(f"{row['confidence']:.2f}"))
+ self.table.setItem(r,4, QTableWidgetItem(f"{row['dry_ratio']:.2f}"))
+ self.table.setItem(r,5, QTableWidgetItem("View Image"))
diff --git a/GUI/src/vast/views/irrigation/irrigation_dashboard.py b/GUI/src/vast/views/irrigation/irrigation_dashboard.py
new file mode 100644
index 000000000..7b59b1c9c
--- /dev/null
+++ b/GUI/src/vast/views/irrigation/irrigation_dashboard.py
@@ -0,0 +1,969 @@
+# from pathlib import Path
+# import sys, random
+
+# # Add current directory to path to allow direct execution
+# script_dir = Path(__file__).parent
+# sys.path.insert(0, str(script_dir))
+
+# from PyQt6.QtWidgets import (
+# QApplication, QWidget, QVBoxLayout, QDialog, QFormLayout, QScrollArea,
+# QComboBox, QLineEdit, QHBoxLayout, QPushButton, QTableWidget, QTableWidgetItem,
+# QHeaderView, QProgressBar, QDialogButtonBox, QGraphicsDropShadowEffect, QLabel
+# )
+# from map_widget import MapWidget
+# from history_panel import HistoryPanel
+# # from .sprinkler_details_dialog import SprinklerDetailsDialog
+# from sprinkler_details_dialog_new import SprinklerDetailsDialogNew
+# from dashboard_bar import DashboardBar
+# from PyQt6.QtGui import QPixmap, QMovie, QFont, QColor, QIcon
+# from PyQt6.QtCore import Qt, QTimer, QSize, QPropertyAnimation, QRect, QEasingCurve
+
+# import psycopg2
+# from psycopg2.extras import RealDictCursor
+
+# # ----------------------- DB helpers (unchanged) -----------------------
+# def get_db_connection():
+# return psycopg2.connect(
+# # host="postgres",
+# host = "localhost",
+# port=5432,
+# user="missions_user",
+# password="pg123",
+# dbname="missions_db",
+# cursor_factory=RealDictCursor
+# )
+
+# # NOTE: original fetch functions retained (they must exist and behave the same)
+# # For brevity we keep their original implementations from the provided file.
+
+# def fetch_sprinkler_history(limit=500):
+# query = f"""
+# SELECT e.device_id, e.dry_ratio, e.decision, e.confidence, e.patch_count, e.extra,
+# p.prev_state, e.ts
+# FROM soil_moisture_events e
+# LEFT JOIN irrigation_policies p ON e.device_id = p.device_id
+# ORDER BY e.ts DESC
+# LIMIT {limit}
+# """
+# with get_db_connection() as conn:
+# with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
+# cur.execute(query)
+# rows = cur.fetchall()
+
+# for row in rows:
+# prev_state = row.get('prev_state') or 'stop'
+# decision = row['decision']
+# if decision == "noop":
+# new_state = prev_state
+# elif decision in ("run", "stop"):
+# new_state = decision
+# else:
+# new_state = "stop"
+# row['prev_state'] = prev_state
+# row['new_state'] = new_state
+
+# return rows
+
+
+# def fetch_sprinkler_data():
+# query = """
+# SELECT e.device_id, e.dry_ratio, e.decision, e.confidence, e.patch_count, e.extra,
+# e.ts, p.prev_state
+# FROM soil_moisture_events e
+# LEFT JOIN irrigation_policies p ON e.device_id = p.device_id
+# WHERE e.ts = (
+# SELECT MAX(ts)
+# FROM soil_moisture_events e2
+# WHERE e2.device_id = e.device_id
+# )
+# ORDER BY e.device_id
+# """
+
+# with get_db_connection() as conn:
+# with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
+# cur.execute(query)
+# rows = cur.fetchall()
+
+# for row in rows:
+# prev_state = row.get('prev_state') or 'stop'
+# decision = row['decision']
+# if decision == "noop":
+# new_state = prev_state
+# elif decision in ("run", "stop"):
+# new_state = decision
+# else:
+# new_state = "stop"
+# row['prev_state'] = prev_state
+# row['new_state'] = new_state
+
+# return rows
+
+
+# # ----------------------- UI Components -----------------------
+# class ImageModal(QDialog):
+# """Modal to show image with dark overlay and details"""
+# def __init__(self, parent, pixmap: QPixmap, details: dict):
+# super().__init__(parent)
+# self.setModal(True)
+# self.setWindowFlag(Qt.WindowType.FramelessWindowHint)
+# self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
+
+# self.main = QWidget(self)
+# self.main.setStyleSheet("background: white; border-radius: 12px;")
+# layout = QVBoxLayout(self.main)
+# self.main.setLayout(layout)
+
+# img_label = QLabel()
+# max_w = int(parent.width() * 0.8)
+# max_h = int(parent.height() * 0.8)
+# img_label.setPixmap(pixmap.scaled(max_w, max_h, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
+# img_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+# layout.addWidget(img_label)
+
+# info = QLabel(f"Timestamp: {details.get('ts')} — Decision: {details.get('decision')} — Confidence: {details.get('confidence'):.2f}")
+# layout.addWidget(info)
+
+# btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
+# btns.rejected.connect(self.close)
+# layout.addWidget(btns)
+
+# # position central
+# w, h = int(parent.width()*0.85), int(parent.height()*0.85)
+# self.setGeometry((parent.width()-w)//2, (parent.height()-h)//2, w, h)
+# self.main.setGeometry(0, 0, w, h)
+
+# class FloatingDetailsCard(QWidget):
+# """Floating centered card (replaces drawer)"""
+# def __init__(self, parent, device_id, details):
+# super().__init__(parent)
+# self.device_id = device_id
+# self.details = details
+# self.setWindowFlags(Qt.WindowType.FramelessWindowHint)
+# self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
+
+# self.card = QWidget(self)
+# self.card.setStyleSheet("""
+# background: white;
+# border-radius: 14px;
+# """)
+
+# shadow = QGraphicsDropShadowEffect(self.card)
+# shadow.setBlurRadius(30)
+# shadow.setOffset(0, 8)
+# shadow.setColor(QColor(0,0,0,100))
+# self.card.setGraphicsEffect(shadow)
+
+# # layout
+# l = QVBoxLayout(self.card)
+# l.setContentsMargins(20, 20, 20, 20)
+# # title with gradient-like color
+# title = QLabel(f"Sprinkler {device_id}")
+# title.setFont(QFont('Segoe UI', 14, QFont.Weight.Bold))
+# title.setStyleSheet("color: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #06b6d4, stop:1 #0891b2);")
+# l.addWidget(title)
+
+# # status row
+# status_row = QHBoxLayout()
+# status_dot = QLabel('●')
+# status_dot.setFont(QFont('Arial', 16))
+# status_color = '#10b981' if details.get('active') else '#6b7280'
+# status_dot.setStyleSheet(f"color: {status_color}")
+# status_row.addWidget(status_dot)
+# status_row.addWidget(QLabel('Active' if details.get('active') else 'Inactive'))
+# status_row.addStretch()
+# l.addLayout(status_row)
+
+# # dry ratio progress
+# dry_label = QLabel('💧 Dry Ratio')
+# l.addWidget(dry_label)
+# pb = QProgressBar()
+# pb.setRange(0, 100)
+# pb.setValue(int(details.get('dry_ratio',0)*100))
+# pb.setTextVisible(True)
+# pb.setFormat('%.0f%%' % (pb.value(),))
+# pb.setFixedHeight(18)
+# pb.setStyleSheet("border-radius:9px;")
+# l.addWidget(pb)
+
+# # confidence with color
+# conf = details.get('confidence',0)
+# conf_label = QLabel(f'🎯 Confidence: {conf:.2f}')
+# if conf >= 0.75:
+# conf_color = '#10b981'
+# elif conf >= 0.4:
+# conf_color = '#f59e0b'
+# else:
+# conf_color = '#ef4444'
+# conf_label.setStyleSheet(f'color: {conf_color}; font-weight: bold')
+# l.addWidget(conf_label)
+
+# # decision
+# decision_label = QLabel(f'⚙️ Decision: {details.get("decision")}')
+# l.addWidget(decision_label)
+
+# # button row (bottom)
+# btn_row = QHBoxLayout()
+# btn_row.setSpacing(16)
+# btn_row.addStretch()
+# # Show History button
+# self.show_history_btn = QPushButton('Show History')
+# self.show_history_btn.setStyleSheet('background:#06b6d4; color:white; padding:8px; border-radius:8px')
+# btn_row.addWidget(self.show_history_btn)
+# # Close button
+# self.close_btn = QPushButton('Close')
+# self.close_btn.clicked.connect(self.close_card)
+# self.close_btn.setFixedWidth(120)
+# self.close_btn.setStyleSheet('background:#3b82f6; color:white; padding:8px; border-radius:10px')
+# btn_row.addWidget(self.close_btn)
+# l.addLayout(btn_row)
+
+# # size and position
+# w, h = int(parent.width()*0.6), int(parent.height()*0.5)
+# self.setGeometry((parent.width()-w)//2, (parent.height()-h)//2, w, h)
+# self.card.setGeometry(0,0,w,h)
+
+# # fade-in animation
+# self.opacity_anim = QPropertyAnimation(self, b"windowOpacity")
+# self.opacity_anim.setDuration(220)
+# self.opacity_anim.setStartValue(0.0)
+# self.opacity_anim.setEndValue(1.0)
+# self.setWindowOpacity(0.0)
+# self.opacity_anim.start()
+
+# def close_card(self):
+# self.close()
+
+# class IrrigationDashboard(QWidget):
+# def __init__(self):
+# super().__init__()
+# print("[DEBUG] IrrigationDashboard.__init__ called")
+# self.setWindowTitle("Irrigation Dashboard 🌾")
+# self.setGeometry(100, 100, 1100, 820)
+# QApplication.setFont(QFont('Roboto', 10))
+# main = QVBoxLayout(self)
+# main.setContentsMargins(0,0,0,0)
+# self.setStyleSheet("""
+# QWidget#root { background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #f0f9ff, stop:1 #e0f2fe); }
+# QLabel.title { font-family: 'Segoe UI'; font-weight: bold; font-size: 18px; color: #111827 }
+# """)
+# self.setObjectName('root')
+
+# # MapWidget replaces map logic
+# print("[DEBUG] Creating DashboardBar...")
+# self.dashboard_bar = DashboardBar(self)
+# main.addWidget(self.dashboard_bar)
+# print("[DEBUG] Creating MapWidget...")
+# self.map_widget = MapWidget(self, map_path=str(script_dir / "map_field.jpg"))
+# self.sprinklers = {} # device_id -> info, for non-map logic (details, etc.)
+# # print("[DEBUG] Creating HistoryPanel...")
+# # self.history_panel = HistoryPanel(self)
+# # self.history_panel.hide()
+
+# # Create map layout with minimal margins to push sprinklers down
+# map_container = QWidget()
+# map_layout = QVBoxLayout(map_container)
+# map_layout.setContentsMargins(0, 10, 0, 0) # Minimal top margin (10px instead of default)
+# map_layout.setSpacing(0)
+# map_layout.addWidget(self.map_widget)
+
+# main.addWidget(map_container, stretch=3)
+# # main.addWidget(self.history_panel, stretch=1)
+
+# # timer for data refresh
+# print("[DEBUG] Setting up timer...")
+# self.timer = QTimer()
+# self.timer.timeout.connect(self.safe_update_data)
+# # Delay first update to avoid issues during init
+# self.timer.singleShot(1000, self.safe_update_data)
+# self.timer.start(3000)
+# print("[DEBUG] IrrigationDashboard.__init__ completed")
+
+# def resizeEvent(self, event):
+# super().resizeEvent(event)
+# # Delegate map resizing to MapWidget
+# if hasattr(self, 'map_widget') and self.map_widget:
+# self.map_widget.resizeEvent(event)
+
+# def set_sprinkler_state(self, s, active):
+# btn: QPushButton = s['widget']
+# # create / reuse label for movie
+# if 'label' not in s:
+# lbl = QLabel(btn)
+# lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
+# lbl.setGeometry(0,0,btn.width(),btn.height())
+# lbl.raise_() # Raise label above button to display image
+# s['label'] = lbl
+# lbl = s['label']
+
+# if active:
+# movie = QMovie(str(script_dir / 'sprinkler_on.gif'))
+# movie.setScaledSize(QSize(btn.width(), btn.height()))
+# lbl.setMovie(movie)
+# movie.start()
+# lbl.raise_() # Ensure label stays on top
+# lbl.show()
+# btn.setStyleSheet(self.sprinkler_style(active=True))
+# s['movie'] = movie
+# else:
+# # static image
+# pix = QPixmap(str(script_dir / 'sprinkler_off.png')) if (script_dir / 'sprinkler_off.png').exists() else QPixmap(btn.width(), btn.height())
+# if pix.isNull():
+# pix = QPixmap(btn.width(), btn.height()); pix.fill(QColor('#6b7280'))
+# lbl.setPixmap(pix.scaled(btn.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
+# lbl.raise_() # Ensure label stays on top
+# lbl.show()
+# btn.setStyleSheet(self.sprinkler_style(active=False))
+# s['movie'] = None
+# s['active'] = active
+
+# def sprinkler_style(self, active=False):
+# # circular button with colored background (not transparent)
+# base = f"""
+# QPushButton {{
+# width: 56px; height:56px; border-radius:28px;
+
+# font-weight: bold;
+# color: white;
+# }}
+# QPushButton:hover {{ }}
+# """
+# return base
+
+# def update_history(self):
+# # Populate filters (preserve current selection) and refresh table
+# rows = fetch_sprinkler_history()
+# prev_dev = self.filter_device.currentText() if self.filter_device.count()>0 else 'All'
+# prev_dec = self.filter_decision.currentText() if self.filter_decision.count()>0 else 'All'
+
+# unique_devices = sorted({row['device_id'] for row in rows})
+# unique_decisions = sorted({row['decision'] for row in rows})
+
+# # repopulate while preserving selection when possible
+# self.filter_device.blockSignals(True)
+# self.filter_decision.blockSignals(True)
+# self.filter_device.clear(); self.filter_device.addItem('All'); self.filter_device.addItems(unique_devices)
+# self.filter_decision.clear(); self.filter_decision.addItem('All'); self.filter_decision.addItems(unique_decisions)
+# idx = self.filter_device.findText(prev_dev)
+# if idx >= 0:
+# self.filter_device.setCurrentIndex(idx)
+# idx = self.filter_decision.findText(prev_dec)
+# if idx >= 0:
+# self.filter_decision.setCurrentIndex(idx)
+# self.filter_device.blockSignals(False)
+# self.filter_decision.blockSignals(False)
+
+# # apply currently selected filters to populate the table
+# self.apply_filters()
+
+# def apply_filters(self):
+# # fetch and filter rows locally
+# rows = fetch_sprinkler_history()
+# dev = self.filter_device.currentText()
+# dec = self.filter_decision.currentText()
+# if dev and dev != 'All':
+# rows = [r for r in rows if r.get('device_id') == dev]
+# if dec and dec != 'All':
+# rows = [r for r in rows if r.get('decision') == dec]
+
+# # date filter
+# try:
+# dfrom = self.filter_date_from.date().toPyDate()
+# dto = self.filter_date_to.date().toPyDate()
+# # only apply if user set meaningful range (non-default)
+# if dfrom and dto and dfrom <= dto:
+# rows = [r for r in rows if r.get('ts') and dfrom <= r['ts'].date() <= dto]
+# except Exception:
+# pass
+
+# # numeric filters
+# try:
+# drymin = float(self.filter_dry_min.text()) if self.filter_dry_min.text() else None
+# confmin = float(self.filter_conf_min.text()) if self.filter_conf_min.text() else None
+# if drymin is not None:
+# rows = [r for r in rows if r.get('dry_ratio', 0) >= drymin]
+# if confmin is not None:
+# rows = [r for r in rows if r.get('confidence', 0) >= confmin]
+# except Exception:
+# pass
+
+# self.populate_history_table(rows)
+
+# def populate_history_table(self, rows):
+# self.history_table.setRowCount(0)
+# for row in rows:
+# r = self.history_table.rowCount()
+# self.history_table.insertRow(r)
+# self.history_table.setItem(r,0, QTableWidgetItem(str(row['ts'])))
+# self.history_table.setItem(r,1, QTableWidgetItem(row['device_id']))
+# dec_item = QTableWidgetItem(row['decision'])
+# if row['decision']=='run': dec_item.setBackground(QColor('#10b981'))
+# elif row['decision']=='stop': dec_item.setBackground(QColor('#ef4444'))
+# else: dec_item.setBackground(QColor('#f59e0b'))
+# self.history_table.setItem(r,2, dec_item)
+# self.history_table.setItem(r,3, QTableWidgetItem(f"{row['confidence']:.2f}"))
+# self.history_table.setItem(r,4, QTableWidgetItem(f"{row['dry_ratio']:.2f}"))
+# view_btn = QPushButton('View Image')
+# view_btn.clicked.connect(lambda _checked, rr=row: self.show_image_modal(rr))
+# self.history_table.setCellWidget(r,5, view_btn)
+
+# def show_image_modal(self, row):
+# # for demo: try to load an image from disk or show placeholder
+# img_path = script_dir / 'sprinkler_image_placeholder.jpg'
+# if img_path.exists():
+# pix = QPixmap(str(img_path))
+# else:
+# pix = QPixmap(400,300)
+# pix.fill(QColor('#ddd'))
+# m = ImageModal(self, pix, row)
+# m.exec()
+
+# def update_data(self):
+# try:
+# rows = fetch_sprinkler_data()
+# healthy = True
+# except Exception as e:
+# print('Error fetching data:', e)
+# import traceback
+# traceback.print_exc()
+# rows = []
+# healthy = False
+
+# # Update sprinklers on map
+# active_count = 0
+# irrigated_area = 0.0
+# last_update = '--'
+# max_timestamp = None
+# for i, row in enumerate(rows):
+# device_id = row['device_id']
+# dry_ratio = row['dry_ratio']
+# decision = row['decision']
+# confidence = row['confidence']
+# new_state = row['new_state']
+# active = new_state == 'run'
+# if active:
+# active_count += 1
+# irrigated_area += dry_ratio if active else 0
+
+# # Track the most recent timestamp across all sprinklers
+# ts = row.get('ts')
+# if ts and (max_timestamp is None or ts > max_timestamp):
+# max_timestamp = ts
+
+# # MapWidget handles display and GIF/image ONLY (no shapes)
+# if device_id not in self.map_widget.sprinklers:
+# rel_x = (100 + i*120 + random.randint(-20, 20)) / max(1, self.map_widget.map_pixmap.width())
+# rel_y = (300 + random.randint(-100, 100)) / max(1, self.map_widget.map_pixmap.height())
+# rel_size = 0.025
+# btn = self.map_widget.add_sprinkler(device_id, rel_x, rel_y, rel_size, active, name=device_id)
+# try:
+# btn.clicked.connect(lambda _e, d=device_id: self.show_details(d))
+# except Exception as e:
+# print(f"[ERROR] Failed to connect clicked signal for {device_id}: {e}")
+
+# s = self.map_widget.sprinklers[device_id]
+# # ALWAYS set sprinkler state on every update - ensures images/GIFs shown for new sprinklers added after startup
+# self.set_sprinkler_state(s, active)
+# s.update({'dry_ratio': dry_ratio, 'decision': decision, 'confidence': confidence})
+# # Update main window's sprinkler info for details dialog
+# self.sprinklers[device_id] = s
+
+# # Set last_update to the most recent timestamp
+# if max_timestamp:
+# last_update = str(max_timestamp)
+
+# total_area = len(rows)
+# irrigated_percent = (irrigated_area / total_area * 100) if total_area else 0.0
+# self.dashboard_bar.update_status(active_count, irrigated_percent, last_update, healthy)
+
+# def safe_update_data(self):
+# """Safe wrapper for update_data that catches exceptions"""
+# try:
+# self.update_data()
+# except Exception as e:
+# print(f"[ERROR] safe_update_data failed: {e}")
+# import traceback
+# traceback.print_exc()
+
+# def show_details(self, device_id):
+# print(f"[DEBUG] show_details() called for device_id={device_id}")
+# if device_id not in self.sprinklers:
+# print(f"[DEBUG] Device {device_id} not found in sprinklers dict")
+# return
+# # Close previous details dialog if open
+# if hasattr(self, '_details_card') and self._details_card:
+# print(f"[DEBUG] Closing previous details card")
+# self._details_card.close()
+# s = self.sprinklers[device_id]
+# details = {
+# 'active': s.get('active', False),
+# 'dry_ratio': s.get('dry_ratio', 0),
+# 'confidence': s.get('confidence', 0),
+# 'decision': s.get('decision', 'noop')
+# }
+# print(f"[DEBUG] Creating new SprinklerDetailsDialogNew with details: {details}")
+# # Use new tabbed dialog
+# card = SprinklerDetailsDialogNew(self, device_id, details)
+# card.history_requested.connect(lambda d: self.show_history_panel(d))
+# self._details_card = card
+# print(f"[DEBUG] Calling card.show()")
+# card.show()
+# print(f"[DEBUG] Dialog shown")
+
+# def show_history_panel(self, device_id):
+# # Close previous details dialog if open
+# if hasattr(self, '_details_card') and self._details_card:
+# self._details_card.close()
+# # Fetch last 10 records for this sprinkler
+# all_history = fetch_sprinkler_history(limit=100)
+# records = [r for r in all_history if r['device_id'] == device_id][:10]
+# self.history_panel.show_history(records)
+# self.history_panel.show()
+# self.map_widget.setFixedWidth(int(self.width() * 0.6))
+# # Add close button to history panel
+# if not hasattr(self.history_panel, 'close_btn'):
+# from PyQt6.QtWidgets import QPushButton
+# close_btn = QPushButton('Close History')
+# close_btn.setStyleSheet('background:#ef4444; color:white; padding:6px; border-radius:8px')
+# close_btn.clicked.connect(self.hide_history_panel)
+# layout = self.history_panel.layout()
+# layout.addWidget(close_btn)
+# self.history_panel.close_btn = close_btn
+
+# def hide_history_panel(self):
+# self.history_panel.hide()
+# self.map_widget.setFixedWidth(self.width())
+# # Ensure details dialog can be reopened after closing history
+# self._details_card = None
+
+# def on_history_clicked(self, row, col):
+# # show full details card when any non-button cell is clicked
+# try:
+# device = self.history_table.item(row,1).text()
+# if device:
+# self.show_details(device)
+# except Exception:
+# pass
+
+# if __name__ == '__main__':
+# app = QApplication(sys.argv)
+# window = IrrigationDashboard()
+# window.show()
+# sys.exit(app.exec())
+
+
+from pathlib import Path
+import sys, random
+from PyQt6.QtWidgets import (
+ QApplication, QWidget, QVBoxLayout, QDialog, QFormLayout, QScrollArea,
+ QComboBox, QLineEdit, QHBoxLayout, QPushButton, QTableWidget, QTableWidgetItem,
+ QHeaderView, QProgressBar, QDialogButtonBox, QGraphicsDropShadowEffect, QLabel
+)
+from .map_widget import MapWidget
+from .history_panel import HistoryPanel
+from .sprinkler_details_dialog_new import SprinklerDetailsDialogNew
+from .dashboard_bar import DashboardBar
+from PyQt6.QtGui import QPixmap, QMovie, QFont, QColor, QIcon
+from PyQt6.QtCore import Qt, QTimer, QSize, QPropertyAnimation, QRect, QEasingCurve
+
+import psycopg2
+from psycopg2.extras import RealDictCursor
+
+# ----------------------- DB helpers (unchanged) -----------------------
+def get_db_connection():
+ return psycopg2.connect(
+ host="postgres",
+ port=5432,
+ user="missions_user",
+ password="pg123",
+ dbname="missions_db",
+ cursor_factory=RealDictCursor
+ )
+
+# NOTE: original fetch functions retained (they must exist and behave the same)
+# For brevity we keep their original implementations from the provided file.
+
+def fetch_sprinkler_history(limit=500):
+ query = f"""
+ SELECT e.device_id, e.dry_ratio, e.decision, e.confidence, e.patch_count, e.extra,
+ p.prev_state, e.ts
+ FROM soil_moisture_events e
+ LEFT JOIN irrigation_policies p ON e.device_id = p.device_id
+ ORDER BY e.ts DESC
+ LIMIT {limit}
+ """
+ with get_db_connection() as conn:
+ with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
+ cur.execute(query)
+ rows = cur.fetchall()
+
+ for row in rows:
+ prev_state = row.get('prev_state') or 'stop'
+ decision = row['decision']
+ if decision == "noop":
+ new_state = prev_state
+ elif decision in ("run", "stop"):
+ new_state = decision
+ else:
+ new_state = "stop"
+ row['prev_state'] = prev_state
+ row['new_state'] = new_state
+
+ return rows
+
+
+def fetch_sprinkler_data():
+ query = """
+ SELECT e.device_id, e.dry_ratio, e.decision, e.confidence, e.patch_count, e.extra,
+ e.ts, p.prev_state
+ FROM soil_moisture_events e
+ LEFT JOIN irrigation_policies p ON e.device_id = p.device_id
+ WHERE e.ts = (
+ SELECT MAX(ts)
+ FROM soil_moisture_events e2
+ WHERE e2.device_id = e.device_id
+ )
+ ORDER BY e.device_id
+ """
+
+ with get_db_connection() as conn:
+ with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
+ cur.execute(query)
+ rows = cur.fetchall()
+
+ for row in rows:
+ prev_state = row.get('prev_state') or 'stop'
+ decision = row['decision']
+ if decision == "noop":
+ new_state = prev_state
+ elif decision in ("run", "stop"):
+ new_state = decision
+ else:
+ new_state = "stop"
+ row['prev_state'] = prev_state
+ row['new_state'] = new_state
+
+ return rows
+
+
+script_dir = Path(__file__).parent
+
+# # ----------------------- UI Components -----------------------
+# class ImageModal(QDialog):
+# """Modal to show image with dark overlay and details"""
+# def __init__(self, parent, pixmap: QPixmap, details: dict):
+# super().__init__(parent)
+# self.setModal(True)
+# self.setWindowFlag(Qt.WindowType.FramelessWindowHint)
+# self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
+
+# self.main = QWidget(self)
+# self.main.setStyleSheet("background: white; border-radius: 12px;")
+# layout = QVBoxLayout(self.main)
+# self.main.setLayout(layout)
+
+# img_label = QLabel()
+# max_w = int(parent.width() * 0.8)
+# max_h = int(parent.height() * 0.8)
+# img_label.setPixmap(pixmap.scaled(max_w, max_h, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
+# img_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+# layout.addWidget(img_label)
+
+# info = QLabel(f"Timestamp: {details.get('ts')} — Decision: {details.get('decision')} — Confidence: {details.get('confidence'):.2f}")
+# layout.addWidget(info)
+
+# btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
+# btns.rejected.connect(self.close)
+# layout.addWidget(btns)
+
+# # position central
+# w, h = int(parent.width()*0.85), int(parent.height()*0.85)
+# self.setGeometry((parent.width()-w)//2, (parent.height()-h)//2, w, h)
+# self.main.setGeometry(0, 0, w, h)
+
+class IrrigationDashboard(QWidget):
+ def __init__(self):
+ super().__init__()
+ self.setWindowTitle("Irrigation Dashboard 🌾")
+ self.setGeometry(100, 100, 1100, 820)
+ QApplication.setFont(QFont('Roboto', 10))
+ main = QVBoxLayout(self)
+ main.setContentsMargins(0,0,0,0)
+ self.setStyleSheet("""
+ QWidget#root { background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #f0f9ff, stop:1 #e0f2fe); }
+ QLabel.title { font-family: 'Segoe UI'; font-weight: bold; font-size: 18px; color: #111827 }
+ """)
+ self.setObjectName('root')
+
+ # MapWidget replaces map logic
+ self.dashboard_bar = DashboardBar(self)
+ main.addWidget(self.dashboard_bar)
+ self.map_widget = MapWidget(self, map_path=str(script_dir / "map_field.jpg"))
+ self.sprinklers = {} # device_id -> info, for non-map logic (details, etc.)
+ self.history_panel = HistoryPanel(self)
+ self.history_panel.hide()
+
+ # Create map layout with minimal margins to push sprinklers down
+ map_container = QWidget()
+ map_layout = QVBoxLayout(map_container)
+ map_layout.setContentsMargins(0, 10, 0, 0) # Minimal top margin (10px instead of default)
+ map_layout.setSpacing(0)
+ map_layout.addWidget(self.map_widget)
+
+ main.addWidget(map_container, stretch=3)
+ main.addWidget(self.history_panel, stretch=1)
+
+ # timer for data refresh
+ self.timer = QTimer()
+ self.timer.timeout.connect(self.update_data)
+ self.timer.start(3000)
+ self.update_data()
+
+ def resizeEvent(self, event):
+ super().resizeEvent(event)
+ # Delegate map resizing to MapWidget
+ if hasattr(self, 'map_widget') and self.map_widget:
+ self.map_widget.resizeEvent(event)
+
+ def set_sprinkler_state(self, s, active):
+ #print(f"Setting sprinkler {s['name']} state to {'active' if active else 'inactive'}")
+ btn: QPushButton = s['widget']
+ # create / reuse label for movie
+ if 'label' not in s:
+ lbl = QLabel(btn)
+ lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ lbl.setGeometry(0,0,btn.width(),btn.height())
+ s['label'] = lbl
+ lbl = s['label']
+
+ if active:
+ movie = QMovie(str(script_dir / 'sprinkler_on.gif'))
+ movie.setScaledSize(QSize(btn.width(), btn.height()))
+ lbl.setMovie(movie)
+ movie.start()
+ lbl.show()
+ btn.setStyleSheet(self.sprinkler_style(active=True))
+ s['movie'] = movie
+ else:
+ # static image
+ pix = QPixmap(str(script_dir / 'sprinkler_off.png')) if (script_dir / 'sprinkler_off.png').exists() else QPixmap(btn.width(), btn.height())
+ if pix.isNull():
+ pix = QPixmap(btn.width(), btn.height()); pix.fill(QColor('#6b7280'))
+ lbl.setPixmap(pix.scaled(btn.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
+ lbl.show()
+ btn.setStyleSheet(self.sprinkler_style(active=False))
+ s['movie'] = None
+ s['active'] = active
+
+ def sprinkler_style(self, active=False):
+ #background: {"#86c7db26" if active else "#6b72801c"};
+ # circular button with shadow and badge placeholder
+ base = f"""
+ QPushButton {{
+ width: 120px; height:120px; border-radius:28px;
+
+ font-weight: bold;
+ color: white;
+ }}
+ QPushButton:hover {{ }}
+ """
+ return base
+
+ # def update_history(self):
+ # # Populate filters (preserve current selection) and refresh table
+ # rows = fetch_sprinkler_history()
+ # prev_dev = self.filter_device.currentText() if self.filter_device.count()>0 else 'All'
+ # prev_dec = self.filter_decision.currentText() if self.filter_decision.count()>0 else 'All'
+
+ # unique_devices = sorted({row['device_id'] for row in rows})
+ # unique_decisions = sorted({row['decision'] for row in rows})
+
+ # # repopulate while preserving selection when possible
+ # self.filter_device.blockSignals(True)
+ # self.filter_decision.blockSignals(True)
+ # self.filter_device.clear(); self.filter_device.addItem('All'); self.filter_device.addItems(unique_devices)
+ # self.filter_decision.clear(); self.filter_decision.addItem('All'); self.filter_decision.addItems(unique_decisions)
+ # idx = self.filter_device.findText(prev_dev)
+ # if idx >= 0:
+ # self.filter_device.setCurrentIndex(idx)
+ # idx = self.filter_decision.findText(prev_dec)
+ # if idx >= 0:
+ # self.filter_decision.setCurrentIndex(idx)
+ # self.filter_device.blockSignals(False)
+ # self.filter_decision.blockSignals(False)
+
+ # # apply currently selected filters to populate the table
+ # self.apply_filters()
+
+ # def apply_filters(self):
+ # # fetch and filter rows locally
+ # rows = fetch_sprinkler_history()
+ # dev = self.filter_device.currentText()
+ # dec = self.filter_decision.currentText()
+ # if dev and dev != 'All':
+ # rows = [r for r in rows if r.get('device_id') == dev]
+ # if dec and dec != 'All':
+ # rows = [r for r in rows if r.get('decision') == dec]
+
+ # # date filter
+ # try:
+ # dfrom = self.filter_date_from.date().toPyDate()
+ # dto = self.filter_date_to.date().toPyDate()
+ # # only apply if user set meaningful range (non-default)
+ # if dfrom and dto and dfrom <= dto:
+ # rows = [r for r in rows if r.get('ts') and dfrom <= r['ts'].date() <= dto]
+ # except Exception:
+ # pass
+
+ # # numeric filters
+ # try:
+ # drymin = float(self.filter_dry_min.text()) if self.filter_dry_min.text() else None
+ # confmin = float(self.filter_conf_min.text()) if self.filter_conf_min.text() else None
+ # if drymin is not None:
+ # rows = [r for r in rows if r.get('dry_ratio', 0) >= drymin]
+ # if confmin is not None:
+ # rows = [r for r in rows if r.get('confidence', 0) >= confmin]
+ # except Exception:
+ # pass
+
+ # self.populate_history_table(rows)
+
+ # def populate_history_table(self, rows):
+ # self.history_table.setRowCount(0)
+ # for row in rows:
+ # r = self.history_table.rowCount()
+ # self.history_table.insertRow(r)
+ # self.history_table.setItem(r,0, QTableWidgetItem(str(row['ts'])))
+ # self.history_table.setItem(r,1, QTableWidgetItem(row['device_id']))
+ # dec_item = QTableWidgetItem(row['decision'])
+ # if row['decision']=='run': dec_item.setBackground(QColor('#10b981'))
+ # elif row['decision']=='stop': dec_item.setBackground(QColor('#ef4444'))
+ # else: dec_item.setBackground(QColor('#f59e0b'))
+ # self.history_table.setItem(r,2, dec_item)
+ # self.history_table.setItem(r,3, QTableWidgetItem(f"{row['confidence']:.2f}"))
+ # self.history_table.setItem(r,4, QTableWidgetItem(f"{row['dry_ratio']:.2f}"))
+ # view_btn = QPushButton('View Image')
+ # view_btn.clicked.connect(lambda _checked, rr=row: self.show_image_modal(rr))
+ # self.history_table.setCellWidget(r,5, view_btn)
+
+ # def show_image_modal(self, row):
+ # # for demo: try to load an image from disk or show placeholder
+ # img_path = script_dir / 'sprinkler_image_placeholder.jpg'
+ # if img_path.exists():
+ # pix = QPixmap(str(img_path))
+ # else:
+ # pix = QPixmap(400,300)
+ # pix.fill(QColor('#ddd'))
+ # m = ImageModal(self, pix, row)
+ # m.exec()
+
+ def update_data(self):
+ try:
+ rows = fetch_sprinkler_data()
+ healthy = True
+ except Exception as e:
+ print('Error fetching data:', e)
+ rows = []
+ healthy = False
+
+ # Update sprinklers on map
+ active_count = 0
+ irrigated_area = 0.0
+ last_update = '--'
+ max_timestamp = None
+ for i, row in enumerate(rows):
+ device_id = row['device_id']
+ dry_ratio = row['dry_ratio']
+ decision = row['decision']
+ confidence = row['confidence']
+ new_state = row['new_state']
+ active = new_state == 'run'
+ if active:
+ active_count += 1
+ irrigated_area += dry_ratio if active else 0
+
+ # Track the most recent timestamp across all sprinklers
+ ts = row.get('ts')
+ if ts and (max_timestamp is None or ts > max_timestamp):
+ max_timestamp = ts
+
+ # MapWidget handles display and GIF/image ONLY (no shapes)
+ if device_id not in self.map_widget.sprinklers:
+ rel_x = (100 + i*233 + random.randint(-20, 20)) / max(1, self.map_widget.map_pixmap.width())
+ rel_y = (550 + random.randint(-230, 230)) / max(1, self.map_widget.map_pixmap.height())
+ rel_size = 0.025
+ btn = self.map_widget.add_sprinkler(device_id, rel_x, rel_y, rel_size, active, name=device_id)
+ btn.clicked.connect(lambda _e, d=device_id: self.show_details(d))
+ s = self.map_widget.sprinklers[device_id]
+ # Always set sprinkler state to ensure image/GIF is shown, never fallback to shapes
+ self.set_sprinkler_state(s, active)
+ s.update({'dry_ratio': dry_ratio, 'decision': decision, 'confidence': confidence})
+ # Update main window's sprinkler info for details dialog
+ self.sprinklers[device_id] = s
+
+ # Set last_update to the most recent timestamp
+ if max_timestamp:
+ last_update = str(max_timestamp)
+
+ total_area = len(rows)
+ irrigated_percent = (irrigated_area / total_area * 100) if total_area else 0.0
+ self.dashboard_bar.update_status(active_count, irrigated_percent, last_update, healthy)
+
+ def show_details(self, device_id):
+ if device_id not in self.sprinklers:
+ return
+ # Close previous details dialog if open
+ if hasattr(self, '_details_card') and self._details_card:
+ self._details_card.close()
+ s = self.sprinklers[device_id]
+ details = {
+ 'active': s.get('active', False),
+ 'dry_ratio': s.get('dry_ratio', 0),
+ 'confidence': s.get('confidence', 0),
+ 'decision': s.get('decision', 'noop')
+ }
+ # Use new tabbed dialog
+ card = SprinklerDetailsDialogNew(self, device_id, details)
+ card.history_requested.connect(lambda d: self.show_history_panel(d))
+ self._details_card = card
+ card.show()
+
+ # def show_history_panel(self, device_id):
+ # # Close previous details dialog if open
+ # if hasattr(self, '_details_card') and self._details_card:
+ # self._details_card.close()
+ # # Fetch last 10 records for this sprinkler
+ # all_history = fetch_sprinkler_history(limit=100)
+ # records = [r for r in all_history if r['device_id'] == device_id][:10]
+ # self.history_panel.show_history(records)
+ # self.history_panel.show()
+ # self.map_widget.setFixedWidth(int(self.width() * 0.6))
+ # # Add close button to history panel
+ # if not hasattr(self.history_panel, 'close_btn'):
+ # from PyQt6.QtWidgets import QPushButton
+ # close_btn = QPushButton('Close History')
+ # close_btn.setStyleSheet('background:#ef4444; color:white; padding:6px; border-radius:8px')
+ # close_btn.clicked.connect(self.hide_history_panel)
+ # layout = self.history_panel.layout()
+ # layout.addWidget(close_btn)
+ # self.history_panel.close_btn = close_btn
+
+ # def hide_history_panel(self):
+ # self.history_panel.hide()
+ # self.map_widget.setFixedWidth(self.width())
+ # # Ensure details dialog can be reopened after closing history
+ # self._details_card = None
+
+ # def on_history_clicked(self, row, col):
+ # # show full details card when any non-button cell is clicked
+ # try:
+ # device = self.history_table.item(row,1).text()
+ # if device:
+ # self.show_details(device)
+ # except Exception:
+ # pass
+
+if __name__ == '__main__':
+ app = QApplication(sys.argv)
+ window = IrrigationDashboard()
+ window.show()
+ sys.exit(app.exec())
diff --git a/GUI/src/vast/views/irrigation/irrigation_view.py b/GUI/src/vast/views/irrigation/irrigation_view.py
new file mode 100644
index 000000000..5eefcf05d
--- /dev/null
+++ b/GUI/src/vast/views/irrigation/irrigation_view.py
@@ -0,0 +1,42 @@
+from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel
+from PyQt6.QtCore import Qt
+from .irrigation_dashboard import IrrigationDashboard
+from dashboard_api import DashboardApi
+
+
+class IrrigationView(QWidget):
+ """Wrapper to integrate IrrigationDashboard into MainWindow"""
+
+ def __init__(self, api: DashboardApi, parent=None):
+ super().__init__(parent)
+ print("[DEBUG] IrrigationView.__init__ called")
+ self.api = api
+
+ print("[DEBUG] Creating layout...")
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ # Create the irrigation dashboard
+ print("[DEBUG] Creating IrrigationDashboard...")
+ try:
+ self.dashboard = IrrigationDashboard()
+ print("[DEBUG] IrrigationDashboard created successfully")
+ print("[DEBUG] Adding dashboard to layout...")
+ layout.addWidget(self.dashboard)
+ except Exception as e:
+ print(f"[ERROR] Failed to create IrrigationDashboard: {e}")
+ import traceback
+ traceback.print_exc()
+ # Create error placeholder
+ error_label = QLabel(f"Failed to load Irrigation Dashboard:\n{str(e)}")
+ error_label.setStyleSheet("color: red; padding: 20px;")
+ layout.addWidget(error_label)
+
+ print("[DEBUG] Setting stylesheet...")
+ self.setStyleSheet("""
+ QWidget {
+ background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #f0f9ff, stop:1 #e0f2fe);
+ }
+ """)
+ print("[DEBUG] IrrigationView.__init__ completed")
diff --git a/GUI/src/vast/views/irrigation/map_field.jpg b/GUI/src/vast/views/irrigation/map_field.jpg
new file mode 100644
index 000000000..5c528b122
Binary files /dev/null and b/GUI/src/vast/views/irrigation/map_field.jpg differ
diff --git a/GUI/src/vast/views/irrigation/map_widget.py b/GUI/src/vast/views/irrigation/map_widget.py
new file mode 100644
index 000000000..a93cb2114
--- /dev/null
+++ b/GUI/src/vast/views/irrigation/map_widget.py
@@ -0,0 +1,104 @@
+from pathlib import Path
+from PyQt6.QtWidgets import QWidget, QPushButton, QLabel
+from PyQt6.QtGui import QPixmap, QMovie, QColor
+from PyQt6.QtCore import Qt, QSize
+
+script_dir = Path(__file__).parent
+
+class MapWidget(QWidget):
+ def __init__(self, parent=None, map_path=None):
+ super().__init__(parent)
+ self.sprinklers = {}
+ self.map_pixmap = QPixmap(map_path) if map_path else QPixmap()
+ self.map_label = QLabel(self)
+ self.map_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.map_label.setStyleSheet('background: transparent;')
+ self.map_label.setPixmap(self.map_pixmap)
+ self.map_aspect = self.map_pixmap.width() / self.map_pixmap.height() if not self.map_pixmap.isNull() else 16/9
+ self.last_scaled_pixmap = self.map_pixmap
+ self.resizeEvent(None)
+
+ def resizeEvent(self, event):
+ if self.map_pixmap.isNull():
+ return
+ available_w = max(100, self.width() - 40)
+ scaled_h = int(available_w / self.map_aspect)
+ scaled_pixmap = self.map_pixmap.scaled(available_w, scaled_h, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
+ self.last_scaled_pixmap = scaled_pixmap
+ self.map_label.setPixmap(scaled_pixmap)
+ self.map_label.setFixedHeight(scaled_pixmap.height())
+ self.map_label.setGeometry(0, 0, available_w, scaled_h)
+ for s in self.sprinklers.values():
+ # Increased size: multiplied rel_size by 1.5 and increased minimum from 40 to 60
+ new_size = max(int(s['rel_size'] * scaled_pixmap.width() * 1.5), 60)
+ new_x = int(s['rel_x'] * scaled_pixmap.width() - new_size/2)
+ new_y = int(s['rel_y'] * scaled_pixmap.height() - new_size/2)
+ s['widget'].setGeometry(new_x, new_y, new_size, new_size)
+ if s.get('movie') and isinstance(s['movie'], QMovie):
+ s['movie'].setScaledSize(QSize(new_size, new_size))
+
+ def add_sprinkler(self, device_id, rel_x, rel_y, rel_size=0.025, active=False, name=None):
+ btn = QPushButton('', self)
+ btn.setObjectName(f'spr_{device_id}')
+ btn.setToolTip(name or device_id)
+ btn.setFixedSize(120, 120)
+ btn.setStyleSheet(self.sprinkler_style(active))
+ btn.installEventFilter(self)
+ self.sprinklers[device_id] = {
+ 'widget': btn, 'movie': None, 'active': active,
+ 'rel_x': rel_x, 'rel_y': rel_y, 'rel_size': rel_size,
+ 'name': name or device_id
+ }
+ self.set_sprinkler_state(self.sprinklers[device_id], active)
+ self.resizeEvent(None)
+ return btn
+
+ def eventFilter(self, obj, event):
+ # Show tooltip on hover
+ if event.type() == event.Type.Enter:
+ for s in self.sprinklers.values():
+ if s['widget'] is obj:
+ obj.setToolTip(s.get('name', ''))
+ return super().eventFilter(obj, event)
+
+ def set_sprinkler_state(self, s, active):
+ btn = s['widget']
+ if 'label' not in s:
+ lbl = QLabel(btn)
+ lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ lbl.setGeometry(0,0,btn.width(),btn.height())
+ s['label'] = lbl
+ lbl = s['label']
+ if active:
+ movie = QMovie(str(script_dir / 'sprinkler_on.gif'))
+ movie.setScaledSize(QSize(btn.width(), btn.height()))
+ lbl.setMovie(movie)
+ movie.start()
+ lbl.show()
+ btn.setStyleSheet(self.sprinkler_style(True))
+ s['movie'] = movie
+ else:
+ pix = QPixmap(str(script_dir / 'sprinkler_off.png'))
+ if pix.isNull():
+ pix = QPixmap(btn.width(), btn.height()); pix.fill(QColor('#6b7280'))
+ lbl.setPixmap(pix.scaled(btn.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
+ lbl.show()
+ btn.setStyleSheet(self.sprinkler_style(False))
+ s['movie'] = None
+ s['active'] = active
+
+ def sprinkler_style(self, active=False):
+ base = f"""
+ QPushButton {{
+ width: 120px; height: 120px; border-radius: 60px; background: {'#10b981' if active else '#6b7280'};
+ font-weight: bold;
+ color: white;
+ }}
+ QPushButton:hover {{}}
+ """
+ return base
+
+ def show_tooltip(self, device_id):
+ s = self.sprinklers.get(device_id)
+ if s:
+ s['widget'].setToolTip(s.get('name', device_id))
diff --git a/GUI/src/vast/views/irrigation/sprinkler_details_dialog.py b/GUI/src/vast/views/irrigation/sprinkler_details_dialog.py
new file mode 100644
index 000000000..837b648b4
--- /dev/null
+++ b/GUI/src/vast/views/irrigation/sprinkler_details_dialog.py
@@ -0,0 +1,21 @@
+# from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton
+# from PyQt6.QtCore import Qt
+
+# class SprinklerDetailsDialog(QDialog):
+# def __init__(self, parent, device_id, details):
+# super().__init__(parent)
+# self.setWindowTitle(f"Sprinkler {device_id} Details")
+# self.setModal(True)
+# layout = QVBoxLayout(self)
+# self.label = QLabel(f"Details for {device_id}")
+# layout.addWidget(self.label)
+# self.details_label = QLabel(str(details))
+# layout.addWidget(self.details_label)
+# self.show_history_btn = QPushButton("Show History")
+# layout.addWidget(self.show_history_btn)
+# self.show_history_btn.clicked.connect(self.on_show_history)
+# self.history_callback = None
+
+# def on_show_history(self):
+# if self.history_callback:
+# self.history_callback()
diff --git a/GUI/src/vast/views/irrigation/sprinkler_details_dialog_new.py b/GUI/src/vast/views/irrigation/sprinkler_details_dialog_new.py
new file mode 100644
index 000000000..4b43dc205
--- /dev/null
+++ b/GUI/src/vast/views/irrigation/sprinkler_details_dialog_new.py
@@ -0,0 +1,834 @@
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QProgressBar,
+ QTableWidget, QTableWidgetItem, QLineEdit, QFormLayout, QScrollArea, QGraphicsDropShadowEffect
+)
+from PyQt6.QtCore import Qt, QPropertyAnimation, pyqtSignal, QTimer
+from PyQt6.QtGui import QFont, QColor, QPixmap
+import psycopg2
+from psycopg2.extras import RealDictCursor
+from datetime import datetime
+from io import BytesIO
+try:
+ from minio import Minio
+ MINIO_AVAILABLE = True
+except ImportError:
+ MINIO_AVAILABLE = False
+
+# DB connection helper (same as in main app)
+def get_db_connection():
+ return psycopg2.connect(
+ # host="postgres",
+ host="postgres",
+ port=5432,
+ user="missions_user",
+ password="pg123",
+ dbname="missions_db",
+ cursor_factory=RealDictCursor
+ )
+
+# MinIO connection helper
+def get_minio_client():
+ """Get MinIO client for accessing imagery bucket."""
+ if not MINIO_AVAILABLE:
+ print("[DEBUG] MinIO client not available (minio package not installed)")
+ return None
+ try:
+ # Try multiple endpoints for flexibility
+ endpoints = [
+ "minio-hot:9000", # Docker internal
+ "localhost:9001", # Desktop/host
+ "127.0.0.1:9001", # Loopback
+ ]
+
+ for endpoint in endpoints:
+ try:
+ client = Minio(
+ endpoint=endpoint,
+ access_key="minioadmin",
+ secret_key="minioadmin123",
+ secure=False
+ )
+ # Test connection
+ client.list_buckets()
+ print(f"[DEBUG] MinIO connected successfully at {endpoint}")
+ return client
+ except Exception as e:
+ print(f"[DEBUG] MinIO endpoint {endpoint} failed: {type(e).__name__}")
+ continue
+
+ print("[DEBUG] MinIO: all endpoints failed")
+ return None
+ except Exception as e:
+ print(f"[DEBUG] MinIO connection error: {e}")
+ return None
+
+def fetch_image_from_minio(image_path: str) -> bytes:
+ """
+ Fetch an image from MinIO 'imagery' bucket.
+
+ Args:
+ image_path: The key/path to the image in MinIO (e.g., 'folder/image.jpg')
+
+ Returns:
+ Image bytes, or None if fetch fails
+ """
+ if not MINIO_AVAILABLE:
+ print("MinIO client not available")
+ return None
+
+ try:
+ client = get_minio_client()
+ if client is None:
+ return None
+
+ response = client.get_object("imagery", image_path)
+ image_bytes = response.read()
+ return image_bytes
+ except Exception as e:
+ print(f"Error fetching image from MinIO: {e}")
+ return None
+
+class SprinklerDetailsDialogNew(QWidget):
+ """Tabbed sprinkler details dialog with Details, History, Parameters, Last Image, and Close tabs."""
+
+ history_requested = pyqtSignal(str) # device_id
+ parameters_saved = pyqtSignal(str, dict) # device_id, params
+
+ def __init__(self, parent, device_id, details):
+ super().__init__(parent)
+ print(f"[DEBUG] SprinklerDetailsDialogNew.__init__ called for device_id={device_id}")
+ self.device_id = device_id
+ self.details = details
+ self.current_tab = 'Details'
+ self.edited_params = {}
+
+ self.setWindowFlags(Qt.WindowType.FramelessWindowHint)
+ self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
+
+ print(f"[DEBUG] Setting up window for {device_id}")
+
+ # Main card
+ self.card = QWidget(self)
+ self.card.setStyleSheet("""
+ background: white;
+ border-radius: 14px;
+ """)
+
+ shadow = QGraphicsDropShadowEffect(self.card)
+ shadow.setBlurRadius(30)
+ shadow.setOffset(0, 8)
+ shadow.setColor(QColor(0, 0, 0, 100))
+ self.card.setGraphicsEffect(shadow)
+
+ # Main layout
+ main_layout = QVBoxLayout(self.card)
+ main_layout.setContentsMargins(20, 20, 20, 20)
+ main_layout.setSpacing(14)
+
+ # Header section with title only
+ header_layout = QVBoxLayout()
+ header_layout.setContentsMargins(0, 0, 0, 0)
+ header_layout.setSpacing(4)
+
+ # Title
+ title = QLabel(f"Sprinkler {device_id}")
+ title.setFont(QFont('Segoe UI', 16, QFont.Weight.Bold))
+ title.setStyleSheet("color: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #06b6d4, stop:1 #0891b2);")
+ header_layout.addWidget(title)
+
+ main_layout.addLayout(header_layout)
+
+ # Content area (will be replaced based on active tab)
+ self.content_area = QWidget()
+ self.content_layout = QVBoxLayout(self.content_area)
+ self.content_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.addWidget(self.content_area, stretch=1)
+
+ # Buttons row at bottom
+ btn_row = QHBoxLayout()
+ btn_row.setSpacing(12)
+ btn_row.addStretch()
+
+ self.tab_buttons = {}
+ self.create_tab_buttons(btn_row)
+
+ main_layout.addLayout(btn_row)
+
+ # Sizing and positioning
+ w, h = int(parent.width() * 0.6), int(parent.height() * 0.65)
+ self.setGeometry((parent.width() - w) // 2, (parent.height() - h) // 2, w, h)
+ self.card.setGeometry(0, 0, w, h)
+
+ # Fade-in animation
+ self.opacity_anim = QPropertyAnimation(self, b"windowOpacity")
+ self.opacity_anim.setDuration(220)
+ self.opacity_anim.setStartValue(0.0)
+ self.opacity_anim.setEndValue(1.0)
+ self.setWindowOpacity(0.0)
+ self.opacity_anim.start()
+
+ print(f"[DEBUG] Dialog initialized, showing Details tab")
+ # Show Details tab initially
+ self.show_tab('Details')
+
+ def create_tab_buttons(self, layout):
+ """Create all 5 tab buttons and add them to the layout."""
+ tabs = ['Details', 'History', 'Parameters', 'Last Image', 'Close']
+ for tab in tabs:
+ btn = QPushButton(tab)
+ btn.setFixedHeight(36)
+ btn.setMinimumWidth(100)
+ btn.setCursor(Qt.CursorShape.PointingHandCursor)
+ btn.setStyleSheet(self.get_button_style(tab == self.current_tab))
+ btn.clicked.connect(lambda checked, t=tab: self.switch_tab(t))
+ self.tab_buttons[tab] = btn
+ # Add all buttons to layout
+ layout.addWidget(btn)
+
+ def get_button_style(self, active=False):
+ if active:
+ return """
+ QPushButton {
+ background: #06b6d4;
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-weight: bold;
+ padding: 8px 16px;
+ }
+ """
+ else:
+ return """
+ QPushButton {
+ background: #e5e7eb;
+ color: #374151;
+ border: none;
+ border-radius: 8px;
+ padding: 8px 16px;
+ }
+ QPushButton:hover {
+ background: #d1d5db;
+ }
+ """
+
+ def switch_tab(self, tab_name):
+ """Switch to a different tab."""
+ print(f"[DEBUG] switch_tab() called: {tab_name}")
+ if tab_name == 'Close':
+ self.close()
+ return
+
+ self.current_tab = tab_name
+
+ # Clear current content safely using takeAt and deleteLater
+ while self.content_layout.count():
+ item = self.content_layout.takeAt(0)
+ if item.widget():
+ item.widget().deleteLater()
+
+ # Show selected tab
+ self.show_tab(tab_name)
+
+ # Update button styling
+ self.update_button_styles()
+ print(f"[DEBUG] Switched to {tab_name} tab")
+
+ def update_button_styles(self):
+ """Update button styles: active button highlighted, others normal."""
+ for tab_name, btn in self.tab_buttons.items():
+ btn.setStyleSheet(self.get_button_style(tab_name == self.current_tab))
+
+ def show_tab(self, tab_name):
+ """Display content for a tab."""
+ if tab_name == 'Details':
+ self.show_details_tab()
+ elif tab_name == 'History':
+ self.show_history_tab()
+ elif tab_name == 'Parameters':
+ self.show_parameters_tab()
+ elif tab_name == 'Last Image':
+ self.show_last_image_tab()
+
+ def show_details_tab(self):
+ """Display the details tab with improved layout and styling."""
+ # Status section
+ status_container = QWidget()
+ status_layout = QHBoxLayout(status_container)
+ status_layout.setContentsMargins(0, 0, 0, 0)
+ status_layout.setSpacing(10)
+
+ status_dot = QLabel('●')
+ status_dot.setFont(QFont('Arial', 14))
+ status_color = '#10b981' if self.details.get('active') else '#9ca3af'
+ status_dot.setStyleSheet(f"color: {status_color}")
+
+ status_text = QLabel('Active' if self.details.get('active') else 'Inactive')
+ status_text.setFont(QFont('Segoe UI', 11, QFont.Weight.Bold))
+ status_text.setStyleSheet(f"color: {status_color}")
+
+ status_layout.addWidget(status_dot)
+ status_layout.addWidget(status_text)
+ status_layout.addStretch()
+
+ self.content_layout.addWidget(status_container)
+ self.content_layout.addSpacing(12)
+
+ # Soil Moisture section
+ moisture_label = QLabel('Soil Moisture')
+ moisture_label.setFont(QFont('Segoe UI', 11, QFont.Weight.Bold))
+ moisture_label.setStyleSheet("color: #1f2937;")
+ self.content_layout.addWidget(moisture_label)
+
+ dry_ratio = self.details.get('dry_ratio', 0)
+ moisture_percent = int(dry_ratio * 100)
+
+ # Percentage label above bar
+ moisture_value_label = QLabel(f"{moisture_percent}%")
+ moisture_value_label.setFont(QFont('Segoe UI', 10, QFont.Weight.Bold))
+ moisture_value_label.setStyleSheet("color: #06b6d4;")
+ self.content_layout.addWidget(moisture_value_label)
+
+ # Progress bar
+ moisture_bar = QProgressBar()
+ moisture_bar.setRange(0, 100)
+ moisture_bar.setValue(moisture_percent)
+ moisture_bar.setFixedHeight(12)
+ moisture_bar.setStyleSheet("""
+ QProgressBar {
+ border: 1px solid #e5e7eb;
+ border-radius: 6px;
+ background: #f3f4f6;
+ }
+ QProgressBar::chunk {
+ background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
+ stop:0 #06b6d4, stop:1 #0891b2);
+ border-radius: 5px;
+ }
+ """)
+ self.content_layout.addWidget(moisture_bar)
+ self.content_layout.addSpacing(16)
+
+ # Confidence section
+ confidence_label = QLabel('Model Confidence')
+ confidence_label.setFont(QFont('Segoe UI', 11, QFont.Weight.Bold))
+ confidence_label.setStyleSheet("color: #1f2937;")
+ self.content_layout.addWidget(confidence_label)
+
+ conf = self.details.get('confidence', 0)
+ conf_percent = int(conf * 100)
+
+ # Determine color based on confidence
+ if conf >= 0.75:
+ conf_color = '#10b981'
+ elif conf >= 0.4:
+ conf_color = '#f59e0b'
+ else:
+ conf_color = '#ef4444'
+
+ # Percentage label above bar
+ conf_value_label = QLabel(f"{conf_percent}%")
+ conf_value_label.setFont(QFont('Segoe UI', 10, QFont.Weight.Bold))
+ conf_value_label.setStyleSheet(f"color: {conf_color};")
+ self.content_layout.addWidget(conf_value_label)
+
+ # Confidence progress bar
+ conf_bar = QProgressBar()
+ conf_bar.setRange(0, 100)
+ conf_bar.setValue(conf_percent)
+ conf_bar.setFixedHeight(12)
+ conf_bar.setStyleSheet(f"""
+ QProgressBar {{
+ border: 1px solid #e5e7eb;
+ border-radius: 6px;
+ background: #f3f4f6;
+ }}
+ QProgressBar::chunk {{
+ background: {conf_color};
+ border-radius: 5px;
+ }}
+ """)
+ self.content_layout.addWidget(conf_bar)
+ self.content_layout.addSpacing(16)
+
+ # Decision section
+ decision = self.details.get('decision', 'noop')
+ # Replace 'noop' with 'no operation'
+ if decision == 'noop':
+ decision = 'no operation'
+
+ decision_label = QLabel('Decision')
+ decision_label.setFont(QFont('Segoe UI', 11, QFont.Weight.Bold))
+ decision_label.setStyleSheet("color: #1f2937;")
+ self.content_layout.addWidget(decision_label)
+
+ # Decision badge
+ decision_badge = QLabel(decision.upper())
+ decision_badge.setFont(QFont('Segoe UI', 10, QFont.Weight.Bold))
+ decision_badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ decision_badge.setFixedHeight(32)
+
+ if decision == 'run':
+ badge_color = '#10b981'
+ bg_color = '#d1fae5'
+ elif decision == 'stop':
+ badge_color = '#ef4444'
+ bg_color = '#fee2e2'
+ else: # no operation
+ badge_color = '#f59e0b'
+ bg_color = '#fef3c7'
+
+ decision_badge.setStyleSheet(f"""
+ background: {bg_color};
+ color: {badge_color};
+ border-radius: 6px;
+ padding: 6px;
+ """)
+ self.content_layout.addWidget(decision_badge)
+
+ self.content_layout.addStretch()
+
+ def show_history_tab(self):
+ """Display the history tab with a beautifully styled table inside the dialog."""
+ records = []
+ try:
+ # Direct database query to avoid import issues
+ query = """
+ SELECT e.device_id, e.dry_ratio, e.decision, e.confidence, e.patch_count, e.ts
+ FROM soil_moisture_events e
+ WHERE e.device_id = %s
+ ORDER BY e.ts DESC
+ LIMIT 10
+ """
+ with get_db_connection() as conn:
+ with conn.cursor() as cur:
+ cur.execute(query, (self.device_id,))
+ records = cur.fetchall()
+ # Convert tuples to dicts if needed
+ if records and not isinstance(records[0], dict):
+ columns = ['device_id', 'dry_ratio', 'decision', 'confidence', 'patch_count', 'ts']
+ records = [dict(zip(columns, row)) for row in records]
+ except Exception as e:
+ print(f"Error fetching history: {e}")
+ records = []
+
+ # Add title label
+ title = QLabel('Last 10 Events')
+ title.setFont(QFont('Segoe UI', 9, QFont.Weight.Bold))
+ title.setStyleSheet("color: #374151; margin-bottom: 8px;")
+ self.content_layout.addWidget(title)
+
+ # History table with better column layout
+ table = QTableWidget(0, 5)
+ table.setHorizontalHeaderLabels(['Time', 'Soil %', 'Confidence %', 'Decision', 'Patches'])
+ table.setAlternatingRowColors(True)
+ table.setColumnWidth(0, 270) # Time
+ table.setColumnWidth(1, 70) # Soil %
+ table.setColumnWidth(2, 70) # Confidence %
+ table.setColumnWidth(3, 130) # Decision (increased from 100 to 130)
+ table.setColumnWidth(4, 70) # Patches
+ table.resizeRowsToContents()
+ table.setWordWrap(True)
+ # Set minimum row height for better visibility
+ table.verticalHeader().setDefaultSectionSize(32)
+
+ # Professional styling with better padding
+ table.setStyleSheet("""
+ QTableWidget {
+ background: white;
+ alternate-background-color: #f9fafb;
+ gridline-color: #e5e7eb;
+ border: 1px solid #e5e7eb;
+ border-radius: 6px;
+ }
+ QHeaderView::section {
+ background: #f3f4f6;
+ padding: 12px;
+ border: none;
+ font-weight: bold;
+ color: #374151;
+ border-right: 1px solid #d1d5db;
+ }
+ QTableWidget::item {
+ padding: 12px;
+ border-bottom: 1px solid #e5e7eb;
+ }
+ """)
+ table.verticalHeader().hide()
+ table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
+ table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
+
+ # Populate rows
+ for row in records:
+ r = table.rowCount()
+ table.insertRow(r)
+
+ # Column 0: Timestamp
+ ts_item = QTableWidgetItem(str(row['ts']))
+ ts_item.setFont(QFont('Courier', 9))
+ ts_item.setForeground(QColor('#6b7280'))
+ table.setItem(r, 0, ts_item)
+
+ # Column 1: Soil Moisture % (dry_ratio as %)
+ dry_percent = int(row['dry_ratio'] * 100)
+ moisture_item = QTableWidgetItem(f"{dry_percent}%")
+ moisture_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
+ moisture_item.setFont(QFont('Courier', 9))
+ table.setItem(r, 1, moisture_item)
+
+ # Column 2: Confidence %
+ conf_percent = int(row['confidence'] * 100)
+ conf_item = QTableWidgetItem(f"{conf_percent}%")
+ conf_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
+ conf_item.setFont(QFont('Courier', 9))
+ table.setItem(r, 2, conf_item)
+
+ # Column 3: Decision with color coding
+ decision = row['decision']
+ if decision == 'noop':
+ decision = 'no operation'
+
+ # Use shorter display text for better fit in cell
+ decision_text = {
+ 'run': 'RUN',
+ 'stop': 'STOP',
+ 'no operation': 'NO OPERATION'
+ }.get(decision, decision.upper())
+
+ dec_item = QTableWidgetItem(decision_text)
+ dec_item.setFont(QFont('Segoe UI', 9, QFont.Weight.Bold))
+ dec_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
+
+ # Set background and text colors with proper contrast
+ if decision == 'run':
+ dec_item.setBackground(QColor("#dde9e5"))
+ dec_item.setForeground(QColor("#58b7e4")) # White text on green
+ elif decision == 'stop':
+ dec_item.setBackground(QColor('#fee2e2')) # Light red background
+ dec_item.setForeground(QColor('#dc2626')) # Dark red text
+ else: # no operation
+ dec_item.setBackground(QColor('#f59e0b'))
+ dec_item.setForeground(QColor('#1f2937')) # Dark text for better contrast on orange
+
+ table.setItem(r, 3, dec_item)
+
+ # Column 4: Patches (patch count)
+ patch_item = QTableWidgetItem(f"{row.get('patch_count', 0)}")
+ patch_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
+ patch_item.setFont(QFont('Courier', 9))
+ table.setItem(r, 4, patch_item)
+
+ self.content_layout.addWidget(table, stretch=1)
+
+ def show_parameters_tab(self):
+ """Display the parameters tab with editable fields."""
+ try:
+ params = self.fetch_parameters()
+ except Exception:
+ params = {}
+
+ # Form for parameters
+ form = QFormLayout()
+ form.setSpacing(16)
+ form.setContentsMargins(0, 0, 0, 0)
+
+ # Define input styling
+ input_style = """
+ QLineEdit {
+ border: 2px solid #e5e7eb;
+ border-radius: 8px;
+ padding: 6px;
+ background: #f9fafb;
+ color: #1f2937;
+ font-size: 11pt;
+ font-weight: 500;
+ }
+ QLineEdit:focus {
+ border: 2px solid #06b6d4;
+ background: #ffffff;
+ }
+ QLineEdit:hover {
+ border: 2px solid #d1d5db;
+ }
+ """
+
+ # Define label styling
+ label_style = """
+ color: #374151;
+ font-weight: bold;
+ font-size: 11pt;
+ """
+
+ self.param_inputs = {}
+
+ # Dry Ratio High
+ dry_high_label = QLabel('Dry Ratio High:')
+ dry_high_label.setStyleSheet(label_style)
+ dry_high_input = QLineEdit()
+ dry_high_input.setText(str(params.get('dry_ratio_high', 0.7)))
+ dry_high_input.setStyleSheet(input_style)
+ dry_high_input.setMinimumHeight(40)
+ self.param_inputs['dry_ratio_high'] = dry_high_input
+ form.addRow(dry_high_label, dry_high_input)
+
+ # Dry Ratio Low
+ dry_low_label = QLabel('Dry Ratio Low:')
+ dry_low_label.setStyleSheet(label_style)
+ dry_low_input = QLineEdit()
+ dry_low_input.setText(str(params.get('dry_ratio_low', 0.4)))
+ dry_low_input.setStyleSheet(input_style)
+ dry_low_input.setMinimumHeight(40)
+ self.param_inputs['dry_ratio_low'] = dry_low_input
+ form.addRow(dry_low_label, dry_low_input)
+
+ # Min Patches
+ min_patches_label = QLabel('Min Patches:')
+ min_patches_label.setStyleSheet(label_style)
+ min_patches_input = QLineEdit()
+ min_patches_input.setText(str(params.get('min_patches', 1)))
+ min_patches_input.setStyleSheet(input_style)
+ min_patches_input.setMinimumHeight(40)
+ self.param_inputs['min_patches'] = min_patches_input
+ form.addRow(min_patches_label, min_patches_input)
+
+ # Duration (minutes)
+ # duration_label = QLabel('Duration (min):')
+ # duration_label.setStyleSheet(label_style)
+ # duration_input = QLineEdit()
+ # duration_input.setText(str(params.get('duration_min', 30)))
+ # duration_input.setStyleSheet(input_style)
+ # duration_input.setMinimumHeight(40)
+ # self.param_inputs['duration_min'] = duration_input
+ # form.addRow(duration_label, duration_input)
+
+ # Save button
+ save_btn = QPushButton('Save Parameters')
+ save_btn.setStyleSheet("""
+ QPushButton {
+ background: #10b981;
+ color: white;
+ border: none;
+ border-radius: 8px;
+ padding: 12px 16px;
+ font-weight: bold;
+ font-size: 11pt;
+ }
+ QPushButton:hover {
+ background: #059669;
+ }
+ QPushButton:pressed {
+ background: #047857;
+ }
+ """)
+ save_btn.setMinimumHeight(44)
+ save_btn.clicked.connect(self.save_parameters)
+
+ scroll = QScrollArea()
+ scroll_widget = QWidget()
+ scroll_widget.setLayout(form)
+ scroll.setWidget(scroll_widget)
+ scroll.setWidgetResizable(True)
+ scroll.setStyleSheet("QScrollArea { border: none; background: transparent; }")
+
+ self.content_layout.addWidget(scroll)
+ self.content_layout.addSpacing(12)
+ self.content_layout.addWidget(save_btn)
+
+ def fetch_parameters(self):
+ """Fetch parameters from irrigation_policies table."""
+ try:
+ with get_db_connection() as conn:
+ with conn.cursor() as cur:
+ query = """
+ SELECT dry_ratio_high, dry_ratio_low, min_patches, duration_min
+ FROM irrigation_policies
+ WHERE device_id = %s
+ """
+ cur.execute(query, (self.device_id,))
+ row = cur.fetchone()
+
+ if row:
+ # Handle both tuple and dict-like responses
+ if isinstance(row, (tuple, list)):
+ return {
+ 'dry_ratio_high': float(row[0]) if row[0] is not None else 0.7,
+ 'dry_ratio_low': float(row[1]) if row[1] is not None else 0.4,
+ 'min_patches': int(row[2]) if row[2] is not None else 1,
+ 'duration_min': int(row[3]) if row[3] is not None else 30
+ }
+ else:
+ # Dictionary-like response
+ return {
+ 'dry_ratio_high': float(row.get('dry_ratio_high', 0.7)),
+ 'dry_ratio_low': float(row.get('dry_ratio_low', 0.4)),
+ 'min_patches': int(row.get('min_patches', 1)),
+ 'duration_min': int(row.get('duration_min', 30))
+ }
+ except Exception as e:
+ print(f"Error fetching parameters: {type(e).__name__}: {str(e)}")
+ return {}
+
+ def save_parameters(self):
+ """Save parameters to irrigation_policies table."""
+ try:
+ # Validate and convert inputs
+ dry_ratio_high = float(self.param_inputs['dry_ratio_high'].text())
+ dry_ratio_low = float(self.param_inputs['dry_ratio_low'].text())
+ min_patches = int(self.param_inputs['min_patches'].text())
+ duration_min = 10 #int(self.param_inputs['duration_min'].text())
+
+ # Basic validation
+ if not (0 <= dry_ratio_high <= 1):
+ raise ValueError("Dry Ratio High must be between 0 and 1")
+ if not (0 <= dry_ratio_low <= 1):
+ raise ValueError("Dry Ratio Low must be between 0 and 1")
+ if min_patches < 0:
+ raise ValueError("Min Patches must be positive")
+ if duration_min < 0:
+ raise ValueError("Duration must be positive")
+
+ with get_db_connection() as conn:
+ with conn.cursor() as cur:
+ query = """
+ INSERT INTO irrigation_policies
+ (device_id, dry_ratio_high, dry_ratio_low, min_patches, duration_min)
+ VALUES (%s, %s, %s, %s, %s)
+ ON CONFLICT (device_id) DO UPDATE SET
+ dry_ratio_high = EXCLUDED.dry_ratio_high,
+ dry_ratio_low = EXCLUDED.dry_ratio_low,
+ min_patches = EXCLUDED.min_patches,
+ duration_min = EXCLUDED.duration_min
+ """
+ cur.execute(query, (self.device_id, dry_ratio_high, dry_ratio_low, min_patches, duration_min))
+ conn.commit()
+
+ # Show success message
+ msg_label = QLabel("✓ Parameters saved successfully!")
+ msg_label.setStyleSheet("color: #10b981; font-weight: bold; padding: 8px;")
+ self.content_layout.addWidget(msg_label)
+ print(f"Parameters saved for device {self.device_id}")
+
+ except ValueError as ve:
+ print(f"Validation error: {ve}")
+ msg_label = QLabel(f"✗ Validation error: {ve}")
+ msg_label.setStyleSheet("color: #ef4444; font-weight: bold; padding: 8px;")
+ self.content_layout.addWidget(msg_label)
+ except Exception as e:
+ print(f"Error saving parameters: {type(e).__name__}: {str(e)}")
+ msg_label = QLabel(f"✗ Error: {type(e).__name__}")
+ msg_label.setStyleSheet("color: #ef4444; font-weight: bold; padding: 8px;")
+ self.content_layout.addWidget(msg_label)
+
+ def show_last_image_tab(self):
+ """Display the last image tab with image from MinIO."""
+ print(f"[DEBUG] show_last_image_tab() called for {self.device_id}")
+ try:
+ # Query database for the most recent image path
+ query = """
+ SELECT extra->>'image_path' as image_path, ts
+ FROM soil_moisture_events
+ WHERE device_id = %s AND extra->>'image_path' IS NOT NULL
+ ORDER BY ts DESC
+ LIMIT 1
+ """
+ with get_db_connection() as conn:
+ with conn.cursor() as cur:
+ cur.execute(query, (self.device_id,))
+ row = cur.fetchone()
+ print(f"[DEBUG] Query result: {row}")
+
+ if row and row.get('image_path'):
+ image_path = row['image_path']
+ ts = row.get('ts', 'Unknown')
+ print(f"[DEBUG] Found image path: {image_path}, ts: {ts}")
+
+ # Fetch image from MinIO
+ image_bytes = fetch_image_from_minio(image_path)
+
+ if image_bytes:
+ print(f"[DEBUG] Successfully fetched image, size: {len(image_bytes)} bytes")
+ # Display the image
+ pixmap = QPixmap()
+ pixmap.loadFromData(image_bytes)
+
+ if not pixmap.isNull():
+ # Create container for image display
+ image_container = QWidget()
+ image_layout = QVBoxLayout(image_container)
+ image_layout.setContentsMargins(0, 0, 0, 0)
+ image_layout.setSpacing(12)
+
+ # Timestamp label
+ ts_label = QLabel(f"Timestamp: {ts}")
+ ts_label.setFont(QFont('Segoe UI', 10, QFont.Weight.Bold))
+ ts_label.setStyleSheet("color: #6b7280;")
+ image_layout.addWidget(ts_label)
+
+ # Image path label
+ path_label = QLabel(f"Path: {image_path}")
+ path_label.setFont(QFont('Courier', 9))
+ path_label.setStyleSheet("color: #9ca3af; word-wrap: true;")
+ path_label.setWordWrap(True)
+ image_layout.addWidget(path_label)
+
+ # Scale image to fit in dialog
+ max_width = int(self.card.width() * 0.8)
+ max_height = 400
+ scaled_pixmap = pixmap.scaledToWidth(
+ max_width,
+ Qt.TransformationMode.SmoothTransformation
+ )
+ if scaled_pixmap.height() > max_height:
+ scaled_pixmap = scaled_pixmap.scaledToHeight(
+ max_height,
+ Qt.TransformationMode.SmoothTransformation
+ )
+
+ # Image label
+ img_label = QLabel()
+ img_label.setPixmap(scaled_pixmap)
+ img_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ img_label.setStyleSheet("border: 1px solid #e5e7eb; border-radius: 6px;")
+ image_layout.addWidget(img_label, stretch=1)
+
+ self.content_layout.addWidget(image_container, stretch=1)
+ print(f"[DEBUG] Image displayed successfully")
+ return
+ else:
+ error_msg = "Failed to load image from bytes"
+ print(f"[DEBUG] {error_msg}")
+ self.show_image_error(error_msg)
+ return
+ else:
+ error_msg = f"Could not fetch image from MinIO: {image_path}"
+ print(f"[DEBUG] {error_msg}")
+ self.show_image_error(error_msg)
+ return
+ else:
+ # No image path found in recent events
+ error_msg = "No image data available for this device"
+ print(f"[DEBUG] {error_msg}")
+ self.show_image_error(error_msg)
+ return
+ except Exception as e:
+ error_msg = f"Error fetching image: {str(e)}"
+ print(f"[DEBUG] {error_msg}")
+ import traceback
+ traceback.print_exc()
+ self.show_image_error(error_msg)
+
+ def show_image_error(self, error_message: str):
+ """Display error message in the image tab."""
+ error_container = QWidget()
+ error_layout = QVBoxLayout(error_container)
+ error_layout.setContentsMargins(0, 0, 0, 0)
+ error_layout.addStretch()
+
+ error_label = QLabel(error_message)
+ error_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ error_label.setFont(QFont('Segoe UI', 12))
+ error_label.setStyleSheet("color: #f59e0b; padding: 20px;")
+ error_label.setWordWrap(True)
+ error_layout.addWidget(error_label)
+
+ error_layout.addStretch()
+ self.content_layout.addWidget(error_container, stretch=1)
diff --git a/GUI/src/vast/views/irrigation/sprinkler_off.png b/GUI/src/vast/views/irrigation/sprinkler_off.png
new file mode 100644
index 000000000..1807f873c
Binary files /dev/null and b/GUI/src/vast/views/irrigation/sprinkler_off.png differ
diff --git a/GUI/src/vast/views/irrigation/sprinkler_on.gif b/GUI/src/vast/views/irrigation/sprinkler_on.gif
new file mode 100644
index 000000000..4401de4bd
Binary files /dev/null and b/GUI/src/vast/views/irrigation/sprinkler_on.gif differ
diff --git a/GUI/src/vast/views/security/analytics/analytics_page.py b/GUI/src/vast/views/security/analytics/analytics_page.py
index d4cdc0475..0bf2ae31e 100644
--- a/GUI/src/vast/views/security/analytics/analytics_page.py
+++ b/GUI/src/vast/views/security/analytics/analytics_page.py
@@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import date
+
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox,
QPushButton, QDateEdit, QFrame, QSizePolicy,
@@ -20,6 +21,88 @@
from src.vast.views.security.analytics import analytics_provider as ap
+class AgGuardMessageBox(QFrame):
+ def __init__(self, parent=None, title="Message", text=""):
+ super().__init__(parent)
+
+ self.setWindowFlags(
+ Qt.WindowType.Dialog |
+ Qt.WindowType.FramelessWindowHint |
+ Qt.WindowType.WindowStaysOnTopHint
+ )
+ # no translucent background → no black corners
+ self.setAutoFillBackground(True)
+ pal = self.palette()
+ pal.setColor(self.backgroundRole(), QColor("#fefefe"))
+ self.setPalette(pal)
+
+ # Light drop shadow
+ shadow = QGraphicsDropShadowEffect()
+ shadow.setBlurRadius(28)
+ shadow.setOffset(0, 3)
+ shadow.setColor(QColor(200, 200, 200, 90))
+ self.setGraphicsEffect(shadow)
+
+ self.setStyleSheet("""
+ QFrame {
+ background-color: #fefefe;
+ border-radius: 16px;
+ border: 1px solid #f3f4f6;
+ }
+ QLabel#title {
+ font-size: 18px;
+ font-weight: 700;
+ color: #1f2937;
+ }
+ QLabel#text {
+ font-size: 14px;
+ color: #4b5563;
+ }
+ QPushButton#ok_btn {
+ background-color: #10b981;
+ color: white;
+ border-radius: 10px;
+ padding: 6px 18px;
+ font-weight: 600;
+ min-width: 80px;
+ }
+ QPushButton#ok_btn:hover {
+ background-color: #059669;
+ }
+ """)
+
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(22, 22, 22, 22)
+ layout.setSpacing(14)
+
+ title_lbl = QLabel(title)
+ title_lbl.setObjectName("title")
+
+ text_lbl = QLabel(text)
+ text_lbl.setObjectName("text")
+ text_lbl.setWordWrap(True)
+
+ btn = QPushButton("OK")
+ btn.setObjectName("ok_btn")
+ btn.clicked.connect(self.close)
+
+ layout.addWidget(title_lbl)
+ layout.addWidget(text_lbl)
+ layout.addStretch()
+ layout.addWidget(btn, alignment=Qt.AlignmentFlag.AlignRight)
+
+ self.adjustSize()
+ self.setMinimumWidth(360)
+
+ def show_centered(self):
+ if self.parent():
+ parent_geo = self.parent().geometry()
+ x = parent_geo.x() + (parent_geo.width() - self.width()) // 2
+ y = parent_geo.y() + (parent_geo.height() - self.height()) // 2
+ self.move(x, y)
+ self.show()
+
+
class GeoAnalyticsView(QWidget):
"""Geo-Analytics Dashboard with fixed analytics panel and multi-selection."""
@@ -181,7 +264,6 @@ def __init__(self, parent: QWidget | None = None):
self.query_input.setMinimumWidth(420)
self.query_input.setFixedHeight(38)
- # ✅ Text-based Send button
self.query_send = QPushButton("Send")
self.query_send.setObjectName("send_btn")
self.query_send.clicked.connect(self._on_query_sent)
@@ -189,7 +271,6 @@ def __init__(self, parent: QWidget | None = None):
query_layout.addWidget(self.query_input, stretch=1)
query_layout.addWidget(self.query_send)
- # Floating drop shadow
query_shadow = QGraphicsDropShadowEffect()
query_shadow.setBlurRadius(22)
query_shadow.setOffset(0, 3)
@@ -205,26 +286,101 @@ def __init__(self, parent: QWidget | None = None):
splitter.setStretchFactor(0, 3)
splitter.setStretchFactor(1, 1)
splitter.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
+ splitter.setStyleSheet("""
+ QSplitter::handle {
+ background-color: #e5e7eb;
+ }
+ QSplitter::handle:hover {
+ background-color: #10b981;
+ }
+ """)
- # LEFT SIDE — map + query box
+ # ─────────────────────────────
+ # LEFT SIDE — map card + query box
+ # ─────────────────────────────
map_container = QWidget()
- map_layout = QVBoxLayout(map_container)
- map_layout.setContentsMargins(0, 0, 0, 0)
- map_layout.setSpacing(8)
+ map_container_layout = QVBoxLayout(map_container)
+ map_container_layout.setContentsMargins(0, 0, 0, 0)
+ map_container_layout.setSpacing(0)
+
+ # Outer shell (white card with strong round corners + shadow)
+ map_shell = QFrame()
+ map_shell.setObjectName("mapShell")
+ map_shell.setStyleSheet("""
+ QFrame#mapShell {
+ background-color: #ffffff;
+ border-radius: 24px;
+ border: 1px solid #e5e7eb;
+ }
+ """)
+ map_shell_shadow = QGraphicsDropShadowEffect()
+ map_shell_shadow.setBlurRadius(26)
+ map_shell_shadow.setOffset(0, 8)
+ map_shell_shadow.setColor(QColor(15, 23, 42, 55))
+ map_shell.setGraphicsEffect(map_shell_shadow)
+
+ shell_layout = QVBoxLayout(map_shell)
+ shell_layout.setContentsMargins(14, 14, 14, 14)
+ shell_layout.setSpacing(10)
+
+ # Inner rounded frame that actually clips the map
+ map_frame = QFrame()
+ map_frame.setObjectName("mapFrame")
+ map_frame.setStyleSheet("""
+ QFrame#mapFrame {
+ background-color: #020617; /* dark slate */
+ border-radius: 18px;
+ border: none;
+ }
+ """)
+ map_frame.setContentsMargins(0, 0, 0, 0)
+ map_frame_layout = QVBoxLayout(map_frame)
+ map_frame_layout.setContentsMargins(0, 0, 0, 0)
+ map_frame_layout.setSpacing(0)
tiles_root = "./src/vast/orthophoto_canvas/data/tiles"
self.viewer = create_orthophoto_viewer(tiles_root, forced_scheme=None, parent=self)
+
+ # Make the view itself frameless and rounded
+ self.viewer.setFrameShape(QFrame.Shape.NoFrame)
+ self.viewer.setStyleSheet("""
+ QGraphicsView {
+ background-color: transparent;
+ border: none;
+ }
+ QGraphicsView::viewport {
+ border-radius: 18px; /* real rounded viewport */
+ background-color: #020617;
+ }
+ """)
+ self.viewer.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
+
+ # Optional static background instead of tiles
+ self.viewer.set_custom_background_image(
+ "./src/vast/orthophoto_canvas/ui/fields.png",
+ hide_tiles=True
+ )
self.viewer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
- map_layout.addWidget(self.viewer, stretch=1)
- map_layout.addWidget(query_frame, stretch=0)
+
+ map_frame_layout.addWidget(self.viewer, stretch=1)
+
+ # Query bar sits under the map, inside the same shell
query_frame.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
+ shell_layout.addWidget(map_frame, stretch=1)
+ shell_layout.addWidget(query_frame, stretch=0)
+
+ map_container_layout.addWidget(map_shell)
splitter.addWidget(map_container)
+
+ # ─────────────────────────────
# RIGHT SIDE — analytics panel
- self.analytics_panel = AnalyticsPanel("All Regions", {},parent=self)
+ # ─────────────────────────────
+ self.analytics_panel = AnalyticsPanel("All Regions", {}, parent=self)
self.analytics_panel.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
splitter.addWidget(self.analytics_panel)
+
root.addWidget(splitter, stretch=1)
# ─────────────────────────────
@@ -236,6 +392,28 @@ def __init__(self, parent: QWidget | None = None):
# Initial load
self._load_regions()
self._update_analytics_panel()
+ self._initial_fit_done = False
+ QtCore.QTimer.singleShot(0, self._fit_map_to_container)
+
+ # Ensure map always fills its container nicely
+ def resizeEvent(self, event):
+ super().resizeEvent(event)
+ QtCore.QTimer.singleShot(0, self._fit_map_to_container)
+
+ def _fit_map_to_container(self):
+ """
+ Make the map fill the entire map container (no gray bars).
+ Run after layouts/child resizes using QTimer.singleShot.
+ """
+ if not hasattr(self, "viewer") or self.viewer is None:
+ return
+
+ scene_rect = self.viewer.scene.sceneRect()
+ if scene_rect.isNull():
+ return
+
+ self.viewer.fitInView(scene_rect, Qt.AspectRatioMode.KeepAspectRatioByExpanding)
+ self.viewer.centerOn(scene_rect.center())
# ─────────────────────────────
# FREE TEXT QUERY HANDLER
@@ -248,13 +426,20 @@ def _on_query_sent(self):
# Change button state
self.query_send.setEnabled(False)
- self.query_send.setText("...") # show dots while sending
+ self.query_send.setText("...")
QApplication.processEvents()
try:
result = ap.select_entities_from_prompt(prompt)
except Exception as e:
- QMessageBox.warning(self, "Error", f"Query failed: {e}")
+ dlg = AgGuardMessageBox(
+ self,
+ title="Invalid Query",
+ text="We couldn't understand your request.\nPlease try rephrasing your query or using clearer terms."
+ )
+ dlg.show_centered()
+
+ # restore state and abort
self.query_send.setEnabled(True)
self.query_send.setText("Send")
return
@@ -366,8 +551,6 @@ def _update_analytics_panel(self):
data = get_device_analytics(device_list or None, self.start_date, self.end_date)
title = "All Devices" if not device_list else ", ".join(device_list)
self.analytics_panel.update_data(title, data)
-
-
def _update_layer_visibility(self):
if self.current_mode == "region":
diff --git a/GUI/src/vast/views/security/analytics/map_layers/device_layer.py b/GUI/src/vast/views/security/analytics/map_layers/device_layer.py
index 93939b822..127249c81 100644
--- a/GUI/src/vast/views/security/analytics/map_layers/device_layer.py
+++ b/GUI/src/vast/views/security/analytics/map_layers/device_layer.py
@@ -8,67 +8,6 @@
from PyQt6.QtCore import Qt, QPropertyAnimation, QEasingCurve
-class _DeviceMarker(QGraphicsTextItem):
- """A single camera/device emoji marker with selection halo and pulse effect."""
-
- def __init__(self, device_id: str, active: bool, on_select=None):
- super().__init__("📷")
- self.device_id = device_id
- self.active = active
- self.on_select = on_select
- self.selected = False
-
- # Base style — emoji, color by status
- self.normal_color = QColor("#10b981") if active else QColor("#9ca3af")
- self.selected_color = QColor("#ffffff")
- self.setFont(QFont("Noto Color Emoji", 14))
- self.setDefaultTextColor(self.normal_color)
- self.setZValue(1000)
- self.setFlag(QGraphicsTextItem.GraphicsItemFlag.ItemIgnoresTransformations, True)
- self.setAcceptHoverEvents(True)
-
- # Halo effect for glow
- self.halo = QGraphicsDropShadowEffect()
- self.halo.setBlurRadius(40)
- self.halo.setOffset(0, 0)
- self.halo.setColor(QColor(16, 185, 129, 160))
- self.setGraphicsEffect(None)
-
- # Pulse animation
- self.pulse = QPropertyAnimation(self, b"opacity")
- self.pulse.setDuration(1000)
- self.pulse.setStartValue(1.0)
- self.pulse.setEndValue(0.5)
- self.pulse.setEasingCurve(QEasingCurve.Type.InOutQuad)
- self.pulse.setLoopCount(-1)
-
- def mousePressEvent(self, event):
- """Toggle selection highlight and trigger callback."""
- self.selected = not self.selected
-
- if self.selected:
- self.setFont(QFont("Noto Color Emoji", 18))
- self.setDefaultTextColor(self.selected_color)
- self.halo.setColor(QColor(5, 150, 105, 255))
- self.setGraphicsEffect(self.halo)
- self.pulse.start()
- else:
- self.setFont(QFont("Noto Color Emoji", 14))
- self.setDefaultTextColor(self.normal_color)
- self.setGraphicsEffect(None)
- self.pulse.stop()
- self.setOpacity(1.0)
-
- if self.on_select:
- self.on_select(self.device_id, self.selected)
-
- super().mousePressEvent(event)
- # ─────────────────────────────────────────────
-
-
-
-
-
# ─────────────────────────────────────────────
# 🗺️ Device Layer
# ─────────────────────────────────────────────
@@ -101,10 +40,15 @@ def add_device(self, device: dict, start_date=None, end_date=None, selected=Fals
xb, yb = pos
scene_x = (xb - self._x_min_base) * TILE_SIZE
scene_y = (yb - self._y_min_base) * TILE_SIZE
-
- marker = _DeviceMarker(device["device_id"], device.get("active", True), self.on_select)
+ marker = _Camera360Marker(
+ device["device_id"],
+ device.get("active", True),
+ self.on_select,
+ radius=10.0, # tweak for size
+ )
marker.setPos(scene_x, scene_y)
self.scene.addItem(marker)
+
self.devices[device["device_id"]] = marker
if selected:
@@ -127,3 +71,109 @@ def setVisible(self, visible: bool):
for item in self.devices.values():
item.setVisible(visible)
print(f"[DeviceLayer] Visibility set to {visible}")
+from PyQt6.QtWidgets import QGraphicsObject, QGraphicsDropShadowEffect
+from PyQt6.QtGui import QColor, QPen, QBrush, QPainter
+from PyQt6.QtCore import QRectF, QPropertyAnimation, QEasingCurve, pyqtSlot
+
+
+class _Camera360Marker(QGraphicsObject):
+ """360° camera marker: donut + center dot + glow + pulse."""
+
+ def __init__(self, device_id: str, active: bool, on_select=None, radius: float = 10.0):
+ super().__init__()
+ self.device_id = device_id
+ self.active = active
+ self.on_select = on_select
+ self.selected = False
+ self._radius = radius
+
+ # Colors
+ self.normal_color = QColor("#10b981") if active else QColor("#9ca3af") # green / gray
+ self.alert_color = QColor("#ef4444") # red for alerts if you use it
+ self.selected_color = QColor("#ffffff")
+
+ # We draw in local coords around (0,0); QGraphicsView will position us
+ self.setZValue(1000)
+ self.setFlag(self.GraphicsItemFlag.ItemIgnoresTransformations, True) # stay same size on zoom
+ self.setAcceptHoverEvents(True)
+
+ # Drop shadow halo for glow
+ self.halo = QGraphicsDropShadowEffect()
+ self.halo.setBlurRadius(32)
+ self.halo.setOffset(0, 0)
+ self.halo.setColor(QColor(16, 185, 129, 180))
+ self.setGraphicsEffect(None) # only on selection
+
+ # Pulse animation on opacity
+ self.pulse = QPropertyAnimation(self, b"opacity")
+ self.pulse.setDuration(1000)
+ self.pulse.setStartValue(1.0)
+ self.pulse.setEndValue(0.6)
+ self.pulse.setEasingCurve(QEasingCurve.Type.InOutQuad)
+ self.pulse.setLoopCount(-1)
+
+ # ─────────────────────────────────────────────
+ # Required overrides
+ # ─────────────────────────────────────────────
+ def boundingRect(self) -> QRectF:
+ # a bit larger than radius to accommodate the stroke + halo
+ r = self._radius + 4
+ return QRectF(-r, -r, 2 * r, 2 * r)
+
+ def paint(self, painter: QPainter, option, widget=None):
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+
+ # Base color depends on state
+ base = self.normal_color if not self.selected else self.normal_color
+
+ # Outer ring (donut)
+ outer_r = self._radius
+ inner_r = self._radius * 0.55
+
+ # Outer circle stroke
+ pen = QPen(base)
+ pen.setWidthF(2.0 if not self.selected else 3.0)
+ painter.setPen(pen)
+ painter.setBrush(Qt.BrushStyle.NoBrush)
+ painter.drawEllipse(QRectF(-outer_r, -outer_r, 2 * outer_r, 2 * outer_r))
+
+ # Soft filled ring (semi-transparent)
+ ring_color = QColor(base.red(), base.green(), base.blue(), 80)
+ painter.setBrush(QBrush(ring_color))
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.drawEllipse(QRectF(-outer_r, -outer_r, 2 * outer_r, 2 * outer_r))
+
+ # Cut inner circle to make donut effect (by drawing a solid inner circle of background color)
+ inner_bg = QColor("#0f172a") # same tone as map background / dark outline
+ painter.setBrush(inner_bg)
+ painter.drawEllipse(QRectF(-inner_r, -inner_r, 2 * inner_r, 2 * inner_r))
+
+ # Center dot – represents the physical camera
+ center_r = inner_r * 0.5
+ painter.setBrush(base if not self.selected else self.selected_color)
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.drawEllipse(QRectF(-center_r, -center_r, 2 * center_r, 2 * center_r))
+
+ # ─────────────────────────────────────────────
+ # Interaction
+ # ─────────────────────────────────────────────
+ def mousePressEvent(self, event):
+ self.toggle_selected()
+ if self.on_select:
+ self.on_select(self.device_id, self.selected)
+ super().mousePressEvent(event)
+
+ @pyqtSlot()
+ def toggle_selected(self):
+ self.selected = not self.selected
+
+ if self.selected:
+ # enable glow + pulse
+ self.setGraphicsEffect(self.halo)
+ self.pulse.start()
+ else:
+ # disable glow + pulse
+ self.setGraphicsEffect(None)
+ self.pulse.stop()
+ self.setOpacity(1.0)
+ self.update()
diff --git a/GUI/src/vast/views/security/analytics/map_layers/region_layer.py b/GUI/src/vast/views/security/analytics/map_layers/region_layer.py
index f62171d38..b457c5e6c 100644
--- a/GUI/src/vast/views/security/analytics/map_layers/region_layer.py
+++ b/GUI/src/vast/views/security/analytics/map_layers/region_layer.py
@@ -1,9 +1,12 @@
+from __future__ import annotations
+
+import json
+from typing import List, Optional, Tuple
+
from PyQt6.QtWidgets import QGraphicsPolygonItem
-from PyQt6.QtGui import QColor, QPen, QPolygonF, QBrush
+from PyQt6.QtGui import QColor, QPen, QPolygonF
from PyQt6.QtCore import Qt, QPointF
-import json
-from shapely.geometry import Polygon, box
-from shapely.ops import unary_union
+
from src.vast.orthophoto_canvas.ui.sensors_layer import (
TILE_SIZE,
_latlon_to_xy_at_max_zoom,
@@ -11,13 +14,16 @@
class RegionLayer:
- """Draws farm regions as interactive polygons positioned by GPS coordinates.
- Supports selection by region ID and clips polygons to the orthophoto scene boundaries."""
+ """
+ Draws farm regions as interactive polygons positioned by GPS coordinates.
+ Uses tile-based projection when possible, and falls back to a linear
+ lon/lat → scene mapping based on the known map coverage if needed.
+ """
def __init__(self, viewer, on_select=None):
self.viewer = viewer
self.scene = viewer.scene
- self.regions = []
+ self.regions: List[QGraphicsPolygonItem] = []
self.on_select = on_select
# Base tile indices at MAX zoom (same as OrthophotoViewer scene)
@@ -28,12 +34,61 @@ def __init__(self, viewer, on_select=None):
# Scene boundary in scene coordinates (0,0) → (width,height)
width = (viewer.ts.z_ranges[z][1] - self._x_min_base + 1) * TILE_SIZE
height = (viewer.ts.z_ranges[z][3] - self._y_min_base + 1) * TILE_SIZE
+ self._scene_width = width
+ self._scene_height = height
+
+ # For reference; not used for clipping anymore
+ self.scene_bounds = (0, 0, width, height)
+
+ # 🔹 Map lon/lat bounds – SAME as you used in your SQL
+ # [COVERAGE z=18] lon:[34.844513..34.855499] lat:[31.895049..31.904376]
+ self._map_min_lon = 34.844513
+ self._map_max_lon = 34.855499
+ self._map_min_lat = 31.895049
+ self._map_max_lat = 31.904376
+
+ # ─────────────────────────────────────────────
+ # Helper: robust projection
+ # ─────────────────────────────────────────────
+ def _project_lon_lat(self, lon: float, lat: float) -> Optional[Tuple[float, float]]:
+ """
+ Try the original tile-based projection first.
+ If it fails (None), fall back to a simple linear mapping
+ from [min_lon..max_lon] × [min_lat..max_lat] → [0..width] × [0..height].
+ """
+ # 1) Try existing helper – keeps consistency with tiles/sensors when it works
+ pos = _latlon_to_xy_at_max_zoom(self.viewer, lat, lon)
+ if pos:
+ xb, yb = pos
+ sx = (xb - self.viewer._x_min_base) * TILE_SIZE
+ sy = (yb - self.viewer._y_min_base) * TILE_SIZE
+ return sx, sy
+
+ # 2) Fallback: linear mapping using known map bounds
+ # Guard against division by zero
+ if (
+ self._map_max_lon == self._map_min_lon
+ or self._map_max_lat == self._map_min_lat
+ ):
+ return None
- self.scene_bounds = box(0, 0, width, height)
+ # Normalize lon/lat into [0,1]
+ x_norm = (lon - self._map_min_lon) / (self._map_max_lon - self._map_min_lon)
+ y_norm = (lat - self._map_min_lat) / (self._map_max_lat - self._map_min_lat)
+
+ # Clip just in case (should already be in [0,1])
+ x_norm = max(0.0, min(1.0, x_norm))
+ y_norm = max(0.0, min(1.0, y_norm))
+
+ # Scene coords: x grows right, y grows down → flip y
+ sx = x_norm * self._scene_width
+ sy = (1.0 - y_norm) * self._scene_height
+
+ return sx, sy
# ─────────────────────────────────────────────
def add_region(self, region: dict, start_date=None, end_date=None, selected_ids=None):
- """Add a region polygon to the orthophoto map, clipped to scene boundaries."""
+ """Add a region polygon to the orthophoto map."""
try:
geom_json = json.loads(region["geom"])
except Exception as e:
@@ -44,75 +99,52 @@ def add_region(self, region: dict, start_date=None, end_date=None, selected_ids=
print(f"[RegionLayer] ⚠️ Region {region.get('name')} missing coordinates")
return
- outer_ring = geom_json["coordinates"][0]
+ coords = geom_json["coordinates"]
+ gtype = geom_json.get("type")
+
+ # Handle Polygon / MultiPolygon
+ if gtype == "Polygon":
+ outer_ring = coords[0]
+ elif gtype == "MultiPolygon":
+ # Pick the largest polygon’s outer ring
+ outer_ring = max(coords, key=lambda c: len(c[0]))[0]
+ else:
+ print(f"[RegionLayer] ⚠️ Unsupported geom type {gtype} for {region.get('name')}")
+ return
+
if len(outer_ring) < 3:
print(f"[RegionLayer] ⚠️ Region {region.get('name')} has too few points")
return
# ── Project region to scene coordinates
- scene_points = []
+ scene_points: list[QPointF] = []
+ print(f"[RegionLayer] ▶ Projecting region '{region.get('name')}'...")
for lon, lat in outer_ring:
- pos = _latlon_to_xy_at_max_zoom(self.viewer, lat, lon)
- if not pos:
+ proj = self._project_lon_lat(lon, lat)
+ print(f" - vertex lon={lon:.6f}, lat={lat:.6f} -> {proj}")
+ if not proj:
continue
- xb, yb = pos
- sx = (xb - self.viewer._x_min_base) * TILE_SIZE
- sy = (yb - self.viewer._y_min_base) * TILE_SIZE
- scene_points.append((sx, sy))
+ sx, sy = proj
+ scene_points.append(QPointF(sx, sy))
if len(scene_points) < 3:
print(f"[RegionLayer] ⚠️ Region {region.get('name')} has too few valid projected points")
return
- # ── Clip polygon to field boundaries
- poly = Polygon(scene_points)
- clipped = poly.intersection(self.scene_bounds)
-
- if clipped.is_empty:
- print(f"[RegionLayer] ⚠️ Region {region.get('name')} lies completely outside field, skipped")
- return
-
- # Merge if multiple fragments (MultiPolygon)
- if clipped.geom_type == "MultiPolygon":
- clipped = unary_union(clipped)
-
- # ── Convert back to QPolygonF
- def to_qpolygonf(geom):
- """Convert Shapely geometry to QPolygonF (skip LineStrings)."""
- if geom.is_empty:
- return None
-
- if geom.geom_type == "Polygon":
- pts = [QPointF(x, y) for x, y in geom.exterior.coords]
- return QPolygonF(pts)
-
- elif geom.geom_type == "MultiPolygon":
- largest = max(geom.geoms, key=lambda g: g.area)
- pts = [QPointF(x, y) for x, y in largest.exterior.coords]
- return QPolygonF(pts)
-
- print(f"[RegionLayer] ⚠️ Skipped degenerate geometry ({geom.geom_type}) for region {region['name']}")
- return None
-
- polygon = to_qpolygonf(clipped)
- if not polygon:
- print(f"[RegionLayer] ⚠️ Region {region['name']} clipped to non-polygon shape → skipped")
- return
+ polygon = QPolygonF(scene_points)
# ── Create graphics item
item = QGraphicsPolygonItem(polygon)
- item.region_id = region["id"] # ✅ store ID
+ item.region_id = region["id"]
item.region_name = region["name"]
item.selected = False
item.setZValue(900)
- pen = QPen(QColor("#2563eb"))
- pen.setWidthF(1.5)
+ pen = QPen(QColor("#111827"))
+ pen.setWidthF(8)
item.setPen(pen)
- # If this region is among pre-selected IDs, fill it stronger
is_selected = (selected_ids is not None) and (region["id"] in selected_ids)
-
base_alpha = 100 if is_selected else 40
item.setBrush(QColor(37, 99, 235, base_alpha))
item.selected = bool(is_selected)
@@ -120,22 +152,23 @@ def to_qpolygonf(geom):
item.setAcceptHoverEvents(True)
item.setFlag(QGraphicsPolygonItem.GraphicsItemFlag.ItemIsSelectable, True)
- # Mouse click toggles selection
item.mousePressEvent = lambda e, it=item: self._toggle_selection(it)
self.scene.addItem(item)
self.regions.append(item)
- print(f"[RegionLayer] ✅ Added region '{item.region_name}' (ID {item.region_id}) "
- f"(clipped to {len(clipped.exterior.coords)} vertices)")
+ print(
+ f"[RegionLayer] ✅ Added region '{item.region_name}' "
+ f"(ID {item.region_id}) with {len(scene_points)} vertices"
+ )
# ─────────────────────────────────────────────
- def _toggle_selection(self, item):
+ def _toggle_selection(self, item: QGraphicsPolygonItem):
"""Toggle fill color when selected and trigger callback."""
item.selected = not item.selected
item.setBrush(QColor(37, 99, 235, 100 if item.selected else 40))
if self.on_select:
- self.on_select(item.region_id, item.selected) # ✅ send ID instead of name
+ self.on_select(item.region_id, item.selected)
# ─────────────────────────────────────────────
def clear(self):
@@ -144,10 +177,10 @@ def clear(self):
self.scene.removeItem(item)
self.regions.clear()
print("[RegionLayer] Cleared all regions")
- # ─────────────────────────────────────────────
+
+ # ─────────────────────────────────────────────
def setVisible(self, visible: bool):
"""Show or hide all region polygons."""
for item in self.regions:
item.setVisible(visible)
print(f"[RegionLayer] Visibility set to {visible}")
-
diff --git a/GUI/src/vast/views/security/analytics/sql_generator.py b/GUI/src/vast/views/security/analytics/sql_generator.py
index bf8e5d0a2..99580f666 100644
--- a/GUI/src/vast/views/security/analytics/sql_generator.py
+++ b/GUI/src/vast/views/security/analytics/sql_generator.py
@@ -78,113 +78,254 @@
"additionalProperties": False
}
-# ──────────────────────────────────────────────
-# 🧠 DSL-aware system prompt
-# ──────────────────────────────────────────────
+
+
SYSTEM_PROMPT = """
-You are an expert SQL-to-DSL translator for the AgGuard Analytics Dashboard.
-Convert a natural language request into a strict JSON object compatible with the AgGuard DSL.
+You are an expert DSL generator for the AgGuard Analytics Dashboard.
+Your task: convert a natural-language request into a strict JSON object
+compatible with the AgGuard DSL (no SQL, JSON only).
-──────────────────────────────
-📘 TABLE: alerts
-──────────────────────────────
+────────────────────────
+TABLE: alerts
+────────────────────────
CREATE TABLE alerts (
- alert_id TEXT PRIMARY KEY,
+ alert_id TEXT PRIMARY KEY,
alert_type TEXT,
- device_id TEXT,
+ device_id TEXT,
started_at TIMESTAMPTZ,
- ended_at TIMESTAMPTZ,
+ ended_at TIMESTAMPTZ,
confidence DOUBLE PRECISION,
- area TEXT,
- lat DOUBLE PRECISION,
- lon DOUBLE PRECISION,
- severity INT DEFAULT 1,
- image_url TEXT,
- vod TEXT,
- hls TEXT,
- ack BOOLEAN DEFAULT FALSE,
- meta JSONB,
+ area TEXT,
+ lat DOUBLE PRECISION,
+ lon DOUBLE PRECISION,
+ severity INT DEFAULT 1,
+ image_url TEXT,
+ vod TEXT,
+ hls TEXT,
+ ack BOOLEAN DEFAULT FALSE,
+ meta JSONB,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-Guidelines for using this table:
-- Use "alert_id" as the unique identifier.
-- Use "started_at" and "ended_at" for time filtering.
-- Use "severity" for numerical scoring or comparisons.
-- Use "ack" to check whether the alert was acknowledged.
-- Use "area", "lat", "lon" for spatial or regional context.
-- Use "alert_type" for category filtering.
-- Use "device_id" to link to the originating device.
-- Avoid creating non-existent columns.
-
-──────────────────────────────
-📘 DSL STRUCTURE
-──────────────────────────────
+Use only these columns. Do not invent new columns or tables.
+
+────────────────────────
+DSL STRUCTURE
+────────────────────────
+The output MUST follow this shape:
+
{
"source": "alerts",
"_ops": [
- {"op": "select", "columns": ["..."]},
- {"op": "where", "cond": { ... }},
+ {"op": "select", "columns": ["..."]},
+ {"op": "where", "cond": { ... }},
{"op": "group_by", "columns": ["..."]},
- {"op": "having", "cond": { ... }},
+ {"op": "having", "cond": { ... }},
{"op": "order_by", "columns": ["..."], "directions": ["ASC"|"DESC"]},
- {"op": "limit", "limit": 50}
+ {"op": "limit", "limit": 50},
+ {"op": "offset", "offset": 0}
]
}
-──────────────────────────────
-📘 CONDITION TREE FORMAT
-──────────────────────────────
-Conditions use nested AND/OR logic and binary predicates:
-- {"all": [ ... ]} → logical AND
-- {"any": [ ... ]} → logical OR
-- {"op": "", "left": {"col": ""}, "right": {"literal": }}
+Rules for fields:
+- "source" must always be "alerts".
+- "_ops" is an ordered array of operations.
+- "op" is one of: "select", "where", "group_by", "having", "order_by", "limit", "offset".
+- "columns" is ALWAYS an array of strings (1–2 items, no duplicates).
+ - For functions/expressions in SELECT/GROUP_BY/ORDER_BY, write SQL text strings, e.g.:
+ "COUNT(alert_id)", "COUNT(*)", "DATE_TRUNC('month', started_at)".
+ - Never put {"func": ...} objects inside "columns".
+- "directions" aligns with "columns" and contains only "ASC" or "DESC".
+- "limit" is 1–500; "offset" is >= 0.
+
+SELECT rules (very important):
+- When selecting entities, SELECT must contain only:
+ - ["device_id"], or
+ - ["area"].
+- Never put aggregates or functions into SELECT (no COUNT(*), SUM, etc. in SELECT).
+
+────────────────────────
+CONDITION TREE FORMAT
+────────────────────────
+Conditions ("cond") are boolean trees:
+
+1. Logical AND:
+ { "all": [ , ... ] }
+
+2. Logical OR:
+ { "any": [ , ... ] }
+
+3. Predicate:
+ { "op": "", "left": , "right": }
-Allowed operators: =, !=, <, <=, >, >=
+Allowed operators: "=", "!=", "<", "<=", ">", ">=".
-──────────────────────────────
-📘 EXAMPLES
-──────────────────────────────
-User: "show all alerts with severity >= 4 and not acknowledged"
+In conditions, an may be:
+- {"col": ""}
+- {"literal": }
+- {"func": "", "args": [ , ... ]}
+
+Notes:
+- Use {"col": "..."} only with real columns from the alerts table.
+- Use {"literal": ...} for numbers, strings, booleans, and time expressions like:
+ "now() - interval '1 month'".
+- Use {"func": ...} only in WHERE/HAVING, not in SELECT/GROUP_BY/ORDER_BY.
+- Do NOT use window functions or OVER() (no row_number, no "max(avg) over ()").
+
+────────────────────────
+EXAMPLES
+────────────────────────
+
+Example 1
+User: "show devices with fence_hole alerts from the last month"
→
{
- "source": "alerts",
- "_ops": [
- {"op": "select", "columns": ["alert_id", "severity", "ack"]},
- {"op": "where", "cond": {
- "all": [
- {"op": ">=", "left": {"col": "severity"}, "right": {"literal": 4}},
- {"op": "=", "left": {"col": "ack"}, "right": {"literal": false}}
- ]
- }}
- ]
+ "source": "alerts",
+ "_ops": [
+ {"op": "select", "columns": ["device_id"]},
+ {
+ "op": "where",
+ "cond": {
+ "all": [
+ {
+ "op": "=",
+ "left": {"col": "alert_type"},
+ "right": {"literal": "fence_hole"}
+ },
+ {
+ "op": ">",
+ "left": {"col": "started_at"},
+ "right": {"literal": "now() - interval '1 month'"}
+ }
+ ]
+ }
+ },
+ {"op": "group_by", "columns": ["device_id"]}
+ ]
}
-User: "how many alerts of type fence_hole in the last month"
+────────────────────────
+
+Example 2
+User: "show areas with more than 3 severe alerts (severity > 3) in the last month"
→
{
- "source": "alerts",
- "_ops": [
- {"op": "select", "columns": ["COUNT(*) AS total_alerts"]},
- {"op": "where", "cond": {
- "all": [
- {"op": "=", "left": {"col": "alert_type"}, "right": {"literal": "fence_hole"}},
- {"op": ">", "left": {"col": "started_at"}, "right": {"literal": "now() - interval '1 month'"}}
- ]
- }}
- ]
+ "source": "alerts",
+ "_ops": [
+ {"op": "select", "columns": ["area"]},
+ {
+ "op": "where",
+ "cond": {
+ "all": [
+ {
+ "op": ">",
+ "left": {"col": "severity"},
+ "right": {"literal": 3}
+ },
+ {
+ "op": ">",
+ "left": {"col": "started_at"},
+ "right": {"literal": "now() - interval '1 month'"}
+ }
+ ]
+ }
+ },
+ {"op": "group_by", "columns": ["area"]},
+ {
+ "op": "having",
+ "cond": {
+ "op": ">",
+ "left": {"func": "count", "args": [ {"literal": "*"} ]},
+ "right": {"literal": 3}
+ }
+ }
+ ]
}
-──────────────────────────────
-📘 RULES
-──────────────────────────────
-1. Always use existing alert columns listed above.
-2. Never reference tables or aliases like "r." or "d.".
-3. Output only valid JSON — never raw SQL.
-4. Include aggregates, order, and limit if implied.
-5. When selecting entities, the SELECT clause must contain only "device_id" or "area". never include COUNT(*), aggregates, or joins.
-6. alert_type is one of the following: masked_person, intruding animal,climbing_fence, fence_hole.
+
+────────────────────────
+
+Example 4
+User: "top 5 devices with highest number of climbing_fence alerts"
+→
+{
+ "source": "alerts",
+ "_ops": [
+ {"op": "select", "columns": ["device_id"]},
+ {
+ "op": "where",
+ "cond": {
+ "op": "=",
+ "left": {"col": "alert_type"},
+ "right": {"literal": "climbing_fence"}
+ }
+ },
+ {"op": "group_by", "columns": ["device_id"]},
+ {
+ "op": "order_by",
+ "columns": ["COUNT(alert_id)"],
+ "directions": ["DESC"]
+ },
+ {"op": "limit", "limit": 5}
+ ]
+}
+
+
+────────────────────────
+
+Example 5
+User: "list devices with unacknowledged masked_person alerts in the last week, newest first, limit 20"
+→
+{
+ "source": "alerts",
+ "_ops": [
+ {"op": "select", "columns": ["device_id"]},
+ {
+ "op": "where",
+ "cond": {
+ "all": [
+ {
+ "op": "=",
+ "left": {"col": "alert_type"},
+ "right": {"literal": "masked_person"}
+ },
+ {
+ "op": "=",
+ "left": {"col": "ack"},
+ "right": {"literal": false}
+ },
+ {
+ "op": ">",
+ "left": {"col": "started_at"},
+ "right": {"literal": "now() - interval '1 week'"}
+ }
+ ]
+ }
+ },
+ {
+ "op": "order_by",
+ "columns": ["started_at"],
+ "directions": ["DESC"]
+ },
+ {"op": "limit", "limit": 20}
+ ]
+}
+
+────────────────────────
+GLOBAL RULES
+────────────────────────
+1. Always use only the columns of the alerts table.
+2. Never reference table aliases like "a." or "r." and never reference other tables.
+3. Output only VALID JSON, with "source" and "_ops" at the top level.
+4. Use WHERE for row filters, GROUP_BY for aggregations, HAVING for aggregate conditions.
+5. For queries that rank entities by number of alerts (e.g. "top", "most", "highest", "least", "fewest", "lowest"):
+ - Use GROUP_BY on the entity ("device_id" or "area").
+ - Use ORDER_BY with "COUNT(alert_id)" or "COUNT(*)".
+ • For "top / most / highest": use direction "DESC".
+ • For "least / fewest / lowest": use direction "ASC".
+ - Use LIMIT N (or LIMIT 1 if the user asks for a single best/worst entity).
+6. alert_type is one of: "masked_person", "intruding animal", "climbing_fence", "fence_hole".
"""
@@ -208,7 +349,7 @@ def generate_sql_from_prompt(prompt: str) -> tuple[str | None, list]:
)
obj = json.loads(response.choices[0].message.content)
-
+ print(obj)
try:
validate(instance=obj, schema=QUERY_SCHEMA)
except ValidationError as e:
@@ -241,3 +382,12 @@ def generate_sql_from_prompt(prompt: str) -> tuple[str | None, list]:
+
+
+
+
+
+
+
+
+
diff --git a/GUI/src/vast/views/security/events_history_page.py b/GUI/src/vast/views/security/events_history_page.py
index b77b46442..889191de4 100644
--- a/GUI/src/vast/views/security/events_history_page.py
+++ b/GUI/src/vast/views/security/events_history_page.py
@@ -686,11 +686,39 @@ def handle_feedback_change(checked, alert=it, up_btn=thumb_up, down_btn=thumb_do
self.table.setCellWidget(r, 7, cell_wrapper)
+ # self.table.setCellWidget(r, 7, feedback_widget)
+
+
+
+
+
+
+
+
+
+
+
+
print("[TABLE] Done populating alerts table.")
+
+
+
+
+
+
+
+ # def _open_video_player(self, info):
+ # print(f"[VIDEO] Opening video player for alert={info.get('alert_id')}")
+ # url = info.get("vod")
+ # if not url:
+ # QtWidgets.QMessageBox.warning(self, "No Video", "This alert has no VOD URL.")
+ # return
+
+
def _open_video_player(self, info):
print(f"[VIEW] Opening media for alert={info.get('alert_id')}")
@@ -743,7 +771,7 @@ def _show_vlc_popup(self, url):
print(f"[VIDEO] Playing URL: {url}")
popup = QtWidgets.QDialog(self)
popup.setWindowTitle("Incident Video Playback")
- popup.setMinimumSize(640, 400)
+ popup.setMinimumSize(1280, 720)
vbox = QtWidgets.QVBoxLayout(popup)
player = QtWidgets.QFrame()
player.setStyleSheet("background:black;border-radius:8px;")
@@ -758,6 +786,48 @@ def _show_vlc_popup(self, url):
mp.set_xwindow(int(player.winId()))
mp.play()
print("[VIDEO] Playback started.")
+ # def _show_vlc_popup(self, url):
+ # print(f"[VIDEO] Playing URL: {url}")
+
+ # popup = QtWidgets.QDialog(self)
+ # popup.setWindowTitle("Incident Video Playback")
+ # popup.setMinimumSize(1280, 720)
+ # vbox = QtWidgets.QVBoxLayout(popup)
+
+ # player = QtWidgets.QFrame()
+ # player.setStyleSheet("background:black;border-radius:8px;")
+ # vbox.addWidget(player, 1)
+
+ # # --- VLC instance with MP4-friendly options ---
+ # inst = vlc.Instance([
+ # "--quiet",
+ # "--no-video-title-show",
+ # "--demux=avformat", # important for MP4
+ # "--network-caching=800",
+ # "--file-caching=800",
+ # "--avcodec-hw=none", # safer decoding
+ # ])
+
+ # mp = inst.media_player_new()
+
+ # # --- Media object with same options ---
+ # media = inst.media_new(url)
+ # media.add_option(":no-audio")
+ # media.add_option(":network-caching=800")
+ # media.add_option(":file-caching=800")
+ # media.add_option(":demux=avformat")
+
+ # mp.set_media(media)
+
+ # popup.show()
+ # if sys.platform.startswith("win"):
+ # mp.set_hwnd(int(player.winId()))
+ # else:
+ # mp.set_xwindow(int(player.winId()))
+ # mp.play()
+
+ # print("[VIDEO] Playback started.")
+
def _send_feedback(self, alert: dict, is_real: bool):
@@ -784,4 +854,4 @@ def _send_feedback(self, alert: dict, is_real: bool):
print("[FEEDBACK][ERROR]", e)
QtWidgets.QMessageBox.warning(self, "Feedback Error", f"Failed to update feedback:\n{e}")
-
\ No newline at end of file
+
diff --git a/GUI/src/vast/views/security/incident_player_vlc.py b/GUI/src/vast/views/security/incident_player_vlc.py
index 8f4bf1b4b..7c9efe9b9 100644
--- a/GUI/src/vast/views/security/incident_player_vlc.py
+++ b/GUI/src/vast/views/security/incident_player_vlc.py
@@ -8,7 +8,12 @@
tail segments in /live.m3u8 so VLC never requests unavailable parts.
(Decay back to normal once stable.)
- No DVR freeze on resolve (removed items disappear; playback stops/advances).
+- DVR seek/scrub still works *after* incident is marked resolved, using the
+ segments already buffered in memory.
- No-cache headers on HLS endpoints.
+- Smoother stream:
+ * DVR poll interval tightened (REFRESH_MS default 300 ms).
+ * VLC network caching default increased (800 ms).
"""
from __future__ import annotations
@@ -21,14 +26,16 @@
from PyQt6.QtCore import Qt, QUrl, QTimer
from PyQt6.QtWebSockets import QWebSocket
from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest
+
import vlc # python-vlc
+
from aiohttp import web, ClientSession
-from vast.views.security.events_history_page import EventsHistoryPage
+from aiohttp.client_exceptions import ClientConnectionError, ClientPayloadError
+from vast.views.security.events_history_page import EventsHistoryPage
from src.vast.views.security.analytics.analytics_page import GeoAnalyticsView
-
# ──────────────────────────────────────────────────────────────────────────────
# Config
# ──────────────────────────────────────────────────────────────────────────────
@@ -40,19 +47,22 @@ class Config:
PORT = int(os.getenv("PORT", "19100"))
# Poll upstream playlist ~2–4x per segment (1.0s segments -> 300ms is good)
- REFRESH_MS = int(os.getenv("REFRESH_MS", "20000"))
+ # ↓ Previously 20000 ms, which caused "segment → pause → segment" behaviour in DVR.
+ REFRESH_MS = int(os.getenv("REFRESH_MS", "300"))
# Show this many segments in the live window…
LIVE_EDGE_SEGMENTS = int(os.getenv("LIVE_EDGE_SEGMENTS", "3"))
# …but hide the freshest N (stay behind live edge to avoid stalls)
LIVE_LAG_SEGMENTS = int(os.getenv("LIVE_LAG_SEGMENTS", "1"))
- # VLC network caching (ms)
- NETWORK_CACHING = int(os.getenv("NETWORK_CACHING", "320"))
+ # VLC network caching (ms) — slightly higher default for smoother playback
+ NETWORK_CACHING = int(os.getenv("NETWORK_CACHING", "800"))
ALERTS_WS = os.getenv("ALERTS_WS", "ws://host.docker.internal:8010/ws/alerts")
ALERTS_SNAPSHOT_HTTP = os.getenv("ALERTS_SNAPSHOT_HTTP", "")
- ALLOWED_TYPES = {"climbing_fence", "masked_person","intruding animal"}
+ ALLOWED_TYPES = {"climbing_fence", "masked_person", "intruding animal"}
+
+
# ──────────────────────────────────────────────────────────────────────────────
# Upstream fetcher + DVR state
# ──────────────────────────────────────────────────────────────────────────────
@@ -62,6 +72,7 @@ class Segment:
duration: float
abs_url: str # absolute URL to fetch
+
class DvrState:
def __init__(self, upstream_index_url: str, auth_token: str = "", refresh_ms: int = 800):
self.upstream_index_url = upstream_index_url
@@ -216,14 +227,85 @@ def render_dvr_vod_playlist(self, *, endlist: bool = False) -> Tuple[str, float]
return "\n".join(out) + "\n", float(total)
+
# ──────────────────────────────────────────────────────────────────────────────
# Aiohttp proxy app
# ──────────────────────────────────────────────────────────────────────────────
import socket
+
def is_port_in_use(port=19090, host="127.0.0.1"):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
return s.connect_ex((host, port)) == 0
+class VlcWidget(QtWidgets.QFrame):
+ positionChanged = QtCore.pyqtSignal(float)
+ timeChanged = QtCore.pyqtSignal(int)
+ ended = QtCore.pyqtSignal() # <-- final, real signal
+
+ def __init__(self, instance: vlc.Instance, parent=None):
+ super().__init__(parent)
+ self.instance = instance
+ self.mediaplayer = self.instance.media_player_new()
+ self.setMinimumSize(640, 360)
+
+ # Timer that emits position/time
+ self._timer = QtCore.QTimer(self)
+ self._timer.setInterval(200)
+ self._timer.timeout.connect(self._on_tick)
+ self._timer.start()
+
+ # Attach VLC "end reached" event
+ em = self.mediaplayer.event_manager()
+ em.event_attach(vlc.EventType.MediaPlayerEndReached, self._on_media_end)
+
+ def _on_media_end(self, _event):
+ print("[VLC] Media end reached")
+ # Emit Qt signal so IncidentPlayerVLC can react
+ self.ended.emit()
+
+ def _on_tick(self):
+ if self.mediaplayer:
+ try:
+ pos = self.mediaplayer.get_position()
+ t = self.mediaplayer.get_time()
+ if pos >= 0:
+ self.positionChanged.emit(pos)
+ if t >= 0:
+ self.timeChanged.emit(t)
+ except Exception:
+ pass
+
+ def set_media(self, mrl: str, options: Optional[List[str]] = None):
+ print(f"[VLC] set_media {mrl} opts={options or []}")
+ media = self.instance.media_new(mrl)
+ for opt in (options or []):
+ media.add_option(opt)
+ self.mediaplayer.set_media(media)
+
+ def play(self):
+ if sys.platform.startswith('linux'):
+ self.mediaplayer.set_xwindow(int(self.winId()))
+ elif sys.platform.startswith('win'):
+ self.mediaplayer.set_hwnd(int(self.winId()))
+ else:
+ self.mediaplayer.set_nsobject(int(self.winId()))
+ print("[VLC] play()")
+ self.mediaplayer.play()
+
+ def pause(self):
+ print("[VLC] pause()")
+ self.mediaplayer.pause()
+
+ def set_position(self, pos01: float):
+ p = max(0.0, min(1.0, float(pos01)))
+ print(f"[VLC] set_position {p:.3f}")
+ self.mediaplayer.set_position(p)
+
+ def set_time_ms(self, t_ms: int):
+ t = int(max(0, t_ms))
+ print(f"[VLC] set_time {t}ms")
+ self.mediaplayer.set_time(t)
+
class ProxyServer:
def __init__(self, media_base: str, camera: Optional[str], incident: Optional[str],
@@ -253,8 +335,6 @@ def __init__(self, media_base: str, camera: Optional[str], incident: Optional[st
self._app.router.add_get("/vod", self.handle_vod)
self._app.router.add_get("/img", self.handle_img)
-
-
# DEBUG routes
self._app.router.add_get('/debug/upstream', self.handle_debug_upstream)
self._app.router.add_get('/debug/dvr', self.handle_debug_dvr)
@@ -263,7 +343,7 @@ def __init__(self, media_base: str, camera: Optional[str], incident: Optional[st
self._runner: Optional[web.AppRunner] = None
self._thread: Optional[threading.Thread] = None
self._loop: Optional[asyncio.AbstractEventLoop] = None
-
+
async def handle_img(self, request):
img_url = request.query.get("u")
if not img_url:
@@ -278,11 +358,20 @@ async def handle_img(self, request):
async with session.get(img_url, headers=headers, timeout=15) as resp:
body = await resp.read()
ctype = resp.headers.get("Content-Type", "image/jpeg")
- return web.Response(body=body, content_type=ctype, status=resp.status, headers=self._nocache_headers())
+ return web.Response(
+ body=body,
+ content_type=ctype,
+ status=resp.status,
+ headers=self._nocache_headers(),
+ )
except Exception as e:
print(f"[HTTP] image fetch error: {e!r}")
- return web.Response(text=f"image fetch error: {type(e).__name__}: {e}", content_type="text/plain", status=502)
-
+ return web.Response(
+ text=f"image fetch error: {type(e).__name__}: {e}",
+ content_type="text/plain",
+ status=502,
+ headers=self._nocache_headers(),
+ )
# no-cache headers helper
def _nocache_headers(self) -> dict:
@@ -300,13 +389,14 @@ def get_durations_ms(self) -> Tuple[int, int]:
segs = list(self.dvr.segments)
total_ms = int(sum(s.duration for s in segs) * 1000)
edge = max(1, int(getattr(Config, "LIVE_EDGE_SEGMENTS", 3)))
- lag = max(0, int(getattr(Config, "LIVE_LAG_SEGMENTS", 0)))
+ lag = max(0, int(getattr(Config, "LIVE_LAG_SEGMENTS", 0)))
# Apply dynamic lag here too so UI stays coherent with playlist
lag += self._current_extra_lag()
keep = min(len(segs), max(1, edge + lag))
last = segs[-keep:] if keep <= len(segs) else segs
live_win_ms = int(sum(s.duration for s in last) * 1000)
return (total_ms, live_win_ms)
+
async def handle_vod(self, request):
vod_url = request.query.get("u")
if not vod_url:
@@ -336,8 +426,10 @@ async def handle_vod(self, request):
if "Content-Range" in resp.headers:
response_headers["Content-Range"] = resp.headers["Content-Range"]
- print(f"[HTTP] vod {resp.status} -> {vod_url} "
- f"({resp.headers.get('Content-Length', '?')} bytes, range={range_hdr})")
+ print(
+ f"[HTTP] vod {resp.status} -> {vod_url} "
+ f"({resp.headers.get('Content-Length', '?')} bytes, range={range_hdr})"
+ )
proxy_resp = web.StreamResponse(status=resp.status, headers=response_headers)
await proxy_resp.prepare(request)
@@ -345,14 +437,19 @@ async def handle_vod(self, request):
try:
async for chunk in resp.content.iter_chunked(8192):
await proxy_resp.write(chunk)
- await proxy_resp.write_eof()
- except (asyncio.CancelledError, ConnectionResetError, ClientConnectionError, ClientPayloadError) as e:
- # ✅ harmless — VLC moved to another range
+ except (asyncio.CancelledError,
+ ConnectionResetError,
+ ClientConnectionError,
+ ClientPayloadError) as e:
+ # Harmless — VLC moved to another range
print(f"[HTTP] client disconnected early ({type(e).__name__}) — OK")
except Exception as e:
print(f"[HTTP] stream write error: {type(e).__name__}: {e}")
finally:
- await proxy_resp.write_eof()
+ try:
+ await proxy_resp.write_eof()
+ except Exception:
+ pass
return proxy_resp
@@ -365,7 +462,6 @@ async def handle_vod(self, request):
headers=self._nocache_headers(),
)
-
# Dynamic lag amount based on recent 404s
def _current_extra_lag(self) -> int:
now = time.monotonic()
@@ -410,17 +506,35 @@ async def handle_debug_upstream(self, _request: web.Request):
body
]
print(f"[HTTP] debug_upstream {resp.status}")
- return web.Response(text="\n".join(out), content_type='text/plain', status=resp.status, headers=self._nocache_headers())
+ return web.Response(
+ text="\n".join(out),
+ content_type='text/plain',
+ status=resp.status,
+ headers=self._nocache_headers(),
+ )
except Exception as e:
- return web.Response(text=f"fetch error: {type(e).__name__}: {e}\n", content_type="text/plain", status=500, headers=self._nocache_headers())
+ return web.Response(
+ text=f"fetch error: {type(e).__name__}: {e}\n",
+ content_type="text/plain",
+ status=500,
+ headers=self._nocache_headers(),
+ )
async def handle_debug_dvr(self, _request: web.Request):
if not self.dvr:
- return web.Response(text="(no DVR yet)\n", content_type="text/plain", headers=self._nocache_headers())
+ return web.Response(
+ text="(no DVR yet)\n",
+ content_type="text/plain",
+ headers=self._nocache_headers(),
+ )
m3u8, total = self.dvr.render_dvr_vod_playlist(endlist=self.resolved)
hdr = f"# segment_count={len(self.dvr.segments)} total_duration_seconds={total:.3f} resolved={self.resolved}\n"
print(f"[HTTP] debug_dvr segments={len(self.dvr.segments)} total_s={total:.3f} endlist={self.resolved}")
- return web.Response(text=hdr + m3u8, content_type="text/plain", headers=self._nocache_headers())
+ return web.Response(
+ text=hdr + m3u8,
+ content_type="text/plain",
+ headers=self._nocache_headers(),
+ )
async def handle_debug_state(self, _request: web.Request):
info = {
@@ -438,7 +552,6 @@ async def handle_debug_state(self, _request: web.Request):
return web.json_response(info, headers=self._nocache_headers())
# URL helpers
-
def _rewrite_to_media_base(self, any_hls_url: str) -> str:
if not any_hls_url:
return any_hls_url
@@ -478,11 +591,15 @@ def _normalize_live_playlist(self, upstream_text: str, upstream_index_url: str)
while i < len(lines):
l = lines[i]
if l.startswith("#EXT-X-VERSION:"):
- try: version = int(l.split(":", 1)[1])
- except: pass
+ try:
+ version = int(l.split(":", 1)[1])
+ except Exception:
+ pass
elif l.startswith("#EXT-X-MEDIA-SEQUENCE:"):
- try: media_seq = int(l.split(":", 1)[1])
- except: media_seq = 0
+ try:
+ media_seq = int(l.split(":", 1)[1])
+ except Exception:
+ media_seq = 0
elif l.startswith("#EXT-X-MAP:"):
m = re.search(r'URI="([^"]+)"', l)
if m:
@@ -496,7 +613,8 @@ def _normalize_live_playlist(self, upstream_text: str, upstream_index_url: str)
attached = []
j = i + 1
while j < len(lines) and lines[j].startswith("#"):
- attached.append(lines[j]); j += 1
+ attached.append(lines[j])
+ j += 1
if j < len(lines):
uri = lines[j]
segments.append((dur, attached, uri))
@@ -508,7 +626,7 @@ def _normalize_live_playlist(self, upstream_text: str, upstream_index_url: str)
i += 1
base_edge = max(1, int(getattr(Config, "LIVE_EDGE_SEGMENTS", 3)))
- base_lag = max(0, int(getattr(Config, "LIVE_LAG_SEGMENTS", 0)))
+ base_lag = max(0, int(getattr(Config, "LIVE_LAG_SEGMENTS", 0)))
# Add dynamic lag derived from recent 404s
effective_lag = base_lag + self._current_extra_lag()
@@ -554,12 +672,6 @@ def switch_source(self, *, camera: Optional[str] = None,
self._last_seg_404_ts = 0.0
self._extra_lag_floor = 0
- # if upstream_hls:
- # self.upstream_index = self._rewrite_to_media_base(upstream_hls)
- # else:
- # if not (self.camera and self.incident):
- # return
- # self.upstream_index = f"{self.media_base}/hls/{self.camera}/{self.incident}/index.m3u8"
if upstream_hls:
# Always normalize, even if relative like "CAM-482A/incident-123/index.m3u8"
self.upstream_index = self._rewrite_to_media_base(upstream_hls)
@@ -569,8 +681,6 @@ def switch_source(self, *, camera: Optional[str] = None,
self.upstream_index = self._rewrite_to_media_base(rel_path)
else:
return
-
-
print(f"[SRC] switch to upstream={self.upstream_index}")
@@ -588,32 +698,70 @@ def _start():
self._loop.call_soon_threadsafe(_start)
def mark_resolved(self):
+ """Mark incident as resolved: stop polling upstream,
+ keep buffered segments for DVR scrubbing."""
if self.resolved:
return
self.resolved = True
if self.dvr:
try:
- self.dvr.stop()
+ self.dvr.stop() # stop adding more segments, keep existing
except Exception:
pass
- self.upstream_index = None
- print("[SRC] incident resolved; upstream disabled; no DVR freeze")
+ # Do NOT clear upstream_index or dvr here; DVR is still usable.
+ print("[SRC] incident resolved; upstream disabled; DVR segments kept for scrubbing")
# HTTP handlers
async def handle_root(self, _request: web.Request):
return web.Response(text='OK', content_type='text/plain', headers=self._nocache_headers())
async def handle_dvr(self, _request: web.Request):
- # No DVR freeze behavior
- return web.Response(text="#EXTM3U\n#EXT-X-ENDLIST\n", content_type='application/vnd.apple.mpegurl', status=410, headers=self._nocache_headers())
+ # No global DVR playlist anymore
+ return web.Response(
+ text="#EXTM3U\n#EXT-X-ENDLIST\n",
+ content_type='application/vnd.apple.mpegurl',
+ status=410,
+ headers=self._nocache_headers(),
+ )
async def handle_live(self, _request: web.Request):
- if self.resolved or not self.upstream_index:
- return web.Response(text="#EXTM3U\n#EXT-X-ENDLIST\n", content_type='application/vnd.apple.mpegurl', status=410, headers=self._nocache_headers())
+ # 1. After resolve: serve a static DVR playlist from buffered segments
+ if self.resolved:
+ if not self.dvr or not self.dvr.segments:
+ # No DVR buffer -> nothing to play
+ return web.Response(
+ text="#EXTM3U\n#EXT-X-ENDLIST\n",
+ content_type="application/vnd.apple.mpegurl",
+ status=410,
+ headers=self._nocache_headers(),
+ )
+
+ m3u8_body, total = self.dvr.render_dvr_vod_playlist(endlist=True)
+ print(
+ f"[HTTP] live.m3u8 (resolved) serving DVR snapshot: "
+ f"{len(self.dvr.segments)} segs, total={total:.3f}s"
+ )
+ return web.Response(
+ text=m3u8_body,
+ content_type="application/vnd.apple.mpegurl",
+ status=200,
+ headers=self._nocache_headers(),
+ )
+ # 2. No source yet -> nothing to serve
+ if not self.upstream_index:
+ return web.Response(
+ text="#EXTM3U\n#EXT-X-ENDLIST\n",
+ content_type="application/vnd.apple.mpegurl",
+ status=410,
+ headers=self._nocache_headers(),
+ )
+
+ # 3. Normal live mode (your existing logic...)
headers = {}
if self.token:
headers['Authorization'] = f'Bearer {self.token}'
+
try:
async with ClientSession() as session:
async with session.get(self.upstream_index, headers=headers, timeout=10) as resp:
@@ -622,14 +770,33 @@ async def handle_live(self, _request: web.Request):
print(f"[HTTP] live.m3u8 upstream {resp.status}")
if resp.status in (404, 410):
self.mark_resolved()
- return web.Response(text="#EXTM3U\n#EXT-X-ENDLIST\n", content_type='application/vnd.apple.mpegurl', status=410, headers=self._nocache_headers())
- return web.Response(text=f"# upstream {resp.status}\n{text}", content_type='text/plain', status=resp.status, headers=self._nocache_headers())
+ return web.Response(
+ text="#EXTM3U\n#EXT-X-ENDLIST\n",
+ content_type='application/vnd.apple.mpegurl',
+ status=410,
+ headers=self._nocache_headers(),
+ )
+ return web.Response(
+ text=f"# upstream {resp.status}\n{text}",
+ content_type='text/plain',
+ status=resp.status,
+ headers=self._nocache_headers(),
+ )
except Exception as e:
print(f"[HTTP] live.m3u8 fetch error: {e!r}")
- return web.Response(text=f"# fetch error: {type(e).__name__}: {e}\n", content_type='text/plain', status=502, headers=self._nocache_headers())
+ return web.Response(
+ text=f"# fetch error: {type(e).__name__}: {e}\n",
+ content_type='text/plain',
+ status=502,
+ headers=self._nocache_headers(),
+ )
text = self._normalize_live_playlist(text, self.upstream_index)
- return web.Response(text=text, content_type='application/vnd.apple.mpegurl', headers=self._nocache_headers())
+ return web.Response(
+ text=text,
+ content_type='application/vnd.apple.mpegurl',
+ headers=self._nocache_headers(),
+ )
async def handle_seg(self, request: web.Request):
url = request.query.get('u')
@@ -648,17 +815,30 @@ async def handle_seg(self, request: web.Request):
# On 404/410, bump lag so subsequent /live.m3u8 hides fresher segs
if status in (404, 410):
self._bump_extra_lag(floor_to=2)
- return web.Response(body=body, content_type=ctype, status=status, headers=self._nocache_headers())
+ return web.Response(
+ body=body,
+ content_type=ctype,
+ status=status,
+ headers=self._nocache_headers(),
+ )
except Exception as e:
print(f"[HTTP] seg fetch error: {e!r} <- {url}")
- return web.Response(text=f"segment fetch error: {type(e).__name__}: {e}", content_type="text/plain", status=502, headers=self._nocache_headers())
+ return web.Response(
+ text=f"segment fetch error: {type(e).__name__}: {e}",
+ content_type="text/plain",
+ status=502,
+ headers=self._nocache_headers(),
+ )
async def handle_dvr_seek(self, request: web.Request):
- if self.resolved or not self.dvr:
- return web.Response(text="#EXTM3U\n#EXT-X-ENDLIST\n",
- content_type='application/vnd.apple.mpegurl',
- status=410,
- headers=self._nocache_headers())
+ # IMPORTANT: allow DVR seek even when resolved, as long as we still have a DVR buffer.
+ if not self.dvr:
+ return web.Response(
+ text="#EXTM3U\n#EXT-X-ENDLIST\n",
+ content_type='application/vnd.apple.mpegurl',
+ status=410,
+ headers=self._nocache_headers(),
+ )
t_ms_str = request.query.get('t', '0')
try:
@@ -700,7 +880,6 @@ async def handle_dvr_seek(self, request: web.Request):
out.append(f'#EXT-X-MEDIA-SEQUENCE:{media_seq}')
# PRECISE intra-segment start (many players honor this; helps VLC too)
- # Start "intra_ms" seconds *into* the first segment of this playlist.
out.append(f'#EXT-X-START:TIME-OFFSET={intra_ms/1000.0:.3f},PRECISE=YES')
if init_url:
@@ -711,12 +890,16 @@ async def handle_dvr_seek(self, request: web.Request):
out.append(f'/seg?u={s.abs_url}')
body = "\n".join(out) + "\n"
- print(f"[HTTP] dvr_seek.m3u8 t={t_ms}ms -> start_idx={start_idx} intra={int(intra_ms)}ms segs={len(trimmed)} resolved={self.resolved}")
- # Optional debug header — handy to confirm behavior in logs/curl:
+ print(
+ f"[HTTP] dvr_seek.m3u8 t={t_ms}ms -> start_idx={start_idx} "
+ f"intra={int(intra_ms)}ms segs={len(trimmed)} resolved={self.resolved}"
+ )
headers = self._nocache_headers() | {"X-Start-Offset-Ms": str(int(intra_ms))}
- return web.Response(text=body,
- content_type='application/vnd.apple.mpegurl',
- headers=headers)
+ return web.Response(
+ text=body,
+ content_type='application/vnd.apple.mpegurl',
+ headers=headers,
+ )
# Lifecycle
def start(self):
@@ -734,8 +917,10 @@ def _run_loop():
finally:
loop.run_until_complete(self._runner.cleanup())
loop.stop()
- if is_port_in_use(19090):
- print("[INFO] DVR proxy already running on port 19090, reusing it.")
+
+ # Use the configured port, not a hardcoded one
+ if is_port_in_use(self.port, self.bind):
+ print(f"[INFO] DVR proxy already running on port {self.port}, reusing it.")
else:
self._thread = threading.Thread(target=_run_loop, daemon=True)
self._thread.start()
@@ -744,8 +929,9 @@ def stop(self):
if self.dvr:
self.dvr.stop()
+
# ──────────────────────────────────────────────────────────────────────────────
-# LEFT PANE + UI — unchanged except: no DVR freeze on resolve
+# LEFT PANE + UI — unchanged except: DVR seek allowed after resolve
# ──────────────────────────────────────────────────────────────────────────────
class AlertsModel(QtCore.QAbstractListModel):
def __init__(self):
@@ -787,11 +973,11 @@ def _key(self, it: dict) -> tuple[str, str]:
return (str(it.get("camera") or ""), str(it.get("incident_id") or ""))
def as_dict(self) -> dict[tuple[str, str], dict]:
- return { self._key(it): it for it in self._items }
+ return {self._key(it): it for it in self._items}
- def replace_with(self, merged: dict[tuple[str,str], dict]):
+ def replace_with(self, merged: dict[tuple[str, str], dict]):
self.set_alerts(list(merged.values()))
-
+
def remove_by_key(self, camera: str, incident_id: str):
k = (str(camera or ""), str(incident_id or ""))
for i, it in enumerate(self._items):
@@ -802,6 +988,7 @@ def remove_by_key(self, camera: str, incident_id: str):
return True
return False
+
class AlertItemDelegate(QtWidgets.QStyledItemDelegate):
def paint(self, painter: QtGui.QPainter, option: QtWidgets.QStyleOptionViewItem, index: QtCore.QModelIndex):
model: AlertsModel = index.model() # type: ignore
@@ -832,26 +1019,34 @@ def paint(self, painter: QtGui.QPainter, option: QtWidgets.QStyleOptionViewItem,
inc = str(a.get("incident_id") or "")[:8]
- title_font = QtGui.QFont(option.font); title_font.setPointSizeF(option.font.pointSizeF() + 1); title_font.setBold(True)
- sub_font = QtGui.QFont(option.font); sub_font.setPointSizeF(option.font.pointSizeF() - 1)
+ title_font = QtGui.QFont(option.font)
+ title_font.setPointSizeF(option.font.pointSizeF() + 1)
+ title_font.setBold(True)
+ sub_font = QtGui.QFont(option.font)
+ sub_font.setPointSizeF(option.font.pointSizeF() - 1)
painter.setPen(QtGui.QColor("#111827"))
painter.setFont(title_font)
- painter.drawText(QtCore.QRect(x, r.top() + 4, r.width() - 20, 18),
- QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter,
- f"{cam} • {anom}")
+ painter.drawText(
+ QtCore.QRect(x, r.top() + 4, r.width() - 20, 18),
+ QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter,
+ f"{cam} • {anom}",
+ )
painter.setPen(QtGui.QColor("#6b7280"))
painter.setFont(sub_font)
- painter.drawText(QtCore.QRect(x, r.top() + 22, r.width() - 20, 16),
- QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter,
- f"Incident: {inc}… • Status: {status}")
+ painter.drawText(
+ QtCore.QRect(x, r.top() + 22, r.width() - 20, 16),
+ QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter,
+ f"Incident: {inc}… • Status: {status}",
+ )
painter.restore()
def sizeHint(self, option: QtWidgets.QStyleOptionViewItem, _index: QtCore.QModelIndex) -> QtCore.QSize:
return QtCore.QSize(220, 42)
+
LEFT_LIST_QSS = """
QListView {
padding: 6px;
@@ -867,6 +1062,7 @@ def sizeHint(self, option: QtWidgets.QStyleOptionViewItem, _index: QtCore.QModel
#LeftHeader { color: #6b7280; font-weight: 600; letter-spacing: 0.4px; margin: 0 6px 6px 6px; }
"""
+
class SeekSlider(QtWidgets.QSlider):
hovered = QtCore.pyqtSignal(int)
clickedTo = QtCore.pyqtSignal(int)
@@ -877,7 +1073,7 @@ def __init__(self, orientation, parent=None):
self._press_x: Optional[float] = None
self._moved: bool = False
self._CLICK_EPS = 4.0
- self._EDGE_SNAP_PX = 8 # ← new: snap zone near the ends
+ self._EDGE_SNAP_PX = 8 # snap zone near the ends
def mousePressEvent(self, ev: QtGui.QMouseEvent):
if ev.button() == Qt.MouseButton.LeftButton:
@@ -920,12 +1116,12 @@ def _value_for_x(self, x: float) -> int:
QtWidgets.QStyle.ComplexControl.CC_Slider,
opt,
QtWidgets.QStyle.SubControl.SC_SliderGroove,
- self
+ self,
)
if groove.width() <= 0:
return self.value()
- # NEW: snap to exact min/max if you're near the ends
+ # snap to exact min/max if you're near the ends
if x <= groove.left() + self._EDGE_SNAP_PX:
return self.minimum()
if x >= groove.right() - self._EDGE_SNAP_PX:
@@ -949,65 +1145,10 @@ def __init__(self, vlc_widget: QtWidgets.QWidget, parent=None):
def show_loading(self, on: bool):
self.setCurrentIndex(1 if on else 0)
-class VlcWidget(QtWidgets.QFrame):
- positionChanged = QtCore.pyqtSignal(float)
- timeChanged = QtCore.pyqtSignal(int)
- def __init__(self, instance: vlc.Instance, parent=None):
- super().__init__(parent)
- self.instance = instance
- self.mediaplayer = self.instance.media_player_new()
- self.setMinimumSize(640, 360)
- self._timer = QtCore.QTimer(self)
- self._timer.setInterval(200)
- self._timer.timeout.connect(self._on_tick)
- self._timer.start()
-
- def _on_tick(self):
- if self.mediaplayer:
- try:
- pos = self.mediaplayer.get_position()
- t = self.mediaplayer.get_time()
- if pos >= 0:
- self.positionChanged.emit(pos)
- if t >= 0:
- self.timeChanged.emit(t)
- except Exception:
- pass
-
- def set_media(self, mrl: str, options: Optional[List[str]] = None):
- print(f"[VLC] set_media {mrl} opts={options or []}")
- media = self.instance.media_new(mrl)
- for opt in (options or []):
- media.add_option(opt)
- self.mediaplayer.set_media(media)
-
- def play(self):
- if sys.platform.startswith('linux'):
- self.mediaplayer.set_xwindow(int(self.winId()))
- elif sys.platform.startswith('win'):
- self.mediaplayer.set_hwnd(int(self.winId()))
- else:
- self.mediaplayer.set_nsobject(int(self.winId()))
- print("[VLC] play()")
- self.mediaplayer.play()
-
- def pause(self):
- print("[VLC] pause()")
- self.mediaplayer.pause()
-
- def set_position(self, pos01: float):
- p = max(0.0, min(1.0, float(pos01)))
- print(f"[VLC] set_position {p:.3f}")
- self.mediaplayer.set_position(p)
-
- def set_time_ms(self, t_ms: int):
- t = int(max(0, t_ms))
- print(f"[VLC] set_time {t}ms")
- self.mediaplayer.set_time(t)
class IncidentPlayerVLC(QtWidgets.QWidget):
- def __init__(self, api,alert_service, parent=None):
+ def __init__(self, api, alert_service, parent=None):
super().__init__(parent)
self.api = api
self.alert_service = alert_service
@@ -1061,8 +1202,10 @@ def __init__(self, api,alert_service, parent=None):
self.vlc_instance = vlc.Instance(*vlc_opts)
self.vlcw = VlcWidget(self.vlc_instance)
self.videoSurface = VideoSurface(self.vlcw)
- self.videoSurface.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding,
- QtWidgets.QSizePolicy.Policy.Expanding)
+ self.videoSurface.setSizePolicy(
+ QtWidgets.QSizePolicy.Policy.Expanding,
+ QtWidgets.QSizePolicy.Policy.Expanding,
+ )
# Controls
self.btnLive = QtWidgets.QPushButton('Go Live')
@@ -1077,8 +1220,10 @@ def __init__(self, api,alert_service, parent=None):
# LEFT PANE
leftContainer = QtWidgets.QGroupBox("Alerts")
- leftContainer.setSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed,
- QtWidgets.QSizePolicy.Policy.Expanding)
+ leftContainer.setSizePolicy(
+ QtWidgets.QSizePolicy.Policy.Fixed,
+ QtWidgets.QSizePolicy.Policy.Expanding,
+ )
leftContainer.setMinimumWidth(300)
leftContainer.setMaximumWidth(340)
@@ -1103,8 +1248,10 @@ def __init__(self, api,alert_service, parent=None):
# Details inside player pane
self.detailGroup = QtWidgets.QGroupBox("Details")
- self.detailGroup.setSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred,
- QtWidgets.QSizePolicy.Policy.Fixed)
+ self.detailGroup.setSizePolicy(
+ QtWidgets.QSizePolicy.Policy.Preferred,
+ QtWidgets.QSizePolicy.Policy.Fixed,
+ )
grid = QtWidgets.QGridLayout(self.detailGroup)
grid.setContentsMargins(12, 8, 12, 12)
grid.setHorizontalSpacing(24)
@@ -1176,7 +1323,6 @@ def __init__(self, api,alert_service, parent=None):
rightLayout.addLayout(ctrls, 0)
rightLayout.addWidget(self.detailGroup, 0)
-
self.rightStack.addWidget(self.emptyPane)
self.rightStack.addWidget(self.playerPane)
self.rightStack.setCurrentIndex(0)
@@ -1193,60 +1339,15 @@ def __init__(self, api,alert_service, parent=None):
outer = QtWidgets.QVBoxLayout(self)
outer.setContentsMargins(6, 6, 6, 6)
outer.setSpacing(6)
- # outer.addWidget(splitter)
- # --- Navigation bar ---
- navBar = QtWidgets.QHBoxLayout()
- navBar.setContentsMargins(6, 6, 6, 6)
- navBar.setSpacing(8)
-
- btnLiveView = QtWidgets.QPushButton("Live Incidents")
- btnLiveView.setCheckable(True)
- btnLiveView.setChecked(True)
- btnHistory = QtWidgets.QPushButton("Events History")
- btnHistory.setCheckable(True)
- btnGeo = QtWidgets.QPushButton("Analytics")
- btnGeo.setCheckable(True)
-
- btnStyle = """
- QPushButton {
- background:#e5e7eb; border:none; border-radius:8px;
- padding:6px 12px; font-weight:600;
- }
- QPushButton:checked { background:#10b981; color:white; }
- """
- btnLiveView.setStyleSheet(btnStyle)
- btnHistory.setStyleSheet(btnStyle)
- btnGeo.setStyleSheet(btnStyle)
-
- navBar.addWidget(btnLiveView)
- navBar.addWidget(btnHistory)
- navBar.addWidget(btnGeo)
- navBar.addStretch(1)
-
-
- # --- Main content stack ---
- self.stack = QtWidgets.QStackedWidget()
- self.livePage = QtWidgets.QWidget()
- self.liveLayout = QtWidgets.QVBoxLayout(self.livePage)
- self.liveLayout.setContentsMargins(0, 0, 0, 0)
- self.liveLayout.addWidget(splitter)
- self.historyPage = EventsHistoryPage(api=self.api, parent=self)
- self.analyticsPage = GeoAnalyticsView(parent=self)
-
- self.stack.addWidget(self.livePage)
- self.stack.addWidget(self.historyPage)
- self.stack.addWidget(self.analyticsPage)
-
- # --- Combine all together ---
- outer.addLayout(navBar)
- outer.addWidget(self.stack)
-
- # --- Navigation logic ---
- btnLiveView.clicked.connect(lambda: self._switch_page(0, btnLiveView, btnHistory, btnGeo))
- btnHistory.clicked.connect(lambda: self._switch_page(1, btnHistory, btnLiveView, btnGeo))
- btnGeo.clicked.connect(lambda: self._switch_page(2, btnGeo, btnLiveView, btnHistory))
+ # Live page only; history/analytics in external navigation
+ livePage = QtWidgets.QWidget()
+ liveLayout = QtWidgets.QVBoxLayout(livePage)
+ liveLayout.setContentsMargins(0, 0, 0, 0)
+ liveLayout.setSpacing(0)
+ liveLayout.addWidget(splitter)
+ outer.addWidget(livePage)
# State
self.mode_live = False
@@ -1255,6 +1356,8 @@ def __init__(self, api,alert_service, parent=None):
self.current_camera: Optional[str] = None
self.current_incident: Optional[str] = None
self.current_status: str = "firing"
+ self.current_alert: Optional[dict] = None
+
self._last_abs_t_ms: int = 0
self._playlist_offset_ms: int = 0
@@ -1266,7 +1369,7 @@ def __init__(self, api,alert_service, parent=None):
self._live_sync.setInterval(800)
self._live_sync.timeout.connect(self._maybe_sync_live_timeline)
- #new
+ # grows DVR slider while paused/seeked
self._dvr_growth = QTimer(self)
self._dvr_growth.setInterval(1200)
self._dvr_growth.timeout.connect(self._maybe_grow_dvr_range_only)
@@ -1284,7 +1387,6 @@ def __init__(self, api,alert_service, parent=None):
print("[IncidentPlayer] Using cached alerts:", len(self.alert_service.alerts))
self._on_alerts_updated(self.alert_service.alerts)
-
# Connections
self.btnLive.clicked.connect(self._go_live)
self.slider.hovered.connect(self._on_slider_hover)
@@ -1292,16 +1394,58 @@ def __init__(self, api,alert_service, parent=None):
self.slider.draggedTo.connect(self._on_slider_drag_released)
self.vlcw.positionChanged.connect(self._on_vlc_pos)
self.vlcw.timeChanged.connect(self._on_vlc_time)
+ self.vlcw.ended.connect(self._on_vlc_end)
self.alertList.clicked.connect(self._on_pick_alert_from_list)
self._show_player(False)
self._set_idle()
-
+ def _on_vlc_end(self):
+ print("[VLC] end reached (proxy.resolved=%s, alerts_empty=%s)"
+ % (self.proxy.resolved, self.alertModel.is_empty()))
+
+ # If incident is resolved and there are no *firing* alerts left,
+ # we can fully tear down the player and hide the panels.
+ if self.proxy.resolved:
+ have_firing = any(
+ (a.get("status") or "firing").lower() == "firing"
+ for a in self.alertModel._items
+ )
+
+ if not have_firing:
+ # Stop VLC
+ try:
+ self.vlcw.mediaplayer.stop()
+ except Exception:
+ pass
+
+ # Optionally drop the current resolved incident from the list,
+ # so the left list is also "clean".
+ if self.current_camera and self.current_incident:
+ self.alertModel.remove_by_key(self.current_camera,
+ self.current_incident)
+
+ # Clear current state
+ self.current_camera = None
+ self.current_incident = None
+ self.current_alert = None
+ self.dvr_duration_ms = 0
+
+ # Reset UI + hide player
+ self._set_idle()
+ self._show_player(False) # switch to NO-ALERTS pane
+ print("[MODE] DVR clip finished; no firing alerts → hiding player")
+ return
+
+ # Case 2: still have firing alerts or proxy not resolved — normal DVR mode
+ self.mode_live = False
+ self._set_live_badge(False)
+ print("[MODE] clip finished; staying in DVR mode")
+
+
def showEvent(self, event):
super().showEvent(event)
- # Only run when *actually* visible in the QStackedWidget
if not self.isVisible():
return
@@ -1318,11 +1462,9 @@ def showEvent(self, event):
self._show_player(True)
self._play_alert(self.alertModel._items[0])
-
def hideEvent(self, event):
super().hideEvent(event)
- # Only deactivate once
if self._is_current_page:
self._is_current_page = False
self.allow_autoplay = False
@@ -1331,7 +1473,7 @@ def hideEvent(self, event):
# Stop VLC safely
try:
self.vlcw.mediaplayer.stop()
- except:
+ except Exception:
pass
# Hide video surface to stop painting
@@ -1339,12 +1481,9 @@ def hideEvent(self, event):
self._set_idle()
-
-
def _on_alerts_updated(self, alerts: list):
"""Called when AlertService emits full list (on initial load)."""
print(f"[AlertService] Full update: {len(alerts)} alerts")
- # print("[DEBUG] alerts from AlertService:", alerts)
self._apply_firing_list(alerts)
def _on_alert_added(self, alert: dict):
@@ -1353,14 +1492,44 @@ def _on_alert_added(self, alert: dict):
self._merge_firing_deltas([alert])
def _on_alert_removed(self, alert_id: str):
- """Called when an alert is resolved/removed."""
print(f"[AlertService] Alert removed: {alert_id}")
- self.alertModel.set_alerts([
- a for a in self.alertModel._items if a.get("alert_id") != alert_id
- ])
+
+ is_current = (self.current_incident == alert_id)
+
+ new_items: list[dict] = []
+ for a in self.alertModel._items:
+ if a.get("incident_id") != alert_id:
+ new_items.append(a)
+ else:
+ # This is the removed alert
+ if is_current and self.proxy.dvr and len(self.proxy.dvr.segments) > 0:
+ updated = dict(a)
+ updated["status"] = "resolved"
+ new_items.append(updated)
+ # else: drop it completely
+
+ self.alertModel.set_alerts(new_items)
+
+ if is_current:
+ self.current_status = "resolved"
+ # Update cached alert metadata if we still have it
+ self.current_alert = next(
+ (a for a in new_items if a.get("incident_id") == alert_id),
+ self.current_alert,
+ )
+ try:
+ self.proxy.mark_resolved()
+ except Exception as e:
+ print(f"[IncidentPlayer] mark_resolved failed: {e!r}")
+
+ # UI: DVR-only mode
+ self.mode_live = False
+ self._set_live_badge(False)
+
self._update_right_pane_visibility()
+
def _fetch_active_alerts_from_db(self):
"""Fetch current active alerts directly from the DB API."""
try:
@@ -1379,16 +1548,30 @@ def _fetch_active_alerts_from_db(self):
print(f"[DB] Error fetching alerts: {e}")
return []
-
# ───── NO-ALERTS helpers ─────
def _show_player(self, on: bool):
self.rightStack.setCurrentIndex(1 if on else 0)
print(f"[UI] right pane -> {'PLAYER' if on else 'NO-ALERTS'}")
def _update_right_pane_visibility(self):
- have_any = not self.alertModel.is_empty()
- print("_update_right_pane_visibility called have any",have_any)
- if not have_any:
+ have_any_list = not self.alertModel.is_empty()
+
+ # We also keep the player visible if we have a "current" incident
+ # (even if it is resolved) and we still have a DVR buffer to scrub.
+ have_current_dvr = (
+ self.current_camera is not None
+ and self.current_incident is not None
+ and self.proxy.dvr is not None
+ and len(self.proxy.dvr.segments) > 0
+ )
+
+ print(
+ "_update_right_pane_visibility called: "
+ f"have_any_list={have_any_list}, have_current_dvr={have_current_dvr}"
+ )
+
+ if not have_any_list and not have_current_dvr:
+ # Nothing in the list and nothing to scrub → fully idle.
try:
self.vlcw.mediaplayer.stop()
except Exception:
@@ -1396,50 +1579,43 @@ def _update_right_pane_visibility(self):
self._set_idle()
self._show_player(False)
else:
+ # Either we have firing alerts or a resolved incident with DVR buffer.
self._show_player(True)
-
- def _switch_page(self, index: int, active_btn: QtWidgets.QPushButton, *others: QtWidgets.QPushButton):
- self.stack.setCurrentIndex(index)
- active_btn.setChecked(True)
- for b in others:
- b.setChecked(False)
- print(f"[UI] switched to page index={index}")
# ───── alerts helpers ─────
def _key(self, it: dict) -> tuple[str, str]:
return (str(it.get('camera') or ''), str(it.get('incident_id') or ''))
-
def _normalize_alert(self, it: dict) -> dict:
meta = it.get("meta") or {}
if isinstance(meta, str):
- import json
try:
meta = json.loads(meta)
except Exception:
meta = {}
+ ended = (
+ it.get("ended_at") or
+ it.get("endsAt") or
+ it.get("endedAt")
+ )
return {
"camera": it.get("device_id") or it.get("camera"),
"incident_id": it.get("alert_id") or it.get("incident_id"),
"anomaly": it.get("alert_type") or it.get("anomaly"),
- "subject": it.get("subject") or meta.get("subject"), # 👈 added line
+ "subject": it.get("subject") or meta.get("subject"),
"hls": it.get("hls"),
"vod": it.get("vod"),
"image_url": it.get("image_url"),
"summary": it.get("summary"),
"severity": it.get("severity"),
"started_at": it.get("started_at") or it.get("startsAt"),
- "ended_at": it.get("ended_at") or it.get("endsAt"),
- "status": "firing" if not (it.get("ended_at") or it.get("endsAt")) else "resolved",
+ "ended_at": ended,
+ "status": "resolved" if ended else "firing",
}
-
-
-
- ##new
+ # Only expand DVR while paused/seeked (DVR mode). Never move the thumb.
def _maybe_grow_dvr_range_only(self):
- # Only expand the slider max while paused/seeked (DVR mode). Never move the thumb.
if self.mode_live:
self._dvr_growth.stop()
return
@@ -1450,19 +1626,17 @@ def _maybe_grow_dvr_range_only(self):
self.dvr_duration_ms = new_max
self.slider.setRange(0, self.dvr_duration_ms)
-
def _apply_firing_list(self, firing: list[dict]):
firing = [self._normalize_alert(it) for it in (firing or []) if it]
- # print("[DEBUG] normalized firing list:", firing)
-
- # ⬇️ Only keep desired alert types
+ # only keep desired alert types that are still firing
firing = [
it for it in firing
if (it.get("status") or "firing").lower() == "firing"
and (it.get("anomaly") or "").lower() in self.cfg.ALLOWED_TYPES
]
+ # Track currently selected item (optional; same as before)
sel = self.alertList.selectionModel().currentIndex() if self.alertList.selectionModel() else QtCore.QModelIndex()
selected_inc = selected_cam = None
if sel.isValid():
@@ -1473,43 +1647,50 @@ def _apply_firing_list(self, firing: list[dict]):
except Exception:
pass
- self.alertModel.set_alerts(firing)
- self._update_right_pane_visibility()
-
- if selected_inc is not None:
- for row, it in enumerate(firing):
- if it.get('incident_id') == selected_inc and it.get('camera') == selected_cam:
- idx = self.alertModel.index(row, 0)
- self.alertList.selectionModel().select(idx, QtCore.QItemSelectionModel.SelectionFlag.ClearAndSelect)
- self.alertList.setCurrentIndex(idx)
- break
-
cur_cam = self.current_camera
cur_inc = self.current_incident or self.cfg.INCIDENT
+ has_current = bool(cur_cam and cur_inc)
+
still_there = any(
- it.get('camera') == cur_cam and it.get('incident_id') == cur_inc
+ it.get('camera') == cur_cam and
+ (it.get('incident_id') or it.get('alert_id')) == cur_inc
for it in firing
- ) if (cur_cam and cur_inc) else False
+ ) if has_current else False
+
+ effective_list = list(firing)
- if (cur_cam and cur_inc) and not still_there:
- if self.current_camera and self.current_incident:
- self.alertModel.remove_by_key(self.current_camera, self.current_incident)
+ # Case: the current incident is no longer "firing" in the upstream list → resolved
+ if has_current and not still_there:
self.current_status = "resolved"
- self.proxy.mark_resolved()
try:
- self.vlcw.mediaplayer.stop()
- except Exception:
- pass
-
- if firing:
- self._show_player(True)
- self._play_alert(firing[0])
- else:
- self._set_idle()
- self._show_player(False)
+ self.proxy.mark_resolved()
+ except Exception as e:
+ print(f"[IncidentPlayer] mark_resolved failed in _apply_firing_list: {e!r}")
+
+ # Keep it in the left list while we still have DVR segments
+ if self.proxy.dvr is not None and len(self.proxy.dvr.segments) > 0:
+ base = dict(self.current_alert or {})
+ base.setdefault("camera", cur_cam)
+ base.setdefault("incident_id", cur_inc)
+ base["status"] = "resolved"
+ if (base.get("anomaly") or "").lower() in self.cfg.ALLOWED_TYPES:
+ key = (base.get("camera"), base.get("incident_id"))
+ if not any(
+ it.get("camera") == key[0] and it.get("incident_id") == key[1]
+ for it in effective_list
+ ):
+ effective_list.append(base)
+
+ self.alertModel.set_alerts(effective_list)
+ self._update_right_pane_visibility()
return
- if firing and not still_there:
+ # Normal case: just "firing" alerts + (possibly) already kept resolved ones
+ self.alertModel.set_alerts(effective_list)
+ self._update_right_pane_visibility()
+
+ # Autoplay only when there was NO current incident at all (e.g., first load)
+ if firing and not has_current:
if self.allow_autoplay:
self._show_player(True)
self._play_alert(firing[0])
@@ -1517,41 +1698,53 @@ def _apply_firing_list(self, firing: list[dict]):
print("[IncidentPlayer] Ignored new alert (page not visible)")
-
def _merge_firing_deltas(self, deltas: list[dict]):
current = self.alertModel.as_dict()
changed = False
-
-
for raw in (deltas or []):
it = self._normalize_alert(raw)
if (it.get("anomaly") or "").lower() not in self.cfg.ALLOWED_TYPES:
- continue # ⬅️ skip other alert types
+ continue # skip other alert types
+
k = self._key(it)
+ status = (it.get("status") or "firing").lower()
- if it.get('status') == 'firing':
+ if status == 'firing':
if current.get(k) != it:
current[k] = it
changed = True
else:
- if k in current:
- current.pop(k, None)
+ # resolved / non-firing
+ keep_on_left = (
+ (self.current_camera, self.current_incident) == k
+ and self.proxy.dvr is not None
+ and len(self.proxy.dvr.segments) > 0
+ )
+ if keep_on_left:
+ stored = dict(current.get(k, it))
+ stored.update(it)
+ stored["status"] = "resolved"
+ current[k] = stored
changed = True
+ else:
+ if k in current:
+ current.pop(k, None)
+ changed = True
- if (self.current_camera, self.current_incident) == k and it.get('status') != 'firing':
+ if (self.current_camera, self.current_incident) == k and status != 'firing':
+ # Current incident resolved: stop polling upstream,
+ # but keep DVR and keep it in the list so UI doesn't "blink out".
self.current_status = "resolved"
+ self.current_alert = it
self.proxy.mark_resolved()
- if self.current_camera and self.current_incident:
- self.alertModel.remove_by_key(self.current_camera, self.current_incident)
- try:
- self.vlcw.mediaplayer.stop()
- except Exception:
- pass
+ print("[IncidentPlayer] Current incident resolved via delta; DVR-only mode")
+ # Do NOT remove from model and do NOT stop the mediaplayer here.
if not changed:
return
+ # Same selection-restore logic as before
sel = self.alertList.selectionModel().currentIndex() if self.alertList.selectionModel() else QtCore.QModelIndex()
selected_key = None
if sel.isValid():
@@ -1559,23 +1752,37 @@ def _merge_firing_deltas(self, deltas: list[dict]):
cur = self.alertModel.get(sel.row())
selected_key = self._key(cur)
except Exception:
- pass
+ selected_key = None
self.alertModel.replace_with(current)
- self._update_right_pane_visibility()
-
- if selected_key:
- items = list(current.values())
- for row, it in enumerate(items):
- if self._key(it) == selected_key:
- idx = self.alertModel.index(row, 0)
- self.alertList.selectionModel().select(idx, QtCore.QItemSelectionModel.SelectionFlag.ClearAndSelect)
- self.alertList.setCurrentIndex(idx)
- break
cur_cam = self.current_camera
cur_inc = self.current_incident or self.cfg.INCIDENT
has_current = (cur_cam and cur_inc and (cur_cam, cur_inc) in current)
+
+ # Restore selection for any remaining alert (same as before)
+ if self.alertList.selectionModel() and self.alertList.selectionModel().currentIndex().isValid():
+ selected_key = None
+ try:
+ cur = self.alertModel.get(self.alertList.selectionModel().currentIndex().row())
+ selected_key = self._key(cur)
+ except Exception:
+ selected_key = None
+
+ if selected_key:
+ items = list(current.values())
+ for row, it in enumerate(items):
+ if self._key(it) == selected_key:
+ idx = self.alertModel.index(row, 0)
+ self.alertList.selectionModel().select(
+ idx, QtCore.QItemSelectionModel.SelectionFlag.ClearAndSelect
+ )
+ self.alertList.setCurrentIndex(idx)
+ break
+
+ # Now decide visibility using the helper (which also checks DVR)
+ self._update_right_pane_visibility()
+
if not has_current:
items = list(current.values())
if items:
@@ -1592,14 +1799,7 @@ def _merge_firing_deltas(self, deltas: list[dict]):
self._set_idle()
self._show_player(False)
- # if items:
- # self._show_player(True)
- # self._play_alert(items[0])
- # else:
- # try: self.vlcw.mediaplayer.stop()
- # except Exception: pass
- # self._set_idle()
- # self._show_player(False)
+
# ───── helpers ─────
def _freeze_ui(self, seconds: float = 0.8):
@@ -1643,6 +1843,11 @@ def _on_pick_alert_from_list(self, idx: QtCore.QModelIndex):
self._play_alert(it)
def _play_alert(self, it: dict):
+ # Remember the previous "current" before we switch
+ old_cam = self.current_camera
+ old_inc = self.current_incident
+ old_status = getattr(self, "current_status", "firing")
+
# Normalize to ensure keys like 'camera', 'anomaly', 'incident_id', 'started_at' exist
it = self._normalize_alert(it)
@@ -1650,9 +1855,15 @@ def _play_alert(self, it: dict):
inc = it.get('incident_id') or self.cfg.INCIDENT
hls_url = it.get('hls') or None
+ # If we are leaving a resolved incident that we were keeping only
+ # for DVR scrubbing, remove it from the list now (no video anymore).
+ if old_cam and old_inc and old_status == "resolved":
+ self.alertModel.remove_by_key(old_cam, old_inc)
+
self.current_camera = cam
self.current_incident = inc
self.current_status = (it.get('status') or 'firing').lower()
+ self.current_alert = it # keep the latest metadata for this incident
self.proxy.switch_source(camera=cam, incident=inc, upstream_hls=hls_url)
self.setWindowTitle("AgGuard — Live Incidents")
@@ -1665,7 +1876,7 @@ def _play_alert(self, it: dict):
def _update_details(self, it: dict):
subject = it.get("subject")
anom = it.get("anomaly") or "–"
- if anom.lower() in ("intruding animal", "intruding_animal","climbing_fence") and subject:
+ if anom.lower() in ("intruding animal", "intruding_animal", "climbing_fence") and subject:
anom = f"{anom.title()} ({subject.title()})"
vals = [
@@ -1678,7 +1889,6 @@ def _update_details(self, it: dict):
for lbl, v in zip(self.lblVals, vals):
lbl.setText(v)
-
# ───── slider / playback helpers ─────
def _fmt(self, ms: int) -> str:
s = max(0, ms // 1000)
@@ -1691,10 +1901,16 @@ def _fmt(self, ms: int) -> str:
def _set_live_badge(self, live: bool):
if live:
self.liveBadge.setText("LIVE")
- self.liveBadge.setStyleSheet("background:#10b981; color:white; padding:3px 8px; border-radius:12px; font-weight:700;")
+ self.liveBadge.setStyleSheet(
+ "background:#10b981; color:white; padding:3px 8px; "
+ "border-radius:12px; font-weight:700;"
+ )
else:
self.liveBadge.setText("DVR")
- self.liveBadge.setStyleSheet("background:#9ca3af; color:white; padding:3px 8px; border-radius:12px; font-weight:700;")
+ self.liveBadge.setStyleSheet(
+ "background:#9ca3af; color:white; padding:3px 8px; "
+ "border-radius:12px; font-weight:700;"
+ )
def _set_idle(self):
self.mode_live = False
@@ -1709,15 +1925,24 @@ def _set_idle(self):
print("[MODE] IDLE")
def _go_live(self):
+ # If the proxy is already resolved, there is no live stream any more.
+ # We keep DVR scrubbing only – do not try to attach /live.m3u8.
+ if self.proxy.resolved:
+ print("[MODE] proxy resolved; live disabled, staying in DVR-only mode")
+ self.mode_live = False
+ self._set_live_badge(False)
+ return
+
# resume live sync; stop DVR growth
if not self._live_sync.isActive():
self._live_sync.start()
self._dvr_growth.stop()
- if self.current_status == "resolved":
- print("[MODE] resolved; not going live")
- self._set_idle()
- return
+ # ❌ OLD: blocked when current_status == "resolved"
+ # if self.current_status == "resolved":
+ # print("[MODE] resolved; not going live")
+ # self._set_idle()
+ # return
if not self.proxy.upstream_index:
return
@@ -1791,8 +2016,10 @@ def _seek_via_playlist(self, t_ms: int):
if not self._dvr_growth.isActive():
self._dvr_growth.start()
- if self.current_status == "resolved" or not self.proxy.dvr:
- print("[SEEK] ignored (no DVR while resolved)")
+ # IMPORTANT: allow seeking even when current_status == "resolved".
+ # Only block if there is no DVR.
+ if not self.proxy.dvr:
+ print("[SEEK] ignored (no DVR)")
return
t_ms = max(0, min(int(t_ms), max(0, self.dvr_duration_ms)))
@@ -1867,12 +2094,10 @@ def _update_time_label(self, t_ms: int):
m, s = divmod(s, 60)
txt = f"{h:d}:{m:02d}:{s:02d}" if h else f"{m:d}:{s:02d}"
self.timeLeft.setText(txt)
-
+
def closeEvent(self, event: QtGui.QCloseEvent):
try:
self.proxy.stop()
except Exception:
pass
super().closeEvent(event)
-
-
diff --git a/RelDB/build_tables/schema.sql b/RelDB/build_tables/schema.sql
index 0427beecb..b6464b643 100644
--- a/RelDB/build_tables/schema.sql
+++ b/RelDB/build_tables/schema.sql
@@ -842,3 +842,42 @@ 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 (lat, lon);
+CREATE TABLE IF NOT EXISTS sensor_embeddings (
+ id BIGSERIAL PRIMARY KEY,
+ sensor_id BIGINT NOT NULL,
+ vec vector(5),
+ created_at TIMESTAMP DEFAULT NOW()
+);
+CREATE OR REPLACE FUNCTION generate_sensor_embedding()
+RETURNS trigger AS $$
+DECLARE
+ name_len INT;
+ type_len INT;
+ status_score FLOAT;
+ vec vector(5);
+BEGIN
+ name_len := COALESCE(LENGTH(NEW.sensor_name), 0);
+ type_len := COALESCE(LENGTH(NEW.sensor_type), 0);
+ status_score := CASE WHEN LOWER(COALESCE(NEW.status, '')) = 'active' THEN 1 ELSE 0 END;
+
+ vec := ARRAY[
+ COALESCE(NEW.lat, 0),
+ COALESCE(NEW.lon, 0),
+ name_len,
+ type_len,
+ status_score
+ ]::vector;
+
+ DELETE FROM sensor_embeddings WHERE sensor_id = NEW.sensor_id;
+
+ INSERT INTO sensor_embeddings(sensor_id, vec)
+ VALUES (NEW.sensor_id, vec);
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trg_generate_embedding
+AFTER INSERT OR UPDATE ON sensors
+FOR EACH ROW
+EXECUTE FUNCTION generate_sensor_embedding();
diff --git a/docker-compose.yml b/docker-compose.yml
index 8bf55b979..87cd954c8 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1473,6 +1473,7 @@ services:
- ag_cloud
+
vector_service:
build: ./services/vector_service
container_name: vector_service
@@ -1483,7 +1484,7 @@ services:
- DB_PASS=pg123
- DB_NAME=missions_db
ports:
- - "8005:8000"
+ - "8006:8000"
depends_on:
- postgres
networks:
diff --git a/services/flink_writer_db/app.py b/services/flink_writer_db/app.py
index 30c4834ab..ff13cf371 100644
--- a/services/flink_writer_db/app.py
+++ b/services/flink_writer_db/app.py
@@ -1,4 +1,4 @@
-import os, json, pathlib, requests
+import os, json, pathlib, requests, queue, threading
from pyflink.common import Types
from pyflink.datastream import StreamExecutionEnvironment
from pyflink.datastream.connectors.kafka import KafkaSource
@@ -8,21 +8,26 @@
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
-# ---------- ENV ----------
+# ------------------------- ENV -------------------------
+
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_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", "flink-writer-db")
DUMMY_DB = int(os.getenv("DUMMY_DB", "0")) == 1
KAFKA_BROKERS = os.getenv("KAFKA_BROKERS", "kafka:9092")
-TOPICS = [t.strip() for t in os.getenv("TOPICS", "sensor_anomalies,alerts,image_new_aerial_connections,sound_new_sounds_connections,sound_new_plants_connections,sounds_metadata,sounds_ultra_metadata").split(",") if t.strip()]
+TOPICS = [t.strip() for t in os.getenv(
+ "TOPICS",
+ "sensor_anomalies,alerts,image_new_aerial_connections,sound_new_sounds_connections,"
+ "sound_new_plants_connections,sounds_metadata,sounds_ultra_metadata"
+).split(",") if t.strip()]
+
+# ------------------------- TOKEN BOOTSTRAP -------------------------
-# ---------- Token Bootstrap ----------
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)
@@ -33,13 +38,11 @@ def _read_token_from_file(path: str) -> str | None:
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")
-
def _fetch_token_via_dev_bootstrap(base: str, retries: int = 3, backoff: float = 0.8) -> str | None:
url = _safe_join_url(base, "/auth/_dev_bootstrap")
payload = {"service_name": DB_API_SERVICE_NAME, "rotate_if_exists": True}
@@ -47,96 +50,152 @@ def _fetch_token_via_dev_bootstrap(base: str, retries: int = 3, backoff: float =
try:
r = requests.post(url, json=payload, timeout=10)
if r.status_code not in (200, 201):
- import time
- time.sleep(backoff * attempt)
+ import time; time.sleep(backoff * attempt)
continue
data = r.json()
- raw = (data.get("service_account", {}) or {}).get("raw_token") \
+ 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:
return raw.strip()
except Exception:
- import time
- time.sleep(backoff * attempt)
+ import time; time.sleep(backoff * attempt)
return None
-
def get_or_bootstrap_token() -> str | None:
token = _read_token_from_file(DB_API_TOKEN_FILE)
if token:
return token
if not DB_API_BASE:
- print("[BOOTSTRAP][WARN] DB_API_BASE not set; cannot bootstrap token.", flush=True)
+ print("[BOOTSTRAP][WARN] DB_API_BASE not set", flush=True)
return None
token = _fetch_token_via_dev_bootstrap(DB_API_BASE)
if token:
_write_token_to_file(DB_API_TOKEN_FILE, token)
print(f"[BOOTSTRAP] wrote service token to {DB_API_TOKEN_FILE}", flush=True)
return token
- print("[BOOTSTRAP][ERROR] Failed to obtain service token (dev bootstrap).", flush=True)
+ print("[BOOTSTRAP][ERROR] Failed to obtain service token", flush=True)
return None
+# ------------------------- SHARED HEADERS -------------------------
+
+shared_headers = {"Content-Type": "application/json"}
+token_value = get_or_bootstrap_token()
-# ---------- HTTP client ----------
-_http = requests.Session()
-svc_token = get_or_bootstrap_token()
-if svc_token:
+if token_value:
if DB_API_AUTH_MODE == "service":
- _http.headers.update({"X-Service-Token": svc_token})
+ shared_headers["X-Service-Token"] = token_value
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, status_forcelist=[500, 502, 503, 504])))
-_http.mount("https://", HTTPAdapter(max_retries=Retry(total=5, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504])))
+ shared_headers["Authorization"] = f"Bearer {token_value}"
+
+# ------------------------- PER-TOPIC WORKERS -------------------------
+
+topic_workers = {}
+topic_queues = {}
+topic_worker_counts = {}
+topic_scale_locks = {}
+
+MAX_WORKERS_PER_TOPIC = 5
+MIN_WORKERS_PER_TOPIC = 1
+SCALE_UP_THRESHOLD = 8000
+SCALE_DOWN_THRESHOLD = 2000
+
+def build_retry_session(headers: dict):
+ s = requests.Session()
+ s.headers.update(headers)
+
+ retry_cfg = Retry(
+ total=5,
+ backoff_factor=0.5,
+ status_forcelist=[500, 502, 503, 504]
+ )
+ adapter = HTTPAdapter(max_retries=retry_cfg)
+ s.mount("http://", adapter)
+ s.mount("https://", adapter)
+ s.headers.update(headers)
+ return s
+
+def start_topic_worker(topic: str):
+ if topic not in topic_worker_counts:
+ topic_worker_counts[topic] = 0
+ topic_scale_locks[topic] = threading.Lock()
+
+ if topic not in topic_queues:
+ topic_queues[topic] = queue.Queue(maxsize=30000)
+
+ q = topic_queues[topic]
+
+ def spawn_worker():
+ session = build_retry_session(shared_headers)
+ base = DB_API_BASE.rstrip("/")
+ url = f"{base}/api/tables/{topic}"
+
+ def worker_loop():
+ while True:
+ payload = q.get()
+ try:
+ r = session.post(url, json=payload, timeout=10)
+ if not (200 <= r.status_code < 300):
+ print(f"[DB][{topic}] Error {r.status_code}: {r.text[:150]}")
+ except Exception as e:
+ print(f"[DB][{topic}] Exception: {e}")
+ finally:
+ q.task_done()
+
+ t = threading.Thread(target=worker_loop, daemon=True)
+ t.start()
+ topic_worker_counts[topic] += 1
+
+ if topic_worker_counts[topic] == 0:
+ spawn_worker()
+
+ if topic not in topic_workers:
+ def autoscaler():
+ while True:
+ size = q.qsize()
+ with topic_scale_locks[topic]:
+ if size > SCALE_UP_THRESHOLD and topic_worker_counts[topic] < MAX_WORKERS_PER_TOPIC:
+ spawn_worker()
+
+ if size < SCALE_DOWN_THRESHOLD and topic_worker_counts[topic] > MIN_WORKERS_PER_TOPIC:
+ topic_worker_counts[topic] = max(topic_worker_counts[topic] - 1, MIN_WORKERS_PER_TOPIC)
+
+ import time
+ time.sleep(2)
+ t = threading.Thread(target=autoscaler, daemon=True)
+ t.start()
+ topic_workers[topic] = t
-# ---------- DB Writer ----------
-def write_to_db_api(table: str, payload: dict) -> bool:
+def enqueue_for_db(topic: str, payload: dict) -> bool:
if DUMMY_DB:
- print(f"[DB-DUMMY] would POST to {DB_API_BASE or 'N/A'}/api/{table}: {json.dumps(payload, ensure_ascii=False)[:250]}", flush=True)
+ print(f"[DB-DUMMY] {topic}: {json.dumps(payload)[:200]}")
return True
- if not DB_API_BASE:
- print("[DB][WARN] DB_API_BASE not set; skipping DB write.", flush=True)
- return False
+ start_topic_worker(topic)
+ q = topic_queues[topic]
- base = DB_API_BASE.rstrip("/")
try:
- r = _http.post(f"{base}/api/tables/{table}", json=payload, timeout=10)
- if 200 <= r.status_code < 300:
- print(f"[DB] wrote to {table} ", flush=True)
- return True
- print(f"[DB] POST failed ({table}): {r.status_code} {r.text[:200]}", flush=True)
+ q.put_nowait(payload)
+ return True
+ except queue.Full:
+ print(f"[WARN][{topic}] queue full - dropping")
return False
- except requests.ConnectionError as e:
- print(f"[DB][WARN] API not reachable ({base}): {e}", flush=True)
- except requests.Timeout as e:
- print(f"[DB][WARN] API timeout ({base}): {e}", flush=True)
- except requests.RequestException as e:
- print(f"[DB][ERROR] {e}", flush=True)
- return False
+# ------------------------- MESSAGE HANDLER -------------------------
-# ---------- Flink Job ----------
def handle_message(topic: str, raw: str) -> bool:
try:
data = json.loads(raw)
except json.JSONDecodeError:
- print(f"[WARN] skip invalid JSON on topic={topic}: {raw[:150]!r}", flush=True)
+ print(f"[WARN] invalid JSON on topic={topic}")
return False
- ok = write_to_db_api(topic, data)
- return ok
+ return enqueue_for_db(topic, data)
+# ------------------------- MAIN FLINK JOB -------------------------
def main():
- # env = StreamExecutionEnvironment.get_execution_environment()
- # env.set_parallelism(int(os.getenv("FLINK_PARALLELISM", "1")))
-
- # cfg = env.get_configuration()
- # cfg.set_string("restart-strategy", "fixed-delay")
- # cfg.set_string("restart-strategy.fixed-delay.attempts", "9999")
- # cfg.set_string("restart-strategy.fixed-delay.delay", "5 s")
-
conf = Configuration()
conf.set_string("restart-strategy", "fixed-delay")
conf.set_string("restart-strategy.fixed-delay.attempts", "9999")
@@ -146,7 +205,7 @@ def main():
env.set_parallelism(int(os.getenv("FLINK_PARALLELISM", "1")))
for topic in TOPICS:
- print(f"[FLINK] Listening on topic: {topic}", flush=True)
+ print(f"[FLINK] Listening on: {topic}", flush=True)
source = (
KafkaSource.builder()
@@ -154,17 +213,23 @@ def main():
.set_topics(topic)
.set_group_id(f"flink-writer-db-{topic}")
.set_value_only_deserializer(SimpleStringSchema())
-
.set_property("allow.auto.create.topics", "true")
.set_property("metadata.max.age.ms", "10000")
.build()
)
- ds = env.from_source(source, WatermarkStrategy.no_watermarks(), f"kafka-{topic}")
- ds.map(lambda raw, t=topic: handle_message(t, raw), output_type=Types.BOOLEAN()).print()
+ ds = env.from_source(
+ source,
+ WatermarkStrategy.no_watermarks(),
+ f"kafka-{topic}"
+ )
+
+ ds.map(
+ lambda raw, t=topic: handle_message(t, raw),
+ output_type=Types.BOOLEAN()
+ ).print()
env.execute("flink-writer-db")
-
if __name__ == "__main__":
- main()
\ No newline at end of file
+ main()
diff --git a/services/security/agguard/app/media_proxy.py b/services/security/agguard/app/media_proxy.py
index 6035697bd..43311b564 100644
--- a/services/security/agguard/app/media_proxy.py
+++ b/services/security/agguard/app/media_proxy.py
@@ -3,6 +3,7 @@
import os, re, mimetypes
from typing import Optional
from fastapi import FastAPI, Header, HTTPException, Request
+from botocore.exceptions import ClientError
from fastapi.responses import Response, StreamingResponse
import yaml
@@ -128,25 +129,90 @@ def get_segment(camera: str, incident: str, name: str, request: Request, range:
headers["Content-Range"] = cr
status = 206
return StreamingResponse(obj["Body"].iter_chunks(), headers=headers, status_code=status, media_type=headers["Content-Type"])
-
# ---------- FINAL MP4 (VOD) ----------
@app.get("/vod/{camera}/{incident}/final.mp4")
-def get_final_mp4(camera: str, incident: str, request: Request, range: Optional[str] = Header(default=None)):
- print("requesting vod")
+def get_final_mp4(
+ camera: str,
+ incident: str,
+ request: Request,
+ range: Optional[str] = Header(default=None)
+):
_require_auth(request)
key = _object_key_for_hls(camera, incident, "final.mp4")
+
+
+
try:
obj = s3.get_object_stream(BUCKET, key, range_header=range)
- except Exception:
- raise HTTPException(status_code=404, detail="vod not found")
- headers = {"Accept-Ranges": "bytes", "Content-Type": _MP4_CT}
+ except ClientError as e:
+ code = e.response["Error"]["Code"]
+
+ # File NOT found → real 404
+ if code in ("NoSuchKey", "404"):
+ raise HTTPException(status_code=404, detail="VOD not found")
+
+ # VLC requests ranges beyond file end → MUST return 416, NOT 404
+ if code == "InvalidRange":
+ raise HTTPException(status_code=416, detail="Invalid Range")
+
+ # Any other S3 problem is a server issue
+ raise HTTPException(status_code=502, detail=f"S3 error: {code}")
+
+ # Non-S3 exceptions (timeout, disconnect, etc.)
+ except Exception as e:
+ raise HTTPException(status_code=502, detail=f"Internal proxy error: {type(e).__name__}")
+
+
+ body = obj["Body"]
+ content_length = obj.get("ContentLength")
+ content_range = obj.get("ContentRange") or obj.get("Content-Range")
+
+ headers = {
+ "Accept-Ranges": "bytes",
+ "Content-Type": _MP4_CT,
+ }
+
+ # Important for VLC
+ if "ContentLength" in obj:
+ headers["Content-Length"] = str(obj["ContentLength"])
+
+
status = 200
- cr = obj.get("ContentRange") or obj.get("Content-Range")
- if cr:
- headers["Content-Range"] = cr
+ if content_range:
+ headers["Content-Range"] = content_range
status = 206
- return StreamingResponse(obj["Body"].iter_chunks(), headers=headers, status_code=status, media_type=_MP4_CT)
+ if content_length and status == 200:
+ headers["Content-Length"] = str(content_length)
+
+ # --- SAFE STREAMING GENERATOR ---
+ def stream_body():
+ try:
+ while True:
+ chunk = body.read(256 * 1024)
+
+ # S3 can return b'' BEFORE true EOF → retry once
+ if chunk == b"":
+ more = body.read(256 * 1024)
+ if more == b"":
+ break
+ yield more
+ continue
+
+ if not chunk:
+ break
+
+ yield chunk
+
+ except Exception as e:
+ print("[MEDIA_PROXY][STREAM ERROR]", e)
+
+ return StreamingResponse(
+ stream_body(),
+ headers=headers,
+ status_code=status,
+ media_type=_MP4_CT,
+ )
@app.get("/img/{camera}/{incident}/{filename}")
def get_image(camera: str, incident: str, filename: str, request: Request):
diff --git a/services/security/agguard/core/events/aggregator.py b/services/security/agguard/core/events/aggregator.py
index 7f4e17486..01150e869 100644
--- a/services/security/agguard/core/events/aggregator.py
+++ b/services/security/agguard/core/events/aggregator.py
@@ -3,7 +3,7 @@
from typing import Dict, List, Tuple, Optional, Any
from dataclasses import dataclass, field
import uuid, datetime as _dt
-import cv2, numpy as np,time
+import cv2, numpy as np, time, threading
from .models import Rule, Incident, Box
from agguard.media.hls_recorder import HlsRecorder, HlsConfig
@@ -11,6 +11,7 @@
import logging
log = logging.getLogger(__name__)
+
@dataclass
class IncidentEvent:
opened_incident_id: str | None = None
@@ -19,6 +20,7 @@ class IncidentEvent:
opened_data: Optional[Any] = None
closed_data: Optional[Any] = None
+
@dataclass
class _EventState:
consec: int = 0
@@ -75,9 +77,10 @@ def __init__(
assoc_iou: float = 0.3,
sample_every: int = 1,
s3=None, video_bucket=None, video_prefix="security/incidents",
- fps=12, hls_segment_time=3.0, hls_list_size=20, hls_use_cmaf=False,
- draw_thickness=2, media_base: Optional[str] = None, media_token: Optional[str] = None):
-
+ fps=12, hls_segment_time=3.0, hls_list_size=20, hls_use_cmaf=False,
+ draw_thickness=2, media_base: Optional[str] = None, media_token: Optional[str] = None
+ ):
+
self.rules = rules
self.camera_id = camera_id
self.roi_pixels = roi_pixels
@@ -95,7 +98,6 @@ def __init__(
)
self.draw_thickness = int(max(1, draw_thickness))
-
self.media_base = (media_base or "").rstrip("/")
self.media_token = media_token or ""
@@ -104,15 +106,23 @@ def _render_frame_with_boxes(self, frame_bgr, dets):
out = frame_bgr.copy()
t = self.draw_thickness
for d in dets or []:
- x1,y1,x2,y2 = int(d["x1"]),int(d["y1"]),int(d["x2"]),int(d["y2"])
- cv2.rectangle(out, (x1,y1), (x2,y2), (0,255,0), t)
+ x1, y1, x2, y2 = int(d["x1"]), int(d["y1"]), int(d["x2"]), int(d["y2"])
+ cv2.rectangle(out, (x1, y1), (x2, y2), (0, 255, 0), t)
tid = d.get("track_id")
if tid is not None:
- cv2.putText(out, str(tid), (x1, max(0, y1-5)),
- cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,255,0), 1, cv2.LINE_AA)
+ cv2.putText(
+ out,
+ str(tid),
+ (x1, max(0, y1 - 5)),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ 0.5,
+ (0, 255, 0),
+ 1,
+ cv2.LINE_AA,
+ )
return out
-# helper to compute s3 prefix
+ # helper to compute s3 prefix
def _hls_prefix(self, inc):
cam = self.camera_id or "unknown"
return f"{self.video_prefix}/{cam}/{inc.incident_id}"
@@ -122,8 +132,6 @@ def _hls_prefix(self, inc):
def set_camera(self, camera_id: Optional[str]) -> None:
self.camera_id = camera_id
-
-
def set_roi_pixels(self, roi_pixels: Optional[List[Tuple[int, int]]]) -> None:
self.roi_pixels = roi_pixels
@@ -145,7 +153,6 @@ def _class_match(t_cls: Any, rule: Rule) -> bool:
True if track class matches rule by name or id (if provided).
If rule doesn't restrict class, accept all.
"""
- # return True
t_name = str(t_cls).lower()
by_name = (rule.target_cls and t_name == str(rule.target_cls).lower())
by_id = (rule.target_cls_id is not None and str(t_cls) == str(rule.target_cls_id))
@@ -165,19 +172,27 @@ def _match_classes(self, rule: Rule, preds: List) -> bool:
log.info("[_match_classes] 🟢 Rule '%s' has empty match_classes → treating as always True", rule.name)
return True
- log.info("[_match_classes] 🔍 Evaluating rule='%s' | match_classes=%s | min_conf=%.2f",
- rule.name, classes, float(rule.min_conf or 0.0))
+ log.info(
+ "[_match_classes] 🔍 Evaluating rule='%s' | match_classes=%s | min_conf=%.2f",
+ rule.name,
+ classes,
+ float(rule.min_conf or 0.0),
+ )
matched = False
for idx, p in enumerate(preds):
try:
# Handle dict-style predictions
if isinstance(p, dict):
- cls_name = str(p.get("label") or p.get("cls") or p.get("class_name") or "").strip().lower()
+ cls_name = str(
+ p.get("label") or p.get("cls") or p.get("class_name") or ""
+ ).strip().lower()
conf = float(p.get("confidence", p.get("conf", 0.0)))
# Handle object-style (protobuf / custom)
elif hasattr(p, "label") or hasattr(p, "cls") or hasattr(p, "class_name"):
- cls_name = str(getattr(p, "label", getattr(p, "cls", getattr(p, "class_name", "")))).strip().lower()
+ cls_name = str(
+ getattr(p, "label", getattr(p, "cls", getattr(p, "class_name", "")))
+ ).strip().lower()
conf = float(getattr(p, "confidence", getattr(p, "conf", 0.0)))
else:
log.warning("[_match_classes] 🚫 Unsupported prediction type %s → skipping", type(p))
@@ -187,7 +202,10 @@ def _match_classes(self, rule: Rule, preds: List) -> bool:
# Check confidence
if conf < float(rule.min_conf or 0.0):
- log.info("[_match_classes] ↳ below min_conf=%.2f → SKIP", float(rule.min_conf or 0.0))
+ log.info(
+ "[_match_classes] ↳ below min_conf=%.2f → SKIP",
+ float(rule.min_conf or 0.0),
+ )
continue
# Check class match
@@ -197,7 +215,12 @@ def _match_classes(self, rule: Rule, preds: List) -> bool:
matched = True
break
elif c in cls_name or cls_name in c:
- log.info("[_match_classes] ⚡ PARTIAL MATCH '%s' ~ '%s' for rule '%s'", cls_name, c, rule.name)
+ log.info(
+ "[_match_classes] ⚡ PARTIAL MATCH '%s' ~ '%s' for rule '%s'",
+ cls_name,
+ c,
+ rule.name,
+ )
matched = True
break
@@ -205,17 +228,63 @@ def _match_classes(self, rule: Rule, preds: List) -> bool:
log.exception("[_match_classes] ❌ Error parsing prediction #%d: %s", idx, e)
if not matched:
- log.info("[_match_classes] ❌ No matches found for rule '%s'. Predictions checked: %d",
- rule.name, len(preds))
+ log.info(
+ "[_match_classes] ❌ No matches found for rule '%s'. Predictions checked: %d",
+ rule.name,
+ len(preds),
+ )
return matched
+ # ------------- HLS deletion scheduler -------------
+
+ def _schedule_hls_deletion(self, hls_recorder, delay_sec: float = 120.0) -> None:
+ """
+ Delete HLS content after a delay (default 2 minutes), in a background thread.
+ """
+ if not hls_recorder:
+ return
+
+ def _worker():
+ try:
+ log.info(
+ "[_schedule_hls_deletion] Sleeping %.1f seconds before deleting HLS...",
+ delay_sec,
+ )
+ time.sleep(delay_sec)
+
+ try:
+ hls_recorder.delete_remote_hls()
+ except Exception as e:
+ log.exception("[_schedule_hls_deletion] remote delete error: %s", e)
+
+ try:
+ hls_recorder.delete_hls_files_only()
+ except Exception as e:
+ log.exception("[_schedule_hls_deletion] local delete error: %s", e)
+ log.info("[_schedule_hls_deletion] HLS cleanup finished.")
+ except Exception as e:
+ log.exception("[_schedule_hls_deletion] Unexpected error: %s", e)
+ threading.Thread(target=_worker, daemon=True).start()
+ # ------------- open / close incidents -------------
- def _open_incident(self, st: _EventState, rule: Rule, ts_sec: float, frame_idx: int, frame_bgr) -> dict:
- log.info("[_open_incident] Opening new incident for rule '%s' at frame %d ts=%.3f", rule.name, frame_idx, ts_sec)
+ def _open_incident(
+ self,
+ st: _EventState,
+ rule: Rule,
+ ts_sec: float,
+ frame_idx: int,
+ frame_bgr,
+ ) -> dict:
+ log.info(
+ "[_open_incident] Opening new incident for rule '%s' at frame %d ts=%.3f",
+ rule.name,
+ frame_idx,
+ ts_sec,
+ )
inc = Incident(
incident_id=str(uuid.uuid4()),
kind=rule.name,
@@ -223,7 +292,7 @@ def _open_incident(self, st: _EventState, rule: Rule, ts_sec: float, frame_idx:
started_ts=ts_sec,
frame_start=frame_idx,
roi=self.roi_pixels,
- severity=getattr(rule, "severity", 0)
+ severity=getattr(rule, "severity", 0),
)
st.open_incident = inc
st.cooldown_left = int(rule.cooldown)
@@ -234,8 +303,10 @@ def _open_incident(self, st: _EventState, rule: Rule, ts_sec: float, frame_idx:
if self.s3 and self.video_bucket:
# --- Live HLS recorder ---
st._hls = HlsRecorder(
- s3=self.s3, bucket=self.video_bucket,
- prefix=self._hls_prefix(inc), cfg=self._hls_cfg,
+ s3=self.s3,
+ bucket=self.video_bucket,
+ prefix=self._hls_prefix(inc),
+ cfg=self._hls_cfg,
)
H, W = frame_bgr.shape[:2]
st._hls.start((H, W))
@@ -243,15 +314,14 @@ def _open_incident(self, st: _EventState, rule: Rule, ts_sec: float, frame_idx:
# --- MP4 recorder (for final video only) ---
st._mp4 = Mp4Recorder(
- s3=self.s3, bucket=self.video_bucket,
- prefix=self._hls_prefix(inc), cfg=self._hls_cfg,
+ s3=self.s3,
+ bucket=self.video_bucket,
+ prefix=self._hls_prefix(inc),
+ cfg=self._hls_cfg,
)
st._mp4.start((H, W))
st._mp4.write_bgr(self._render_frame_with_boxes(frame_bgr, st.detections))
-
-
-
# Only notify external world once the playlist definitely exists
if self.media_base and hasattr(st, "_hls") and st._hls:
log.info("[_open_incident] Waiting for playlist readiness...")
@@ -262,7 +332,14 @@ def _open_incident(self, st: _EventState, rule: Rule, ts_sec: float, frame_idx:
vod_url = f"{camera}/{incident_id}/final.mp4"
anomaly = inc.kind or "unknown"
sev = "info"
- log.info("[_open_incident] Sending alert to Alertmanager for incident_id=%s hls_url=%s", incident_id, hls_url)
+ log.info(
+ "[_open_incident] Sending alert to Alertmanager for incident_id=%s hls_url=%s",
+ incident_id,
+ hls_url,
+ )
+ else:
+ hls_url = None
+ vod_url = None
opened_data = {
"incident_id": inc.incident_id,
@@ -273,11 +350,17 @@ def _open_incident(self, st: _EventState, rule: Rule, ts_sec: float, frame_idx:
"roi": self.roi_pixels,
"severity": getattr(rule, "severity", 0),
"hls": hls_url,
- "subject": getattr(st, "subject", None), # ✅ Add this line
+ "subject": getattr(st, "subject", None),
}
return opened_data
- def _close_incident(self, key: Tuple[str, str], st: _EventState, ts_sec: float, frame_idx: int)->dict:
+ def _close_incident(
+ self,
+ key: Tuple[str, str],
+ st: _EventState,
+ ts_sec: float,
+ frame_idx: int,
+ ) -> dict:
inc = st.open_incident
if not inc:
log.info("[_close_incident] No open incident to close for key=%s", key)
@@ -291,45 +374,53 @@ def _close_incident(self, key: Tuple[str, str], st: _EventState, ts_sec: float,
# severity = mean tracks per frame during the incident
severity = round(float(st.total_tracks) / max(st.total_frames, 1))
- log.info("[_close_incident] Computed severity=%.3f (tracks=%d frames=%d)", severity, st.total_tracks, st.total_frames)
+ log.info(
+ "[_close_incident] Computed severity=%.3f (tracks=%d frames=%d)",
+ severity,
+ st.total_tracks,
+ st.total_frames,
+ )
+
+ mp4_key = None
-
if self.s3 and self.video_bucket and hasattr(st, "_hls") and st._hls:
try:
- log.info("[_close_incident] Finalizing HLS to MP4 for incident_id=%s", inc.incident_id)
- # mp4_key = st._mp4.finalize() if hasattr(st, "_mp4") and st._mp4 else None
+ log.info(
+ "[_close_incident] Finalizing HLS to MP4 for incident_id=%s",
+ inc.incident_id,
+ )
+
if st._hls:
try:
# 1️⃣ Stop ffmpeg FIRST — always
st._hls.stop()
- log.info("[DEBUG] ffmpeg alive? %s", st._hls._proc and st._hls._proc.poll() is None)
+ log.info(
+ "[DEBUG] ffmpeg alive? %s",
+ st._hls._proc and st._hls._proc.poll() is None,
+ )
- time.sleep(0.2)
+ time.sleep(0.2)
# 2️⃣ Finalize MP4 — now safe
- mp4_key = st._mp4.finalize() if st._mp4 else None
-
- # 3️⃣ Delete HLS fragments — ffmpeg is now dead
- # 3️⃣ DELETE REMOTE HLS FRAGMENTS FROM S3
- try:
- st._hls.delete_remote_hls()
- except Exception as e:
- log.exception("[_close_incident] remote delete error: %s", e)
-
- # 4️⃣ delete LOCAL files (optional for cleanup)
- st._hls.delete_hls_files_only()
+ mp4_key = st._mp4.finalize() if hasattr(st, "_mp4") and st._mp4 else None
+ log.info(
+ "[_close_incident] finalize_to_mp4() returned mp4_key=%s",
+ mp4_key,
+ )
+ # 3️⃣ Schedule HLS deletion in 2 minutes
+ self._schedule_hls_deletion(st._hls, delay_sec=120.0)
except Exception as e:
log.exception("[_close_incident] Cleanup error: %s", e)
- log.info("[_close_incident] finalize_to_mp4() returned mp4_key=%s", mp4_key)
except Exception as e:
log.exception("[_close_incident] Error finalizing MP4: %s", e)
- poster_file_id = None
else:
- log.info("[_close_incident] Skipping MP4 finalization — missing s3/video_bucket or no _hls")
+ log.info(
+ "[_close_incident] Skipping MP4 finalization — missing s3/video_bucket or no _hls"
+ )
-
+ # Reset state for this (camera, rule)
self._states[key] = _EventState()
closed_data = {
@@ -339,24 +430,34 @@ def _close_incident(self, key: Tuple[str, str], st: _EventState, ts_sec: float,
"ts_iso": _dt.datetime.utcfromtimestamp(inc.started_ts).isoformat() + "Z",
"duration_sec": inc.duration_sec,
"frame_end": frame_idx,
- "severity":inc.severity+severity,
- "vod":f"{inc.camera_id}/{inc.incident_id}/final.mp4",
- "subject": getattr(st, "subject", None)
-
+ "severity": inc.severity + severity,
+ "vod": f"{inc.camera_id}/{inc.incident_id}/final.mp4",
+ "subject": getattr(st, "subject", None),
}
return closed_data
-
# ------------- public API -------------
- def update(self, frame_idx: int, ts_sec: float, frame_bgr, tracks: List, outputs: Dict[str, List]) -> IncidentEvent:
+ def update(
+ self,
+ frame_idx: int,
+ ts_sec: float,
+ frame_bgr,
+ tracks: List,
+ outputs: Dict[str, List],
+ ) -> IncidentEvent:
"""
Evaluate evidence per (camera_id, rule). Maintain incident state.
Also captures ALL detections (bbox + conf) for record_frame().
Returns IncidentEvent to signal opens/updates/closes to the caller.
"""
- log.info("[update] frame_idx=%d ts=%.3f num_tracks=%d num_outputs=%d",
- frame_idx, ts_sec, len(tracks), len(outputs or {}))
+ log.info(
+ "[update] frame_idx=%d ts=%.3f num_tracks=%d num_outputs=%d",
+ frame_idx,
+ ts_sec,
+ len(tracks),
+ len(outputs or {}),
+ )
H, W = frame_bgr.shape[:2]
by_cls = outputs or {}
@@ -366,22 +467,29 @@ def update(self, frame_idx: int, ts_sec: float, frame_bgr, tracks: List, outputs
# Only skip 'intruding animal' if climbing_fence truly matched
if rule.name == "intruding animal":
cf_preds = by_cls.get("climbing_fence", [])
- climbing_rule = next((r for r in self.rules if r.name == "climbing_fence"), None)
+ climbing_rule = next(
+ (r for r in self.rules if r.name == "climbing_fence"),
+ None,
+ )
if climbing_rule and self._match_classes(climbing_rule, cf_preds):
- log.info("[update] Valid climbing_fence detected → suppressing intruding animal.")
+ log.info(
+ "[update] Valid climbing_fence detected → suppressing intruding animal."
+ )
continue
- log.info("[update] Evaluating rule '%s' (target_cls=%s cooldown=%s)",
- rule.name, getattr(rule, 'target_cls', None), getattr(rule, 'cooldown', None))
+ log.info(
+ "[update] Evaluating rule '%s' (target_cls=%s cooldown=%s)",
+ rule.name,
+ getattr(rule, "target_cls", None),
+ getattr(rule, "cooldown", None),
+ )
candidate_tracks = [t for t in tracks if self._class_match(t.cls, rule)]
preds = by_cls.get(rule.name, []) or by_cls.get(rule.target_cls, [])
- # preds = by_cls.get(rule.target_cls, []) if rule.target_cls else []
-
st = self._state(rule)
-
+
# 🧠 Prefer subject propagated from "intruding animal" (via outputs["_subject"])
if "_subject" in outputs and outputs["_subject"]:
st.subject = outputs["_subject"][0] # e.g. "bear"
@@ -396,8 +504,9 @@ def update(self, frame_idx: int, ts_sec: float, frame_bgr, tracks: List, outputs
best_pred = max(
preds,
key=lambda p: getattr(
- p, "confidence",
- p.get("confidence", 0.0) if isinstance(p, dict) else 0.0
+ p,
+ "confidence",
+ p.get("confidence", 0.0) if isinstance(p, dict) else 0.0,
),
)
@@ -410,14 +519,20 @@ def update(self, frame_idx: int, ts_sec: float, frame_bgr, tracks: List, outputs
log.info(
"[update] 🐾 Subject detected for rule '%s': %s (conf=%.2f)",
- rule.name, st.subject, conf_val
+ rule.name,
+ st.subject,
+ conf_val,
)
else:
st.subject = None
conf_val = 0.0
- log.info("[update] Found %d candidate tracks and %d predictions for rule '%s'",
- len(candidate_tracks), len(preds), rule.name)
+ log.info(
+ "[update] Found %d candidate tracks and %d predictions for rule '%s'",
+ len(candidate_tracks),
+ len(preds),
+ rule.name,
+ )
evidence = False
frame_detections: List[Dict[str, Any]] = []
@@ -426,24 +541,40 @@ def update(self, frame_idx: int, ts_sec: float, frame_bgr, tracks: List, outputs
bx = _clamp_box(tuple(map(int, t.bbox)), W, H)
if self._match_classes(rule, preds):
evidence = True
- log.info("[update] Evidence matched for rule '%s' on track_id=%s bbox=%s",
- rule.name, getattr(t, 'track_id', None), bx)
+ log.info(
+ "[update] Evidence matched for rule '%s' on track_id=%s bbox=%s",
+ rule.name,
+ getattr(t, "track_id", None),
+ bx,
+ )
x1, y1, x2, y2 = bx
try:
conf_val = float(t.conf)
except Exception:
conf_val = None
- frame_detections.append({
- "track_id": int(t.track_id) if getattr(t, "track_id", None) is not None else None,
- "x1": x1, "y1": y1, "x2": x2, "y2": y2,
- "conf": conf_val,
- })
+ frame_detections.append(
+ {
+ "track_id": int(t.track_id)
+ if getattr(t, "track_id", None) is not None
+ else None,
+ "x1": x1,
+ "y1": y1,
+ "x2": x2,
+ "y2": y2,
+ "conf": conf_val,
+ }
+ )
st = self._state(rule)
key = self._key(rule)
- log.info("[update] Current state for key=%s consec=%d cooldown_left=%d open_incident=%s",
- key, st.consec, st.cooldown_left, getattr(st.open_incident, 'incident_id', None))
+ log.info(
+ "[update] Current state for key=%s consec=%d cooldown_left=%d open_incident=%s",
+ key,
+ st.consec,
+ st.cooldown_left,
+ getattr(st.open_incident, "incident_id", None),
+ )
# If recording, write current frame (with boxes) continuously
if st.open_incident is not None:
@@ -455,8 +586,12 @@ def update(self, frame_idx: int, ts_sec: float, frame_bgr, tracks: List, outputs
st.total_tracks += len(frame_detections)
st.total_frames += 1
- log.info("[update] Recorded frame for active incident=%s total_tracks=%d total_frames=%d",
- st.open_incident.incident_id, st.total_tracks, st.total_frames)
+ log.info(
+ "[update] Recorded frame for active incident=%s total_tracks=%d total_frames=%d",
+ st.open_incident.incident_id,
+ st.total_tracks,
+ st.total_frames,
+ )
st.last_seen_frame = frame_idx
st.last_seen_ts = ts_sec
@@ -464,52 +599,100 @@ def update(self, frame_idx: int, ts_sec: float, frame_bgr, tracks: List, outputs
prev_consec = st.consec
st.consec = st.consec + 1 if evidence else 0
- log.info("[update] Consecutive evidence count changed from %d -> %d (rule='%s')",
- prev_consec, st.consec, rule.name)
+ log.info(
+ "[update] Consecutive evidence count changed from %d -> %d (rule='%s')",
+ prev_consec,
+ st.consec,
+ rule.name,
+ )
if st.open_incident is None and st.consec >= int(rule.min_consec or 1):
log.info("[update] Triggering _open_incident for rule '%s'", rule.name)
- opened_data = self._open_incident(st, rule, ts_sec, frame_idx, frame_bgr)
+ opened_data = self._open_incident(
+ st,
+ rule,
+ ts_sec,
+ frame_idx,
+ frame_bgr,
+ )
evt.opened_incident_id = st.open_incident.incident_id
evt.opened_data = opened_data
- log.info("[update] Opened new incident_id=%s for rule='%s'",
- evt.opened_incident_id, rule.name)
+ log.info(
+ "[update] Opened new incident_id=%s for rule='%s'",
+ evt.opened_incident_id,
+ rule.name,
+ )
if st.open_incident is not None:
# sample a representative bbox to append to incident trail
- if self.sample_every > 0 and (frame_idx % self.sample_every == 0) and st.detections:
+ if (
+ self.sample_every > 0
+ and (frame_idx % self.sample_every == 0)
+ and st.detections
+ ):
bx = max(
- ((d["x2"] - d["x1"]) * (d["y2"] - d["y1"]), d) for d in st.detections
+ (
+ (d["x2"] - d["x1"]) * (d["y2"] - d["y1"]),
+ d,
+ )
+ for d in st.detections
)[1]
- st.open_incident.boxes.append((bx["x1"], bx["y1"], bx["x2"], bx["y2"]))
+ st.open_incident.boxes.append(
+ (bx["x1"], bx["y1"], bx["x2"], bx["y2"])
+ )
st.open_incident.confs.append(1.0)
- log.info("[update] Appended sample bbox=%s to incident trail for %s", bx, st.open_incident.incident_id)
+ log.info(
+ "[update] Appended sample bbox=%s to incident trail for %s",
+ bx,
+ st.open_incident.incident_id,
+ )
# cooldown logic
prev_cooldown = st.cooldown_left
- st.cooldown_left = int(rule.cooldown) if evidence else (st.cooldown_left - 1)
- log.info("[update] Cooldown changed %d -> %d (evidence=%s)",
- prev_cooldown, st.cooldown_left, evidence)
+ st.cooldown_left = (
+ int(rule.cooldown) if evidence else (st.cooldown_left - 1)
+ )
+ log.info(
+ "[update] Cooldown changed %d -> %d (evidence=%s)",
+ prev_cooldown,
+ st.cooldown_left,
+ evidence,
+ )
if not evidence and st.cooldown_left <= 0:
closed_id = st.open_incident.incident_id
- log.info("[update] Closing incident %s (cooldown expired)", closed_id)
- closed_data = self._close_incident(key, st, ts_sec, frame_idx)
+ log.info(
+ "[update] Closing incident %s (cooldown expired)",
+ closed_id,
+ )
+ closed_data = self._close_incident(
+ key,
+ st,
+ ts_sec,
+ frame_idx,
+ )
evt.closed_incident_id = closed_id
evt.closed_data = closed_data
else:
evt.updated_incident_id = st.open_incident.incident_id
- log.info("[update] Updating active incident %s (evidence=%s cooldown=%d)",
- st.open_incident.incident_id, evidence, st.cooldown_left)
-
- log.info("[update] Returning IncidentEvent opened=%s updated=%s closed=%s",
- evt.opened_incident_id, evt.updated_incident_id, evt.closed_incident_id)
+ log.info(
+ "[update] Updating active incident %s (evidence=%s cooldown=%d)",
+ st.open_incident.incident_id,
+ evidence,
+ st.cooldown_left,
+ )
+
+ log.info(
+ "[update] Returning IncidentEvent opened=%s updated=%s closed=%s",
+ evt.opened_incident_id,
+ evt.updated_incident_id,
+ evt.closed_incident_id,
+ )
return evt
-
def flush(self, ts_sec: float, frame_idx: int):
"""Close any open incidents across all cameras/rules."""
for key, st in list(self._states.items()):
if st.open_incident is not None:
- self._close_incident(key, st, ts_sec, frame_idx)
\ No newline at end of file
+ self._close_incident(key, st, ts_sec, frame_idx)
diff --git a/services/security/agguard/media/hls_recorder.py b/services/security/agguard/media/hls_recorder.py
index 487e1042f..2a91a95c7 100644
--- a/services/security/agguard/media/hls_recorder.py
+++ b/services/security/agguard/media/hls_recorder.py
@@ -22,6 +22,7 @@
".mp4": "video/mp4",
}
+
@dataclass
class HlsConfig:
# Fixed cadence; 4–6 fps works well for security UIs
@@ -52,6 +53,9 @@ class HlsRecorder:
prefix: str
cfg: HlsConfig = field(default_factory=HlsConfig)
+ # Minimum delay between stop() and any delete_*()
+ MIN_DELETE_DELAY: float = 120.0 # seconds
+
_tmpdir: Optional[str] = None
_proc: Optional[subprocess.Popen] = None
@@ -69,6 +73,10 @@ class HlsRecorder:
_frame_shape: Optional[Tuple[int, int]] = None
_first_frame_evt: threading.Event = field(default_factory=threading.Event)
+ # State after stop()
+ _stopped_tmpdir: Optional[str] = None
+ _stopped_at: Optional[float] = None
+
# ────────────────────────────────────────────────────────────────────────
# Public API
# ────────────────────────────────────────────────────────────────────────
@@ -83,6 +91,10 @@ def start(self, frame_size: Tuple[int, int]) -> None:
self._tmpdir = tempfile.mkdtemp(prefix="hls_")
out = pathlib.Path(self._tmpdir)
+ # reset stop metadata on reuse
+ self._stopped_tmpdir = None
+ self._stopped_at = None
+
seg_ext = ".m4s" if self.cfg.use_cmaf else ".ts"
seg_pattern = str(out / f"segment_%05d{seg_ext}")
m3u8_path = str(out / "index.m3u8")
@@ -110,7 +122,10 @@ def start(self, frame_size: Tuple[int, int]) -> None:
# Optional silent audio (improves compatibility)
if self.cfg.add_silent_audio:
- cmd += ["-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=48000"]
+ cmd += [
+ "-f", "lavfi",
+ "-i", "anullsrc=channel_layout=stereo:sample_rate=48000",
+ ]
map_args = ["-map", "0:v:0", "-map", "1:a:0"]
else:
map_args = ["-map", "0:v:0"]
@@ -184,8 +199,6 @@ def write_bgr(self, frame_bgr) -> None:
if not self._first_frame_evt.is_set():
self._first_frame_evt.set()
-
-
# ────────────────────────────────────────────────────────────────────────
# Internals
# ────────────────────────────────────────────────────────────────────────
@@ -195,8 +208,6 @@ def _feeder_loop(self):
period = 1.0 / float(fps)
# Wait for the first real frame to avoid initial black.
- # Since your aggregator calls write_bgr() immediately after start(),
- # this should trigger right away.
self._first_frame_evt.wait(timeout=3.0)
# If absolutely nothing arrived, we can still fall back to blank.
@@ -221,9 +232,6 @@ def _feeder_loop(self):
pass
dt = time.perf_counter() - t0
time.sleep(max(0.0, period - dt))
-
-
-
def _parse_tail_refs(self, m3u8_path: pathlib.Path) -> list[str]:
"""
@@ -242,6 +250,7 @@ def _parse_tail_refs(self, m3u8_path: pathlib.Path) -> list[str]:
# keep raw line (local filename)
uris.append(os.path.basename(s))
return uris
+
def _make_publishable_index(self, m3u8_path: pathlib.Path, exist_names: set[str]) -> Optional[pathlib.Path]:
"""
Create a temp 'index.publish.m3u8' that is identical to the local m3u8
@@ -292,8 +301,8 @@ def _make_publishable_index(self, m3u8_path: pathlib.Path, exist_names: set[str]
out: list[str] = ["#EXTM3U"]
# normalize a few important headers; keep original others
have_version = any(h.startswith("#EXT-X-VERSION:") for h in headers)
- have_indep = any(h.startswith("#EXT-X-INDEPENDENT-SEGMENTS") for h in headers)
- have_target = any(h.startswith("#EXT-X-TARGETDURATION:") for h in headers)
+ have_indep = any(h.startswith("#EXT-X-INDEPENDENT-SEGMENTS") for h in headers)
+ have_target = any(h.startswith("#EXT-X-TARGETDURATION:") for h in headers)
if not have_version:
out.append("#EXT-X-VERSION:6")
@@ -362,8 +371,6 @@ def _sync_loop(self) -> None:
key = f"{self.prefix}/{p.name}"
if seen.get(key) == sig:
continue
- # Because ffmpeg uses -hls_flags temp_file, a segment appears atomically
- # when fully written; we can upload immediately.
self.s3.put_file(self.bucket, key, str(p), _CT.get(p.suffix.lower(), "application/octet-stream"))
seen[key] = sig
self._uploaded_keys.add(key)
@@ -384,14 +391,12 @@ def _sync_loop(self) -> None:
if stat.st_size <= 0:
continue
- # Build set of locally existing segment names (what we can publish)
- existing = {q.name for q in segs} # segs you already built in step 2
+ existing = {q.name for q in segs}
if self.cfg.use_cmaf:
- existing |= {q.name for q in init_files} # init.mp4 is allowed in MAP
+ existing |= {q.name for q in init_files}
publishable = self._make_publishable_index(p, existing)
if not publishable:
- # Nothing safe to publish yet; try next loop
continue
pub_stat = publishable.stat()
@@ -403,7 +408,6 @@ def _sync_loop(self) -> None:
self._uploaded_keys.add(key)
have_index = True
-
if have_index and have_init and not self._ready_evt.is_set():
self._ready_evt.set()
@@ -413,7 +417,6 @@ def _sync_loop(self) -> None:
time.sleep(interval)
-
def wait_ready(self, timeout: float = 6.0) -> bool:
"""Block until the playlist (+ init for CMAF) has been uploaded once (or timeout)."""
return self._ready_evt.wait(timeout)
@@ -425,51 +428,11 @@ def _cleanup_local(self) -> None:
finally:
self._tmpdir = None
-
-
-
# ────────────────────────────────────────────────────────────────────────
- # Playlist freezing / reconstruction for finalize
+ # Stop
# ────────────────────────────────────────────────────────────────────────
- def _build_temp_playlist(
- self,
- workdir: pathlib.Path,
- segs_ts: list[pathlib.Path],
- segs_m4s: list[pathlib.Path],
- have_cmaf: bool,
- ) -> Optional[pathlib.Path]:
- """Create a local m3u8 referencing all segments we have."""
- if have_cmaf:
- if not segs_m4s:
- return None
- td = 1
- p = workdir / "reconstructed.m3u8"
- with p.open("w", encoding="utf-8") as f:
- f.write("#EXTM3U\n#EXT-X-VERSION:6\n#EXT-X-PLAYLIST-TYPE:EVENT\n")
- f.write("#EXT-X-INDEPENDENT-SEGMENTS\n")
- f.write(f"#EXT-X-TARGETDURATION:{td}\n")
- if (workdir / "init.mp4").exists():
- f.write('#EXT-X-MAP:URI="init.mp4"\n')
- for s in segs_m4s:
- f.write("#EXTINF:1.000,\n")
- f.write(f"{s.name}\n")
- return p
- else:
- if not segs_ts:
- return None
- td = 1
- p = workdir / "reconstructed.m3u8"
- with p.open("w", encoding="utf-8") as f:
- f.write("#EXTM3U\n#EXT-X-VERSION:6\n#EXT-X-PLAYLIST-TYPE:EVENT\n")
- f.write("#EXT-X-INDEPENDENT-SEGMENTS\n")
- f.write(f"#EXT-X-TARGETDURATION:{td}\n")
- for s in segs_ts:
- f.write("#EXTINF:1.000,\n")
- f.write(f"{s.name}\n")
- return p
-
def stop(self):
- """Stop all threads and kill ffmpeg reliably."""
+ """Stop all threads and kill ffmpeg reliably (no deletion here)."""
try:
# Stop feeder
self._feeder_stop.set()
@@ -485,7 +448,7 @@ def stop(self):
if self._proc and self._proc.stdin:
try:
self._proc.stdin.close()
- except:
+ except Exception:
pass
# HARD kill ffmpeg no matter what
@@ -499,30 +462,23 @@ def stop(self):
try:
self._proc.kill()
self._proc.wait(timeout=0.5)
- except:
+ except Exception:
pass
finally:
# Always mark tmpdir as stopped
self._stopped_tmpdir = self._tmpdir
+ self._stopped_at = time.time()
self._tmpdir = None
self._proc = None
+ print(f"[HlsRecorder] stop() called; _stopped_at={self._stopped_at:.3f}, dir={self._stopped_tmpdir}")
-
-
-
-
-
+ # ────────────────────────────────────────────────────────────────────────
+ # Playlist freezing / reconstruction for finalize (unchanged)
+ # ────────────────────────────────────────────────────────────────────────
def _freeze_local_playlist(self, m3u8_path: pathlib.Path, workdir: pathlib.Path) -> pathlib.Path:
- """
- Produce a 'finalize.m3u8' that:
- - keeps essential headers (normalized),
- - rewrites URIs to local filenames,
- - appends #EXT-X-ENDLIST,
- - avoids HTTP or proxy (/seg?u=) paths so ffmpeg won't poll.
- """
def _maybe_localize_uri(uri: str) -> str:
- m = re.search(r'[?&]u=([^&]+)', uri)
+ m = re.search(r"[?&]u=([^&]+)", uri)
inner = m.group(1) if m else uri
u = urlparse(inner)
candidate = os.path.basename(u.path if u.scheme in ("http", "https") else inner)
@@ -555,9 +511,7 @@ def _maybe_localize_uri(uri: str) -> str:
body.append(f'#EXT-X-MAP:URI="{_maybe_localize_uri(m.group(1))}"')
elif s.startswith("#EXTINF:"):
body.append(s)
- # ignore PROGRAM-DATE-TIME, PLAYLIST-TYPE, MEDIA-SEQUENCE, etc.
continue
- # media URI
body.append(_maybe_localize_uri(s))
frozen = ["#EXTM3U", f"#EXT-X-VERSION:{version}"]
@@ -571,34 +525,53 @@ def _maybe_localize_uri(uri: str) -> str:
out_path.write_text("\n".join(frozen) + "\n", encoding="utf-8")
return out_path
+ # ────────────────────────────────────────────────────────────────────────
+ # Deletion helpers (with hard guard)
+ # ────────────────────────────────────────────────────────────────────────
+ def _can_delete(self, kind: str) -> bool:
+ """
+ Common guard: we only allow delete if stop() was called and at least
+ MIN_DELETE_DELAY seconds have passed.
+ """
+ if self._stopped_at is None:
+ print(f"[HlsRecorder] ⛔ {kind}: stop() was never called → NOT deleting.")
+ return False
+
+ dt = time.time() - self._stopped_at
+ if dt < self.MIN_DELETE_DELAY:
+ print(
+ f"[HlsRecorder] ⏳ {kind}: only {dt:.1f}s since stop "
+ f"(need {self.MIN_DELETE_DELAY}s) → NOT deleting."
+ )
+ return False
+
+ print(f"[HlsRecorder] ✅ {kind}: {dt:.1f}s since stop → OK to delete.")
+ return True
+
def delete_hls_files_only(self) -> None:
"""
Delete all HLS-related files (.ts, .m3u8, .m4s, .aac, .wav, .mp3, .tmp)
from the directory where HLS was recorded.
"""
+ if not self._can_delete("local delete"):
+ return
- # Use the directory saved in stop()
- base = getattr(self, "_stopped_tmpdir", None) or self._tmpdir
+ base = self._stopped_tmpdir or self._tmpdir
if not base or not os.path.isdir(base):
print("[HLS DEBUG] No directory to clean:", base)
return
base_dir = pathlib.Path(base)
- exts_to_delete = {
- ".ts", ".m3u8", ".m4s",
- ".aac", ".wav", ".mp3",
- ".tmp"
- }
+ exts_to_delete = {".ts", ".m3u8", ".m4s", ".aac", ".wav", ".mp3", ".tmp"}
- print("\n[HLS DEBUG] BEFORE DELETE:")
+ print("\n[HLS DEBUG] BEFORE DELETE (local):")
for p in base_dir.iterdir():
print(" -", p.name)
for p in base_dir.iterdir():
if not p.is_file():
continue
-
suffix = p.suffix.lower()
if suffix in exts_to_delete or p.name.endswith(".tmp") or p.name == "init.mp4":
try:
@@ -606,27 +579,26 @@ def delete_hls_files_only(self) -> None:
except Exception as e:
print(f"[HlsRecorder] ⚠️ Failed to delete {p}: {e}")
- print("\n[HLS DEBUG] AFTER DELETE:")
+ print("\n[HLS DEBUG] AFTER DELETE (local):")
for p in base_dir.iterdir():
print(" -", p.name)
print(f"[HlsRecorder] ✅ Deleted HLS files in {base}")
-
+
def delete_remote_hls(self):
"""
Delete ALL uploaded HLS fragments from S3/MinIO except final.mp4.
- Uses S3Client.delete_prefix for efficiency.
+ Uses the underlying boto client for efficiency.
"""
+ if not self._can_delete("remote delete"):
+ return
+
prefix = self.prefix.rstrip("/") + "/"
print("[HLS] Deleting remote HLS under prefix:", prefix)
- # 1️⃣ List objects under this prefix
try:
- resp = self.s3.s3.list_objects_v2(
- Bucket=self.bucket,
- Prefix=prefix
- )
+ resp = self.s3.s3.list_objects_v2(Bucket=self.bucket, Prefix=prefix)
except Exception as e:
print("[HLS] ❌ Failed to list remote objects:", e)
return
@@ -636,7 +608,6 @@ def delete_remote_hls(self):
print("[HLS] (no remote objects found)")
return
- # 2️⃣ Build deletion list EXCEPT final.mp4
to_delete = []
for obj in objects:
key = obj["Key"]
@@ -648,13 +619,8 @@ def delete_remote_hls(self):
print("[HLS] No HLS fragments to delete (only final.mp4 exists).")
return
- # 3️⃣ Batch delete
try:
- self.s3.s3.delete_objects(
- Bucket=self.bucket,
- Delete={"Objects": to_delete}
- )
+ self.s3.s3.delete_objects(Bucket=self.bucket, Delete={"Objects": to_delete})
print(f"[HLS] ✅ Deleted {len(to_delete)} remote HLS objects.")
except Exception as e:
print("[HLS] ❌ Failed remote batch delete:", e)
-
diff --git a/services/vector_service/main.py b/services/vector_service/main.py
index b5122fd4c..1b33713b8 100644
--- a/services/vector_service/main.py
+++ b/services/vector_service/main.py
@@ -1,8 +1,8 @@
-
-from fastapi import FastAPI
+from fastapi import FastAPI, HTTPException
import asyncpg
import numpy as np
import os
+from datetime import datetime, timedelta
app = FastAPI()
@@ -12,102 +12,173 @@
DB_PASS = os.getenv("DB_PASS", "pg123")
DB_NAME = os.getenv("DB_NAME", "missions_db")
+
+# =========================================================
+# STARTUP – CREATE CONNECTION POOL (SAFE)
+# =========================================================
@app.on_event("startup")
async def startup():
- import asyncio
- max_retries = 10
- for attempt in range(max_retries):
- try:
- app.state.conn = await asyncpg.connect(
- user=DB_USER,
- password=DB_PASS,
- database=DB_NAME,
- host=DB_HOST,
- port=DB_PORT
- )
- print("✅ Connected to Postgres")
- return
- except Exception as e:
- print(f"⏳ Waiting for Postgres... attempt {attempt + 1}/{max_retries} ({e})")
- await asyncio.sleep(3)
- raise RuntimeError("❌ Could not connect to Postgres after several attempts")
+ print("⏳ Creating Postgres pool...")
+ try:
+ app.state.pool = await asyncpg.create_pool(
+ user=DB_USER,
+ password=DB_PASS,
+ host=DB_HOST,
+ port=DB_PORT,
+ database=DB_NAME,
+ min_size=1,
+ max_size=10,
+ )
+ # ensure pgvector exists
+ async with app.state.pool.acquire() as conn:
+ await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;")
+
+ print("✅ Postgres connection pool ready.")
+ except Exception as e:
+ print(f"❌ Failed to connect to Postgres: {e}")
+ raise
+
@app.on_event("shutdown")
async def shutdown():
- await app.state.conn.close()
+ await app.state.pool.close()
+ print("🛑 Connection pool closed")
+
+# =========================================================
+# Helper: Validate Embedding Vector
+# =========================================================
+def validate_vector(vec):
+ if not isinstance(vec, list):
+ raise HTTPException(400, "Vector must be a list of floats.")
+ if len(vec) != 5:
+ raise HTTPException(400, "Embedding vector must be length 5.")
+ try:
+ return [float(x) for x in vec]
+ except:
+ raise HTTPException(400, "Vector contains non-numeric values.")
+
+
+
+# =========================================================
+# INSERT VECTOR (sensor_embeddings)
+# =========================================================
@app.post("/add_embedding")
-async def add_embedding(vector: list[float]):
- vec_str = "[" + ",".join(str(x) for x in vector) + "]"
- await app.state.conn.execute("INSERT INTO embeddings (vec) VALUES ($1::vector)", vec_str)
+async def add_embedding(sensor_id: int, vector: list[float]):
+ vec = validate_vector(vector)
+ vec_str = "[" + ",".join(str(x) for x in vec) + "]"
+
+ async with app.state.pool.acquire() as conn:
+ await conn.execute(
+ """
+ INSERT INTO sensor_embeddings (sensor_id, vec)
+ VALUES ($1, $2::vector)
+ """,
+ sensor_id, vec_str
+ )
+
return {"status": "ok"}
+
+
+# =========================================================
+# VECTOR SEARCH
+# =========================================================
@app.post("/search")
async def search(vector: list[float], limit: int = 5):
- vec_str = "[" + ",".join(str(x) for x in vector) + "]"
- rows = await app.state.conn.fetch(
- "SELECT id, vec <-> $1::vector AS distance FROM embeddings ORDER BY vec <-> $1::vector LIMIT $2;",
- vec_str, limit
- )
+ vec = validate_vector(vector)
+ vec_str = "[" + ",".join(str(x) for x in vec) + "]"
+
+ async with app.state.pool.acquire() as conn:
+ rows = await conn.fetch(
+ """
+ SELECT id, vec <-> $1::vector AS distance
+ FROM sensor_embeddings
+ ORDER BY distance
+ LIMIT $2;
+ """,
+ vec_str, limit
+ )
+
return {"results": [{"id": r["id"], "distance": r["distance"]} for r in rows]}
+
+
+# =========================================================
+# GENERATE EMBEDDINGS FROM SENSORS
+# =========================================================
@app.post("/generate_embeddings_from_sensors")
async def generate_embeddings_from_sensors():
- """
- שולף נתונים מטבלת sensors, יוצר מהם embeddings, ושומר אותם ב-DB יחד עם sensor_id.
- """
- rows = await app.state.conn.fetch("SELECT id, sensor_name, sensor_type, lat, lon, status FROM sensors;")
- if not rows:
- return {"message": "No sensors found."}
-
- inserted = 0
- for r in rows:
- sensor_id = r["id"]
- lat = r["lat"] or 0.0
- lon = r["lon"] or 0.0
- name_len = len(r["sensor_name"] or "")
- type_len = len(r["sensor_type"] or "")
- status_score = 1.0 if (r["status"] or "").lower() == "active" else 0.0
-
- # יצירת embedding פשוט
- vector = np.array([lat, lon, name_len, type_len, status_score], dtype=float)
- vec_str = "[" + ",".join(str(x) for x in vector) + "]"
-
- # שמירה ל-DB כולל sensor_id
- await app.state.conn.execute(
- "INSERT INTO embeddings (sensor_id, vec) VALUES ($1, $2::vector)",
- sensor_id, vec_str
+
+ async with app.state.pool.acquire() as conn:
+ rows = await conn.fetch(
+ """
+ SELECT sensor_id, sensor_name, sensor_type, lat, lon, status
+ FROM sensors;
+ """
)
- inserted += 1
- print(f"✅ {inserted} embeddings inserted (with sensor_id).")
- return {"message": f"{inserted} embeddings generated from sensors (with sensor_id)."}
+ if not rows:
+ return {"message": "No sensors found."}
+
+ inserted = 0
+
+ for r in rows:
+ sensor_id = r["sensor_id"]
+ name_len = len(r["sensor_name"] or "")
+ type_len = len(r["sensor_type"] or "")
+ lat = r["lat"] or 0.0
+ lon = r["lon"] or 0.0
+ status_score = 1.0 if (r["status"] or "").lower() == "active" else 0.0
+
+ # vector(5) is still your default
+ vector = np.array([lat, lon, name_len, type_len, status_score], dtype=float)
+ vec_str = "[" + ",".join(str(x) for x in vector) + "]"
+
+ await conn.execute(
+ """
+ INSERT INTO sensor_embeddings (sensor_id, vec)
+ VALUES ($1, $2::vector)
+ """,
+ sensor_id, vec_str
+ )
+ inserted += 1
+
+ print(f"✅ {inserted} embeddings created.")
+ return {"message": f"{inserted} embeddings generated."}
+
+
+
+# =========================================================
+# FIND SIMILAR SENSORS BY SENSOR ID
+# =========================================================
@app.get("/similar_sensors/{sensor_id}")
async def similar_sensors(sensor_id: int, limit: int = 5):
- # שליפת ה-embedding של הסנסור שביקשנו
- row = await app.state.conn.fetchrow(
- "SELECT vec FROM embeddings WHERE sensor_id=$1;", sensor_id
- )
- if not row:
- return {"message": f"No embedding found for sensor_id {sensor_id}"}
- vec = row["vec"]
+ async with app.state.pool.acquire() as conn:
+
+ row = await conn.fetchrow(
+ "SELECT vec FROM sensor_embeddings WHERE sensor_id=$1;",
+ sensor_id
+ )
+ if not row:
+ return {"message": f"No embedding found for sensor_id {sensor_id}"}
+
+ vec = row["vec"]
+
+ results = await conn.fetch(
+ """
+ SELECT e.sensor_id, e.vec <-> $1 AS distance,
+ s.sensor_name, s.sensor_type, s.lat, s.lon, s.status
+ FROM sensor_embeddings e
+ JOIN sensors s ON e.sensor_id = s.sensor_id
+ WHERE e.sensor_id <> $2
+ ORDER BY distance
+ LIMIT $3;
+ """,
+ vec, sensor_id, limit
+ )
- # שליפת הסנסורים הכי דומים לפי המרחק הווקטורי
- results = await app.state.conn.fetch(
- """
- SELECT e.sensor_id, e.vec <-> $1 AS distance,
- s.sensor_name, s.sensor_type, s.lat, s.lon, s.status
- FROM embeddings e
- JOIN sensors s ON e.sensor_id = s.id
- WHERE e.sensor_id <> $2
- ORDER BY distance
- LIMIT $3;
- """,
- vec, sensor_id, limit
- )
-
- # בניית התשובה
return {
"similar_sensors": [
{
@@ -122,8 +193,12 @@ async def similar_sensors(sensor_id: int, limit: int = 5):
for r in results
]
}
-from datetime import datetime, timedelta
+
+
+# =========================================================
+# ADVANCED SIMILARITY SEARCH
+# =========================================================
@app.get("/similar_sensors_advanced")
async def similar_sensors_advanced(
sensor_id: int,
@@ -133,93 +208,93 @@ async def similar_sensors_advanced(
date_filter: str = None,
limit: int = 5
):
- """
- Generic endpoint for flexible similarity queries.
- Supports filters:
- - same_day (bool)
- - same_type (bool)
- - same_status (bool)
- - date_filter ('today', 'yesterday', 'monday', 'last_wednesday', etc.)
- """
-
- # Fetch base sensor
- sensor = await app.state.conn.fetchrow("SELECT * FROM sensors WHERE id=$1;", sensor_id)
- if not sensor:
- return {"message": f"Sensor {sensor_id} not found."}
-
- base_date = sensor["install_date"].date()
- sensor_type = sensor["sensor_type"]
- status = sensor["status"]
-
- # Fetch embedding
- row = await app.state.conn.fetchrow("SELECT vec FROM embeddings WHERE sensor_id=$1;", sensor_id)
- if not row:
- return {"message": f"No embedding found for sensor {sensor_id}."}
- vec = row["vec"]
-
- # --- date_filter support ---
- start_date, end_date = None, None
- today = datetime.utcnow().date()
- weekdays = {
- "monday": 0, "tuesday": 1, "wednesday": 2,
- "thursday": 3, "friday": 4, "saturday": 5, "sunday": 6
- }
- if date_filter:
- df = date_filter.lower()
- if df == "today":
- start_date, end_date = today, today
- elif df == "yesterday":
- start_date = today - timedelta(days=1)
- end_date = start_date
- elif df.startswith("last_"):
- day = df.replace("last_", "")
- if day in weekdays:
- today_weekday = today.weekday()
- days_back = (today_weekday - weekdays[day] + 7) % 7 + 7
- target_day = today - timedelta(days=days_back)
- start_date, end_date = target_day, target_day
- elif df in weekdays:
- today_weekday = today.weekday()
- days_back = (today_weekday - weekdays[df] + 7) % 7
- target_day = today - timedelta(days=days_back)
- start_date, end_date = target_day, target_day
-
- # --- dynamic query ---
- query = """
- SELECT e.sensor_id, e.vec <-> $1 AS distance,
- s.sensor_name, s.sensor_type, s.install_date, s.status
- FROM embeddings e
- JOIN sensors s ON e.sensor_id = s.id
- WHERE e.sensor_id <> $2
- """
- params = [vec, sensor_id]
- param_idx = 3
-
- if same_day:
- query += f" AND DATE(s.install_date) = ${param_idx}"
- params.append(base_date)
- param_idx += 1
-
- if same_type:
- query += f" AND s.sensor_type = ${param_idx}"
- params.append(sensor_type)
- param_idx += 1
-
- if same_status:
- query += f" AND s.status = ${param_idx}"
- params.append(status)
- param_idx += 1
-
- if start_date and end_date:
- query += f" AND DATE(s.install_date) BETWEEN ${param_idx} AND ${param_idx + 1}"
- params.extend([start_date, end_date])
- param_idx += 2
-
- query += f" ORDER BY distance LIMIT ${param_idx};"
- params.append(limit)
-
- results = await app.state.conn.fetch(query, *params)
+ async with app.state.pool.acquire() as conn:
+
+ sensor = await conn.fetchrow("SELECT * FROM sensors WHERE sensor_id=$1;", sensor_id)
+ if not sensor:
+ return {"message": f"Sensor {sensor_id} not found."}
+
+ base_date = sensor["install_date"].date()
+ sensor_type = sensor["sensor_type"]
+ status = sensor["status"]
+
+ row = await conn.fetchrow(
+ "SELECT vec FROM sensor_embeddings WHERE sensor_id=$1;",
+ sensor_id
+ )
+ if not row:
+ return {"message": f"No embedding found for sensor {sensor_id}."}
+
+ vec = row["vec"]
+
+ # --- date logic ---
+ today = datetime.utcnow().date()
+ weekdays = {
+ "monday": 0, "tuesday": 1, "wednesday": 2,
+ "thursday": 3, "friday": 4, "saturday": 5, "sunday": 6
+ }
+
+ start_date = end_date = None
+
+ if date_filter:
+ df = date_filter.lower()
+
+ if df == "today":
+ start_date = end_date = today
+
+ elif df == "yesterday":
+ start_date = end_date = today - timedelta(days=1)
+
+ elif df.startswith("last_"):
+ day = df.replace("last_", "")
+ if day in weekdays:
+ today_wd = today.weekday()
+ days_back = (today_wd - weekdays[day] + 7) % 7 + 7
+ target = today - timedelta(days=days_back)
+ start_date = end_date = target
+
+ elif df in weekdays:
+ today_wd = today.weekday()
+ days_back = (today_wd - weekdays[df] + 7) % 7
+ target = today - timedelta(days=days_back)
+ start_date = end_date = target
+
+ # Base query
+ query = """
+ SELECT e.sensor_id, e.vec <-> $1 AS distance,
+ s.sensor_name, s.sensor_type, s.install_date, s.status
+ FROM sensor_embeddings e
+ JOIN sensors s ON e.sensor_id = s.sensor_id
+ WHERE e.sensor_id <> $2
+ """
+ params = [vec, sensor_id]
+ idx = 3
+
+ if same_day:
+ query += f" AND DATE(s.install_date) = ${idx}"
+ params.append(base_date)
+ idx += 1
+
+ if same_type:
+ query += f" AND s.sensor_type = ${idx}"
+ params.append(sensor_type)
+ idx += 1
+
+ if same_status:
+ query += f" AND s.status = ${idx}"
+ params.append(status)
+ idx += 1
+
+ if start_date and end_date:
+ query += f" AND DATE(s.install_date) BETWEEN ${idx} AND ${idx+1}"
+ params.extend([start_date, end_date])
+ idx += 2
+
+ query += f" ORDER BY distance LIMIT ${idx}"
+ params.append(limit)
+
+ results = await conn.fetch(query, *params)
return {
"base_sensor": {
diff --git a/services/vector_service/vector_service/main.py b/services/vector_service/vector_service/main.py
index b5122fd4c..1b33713b8 100644
--- a/services/vector_service/vector_service/main.py
+++ b/services/vector_service/vector_service/main.py
@@ -1,8 +1,8 @@
-
-from fastapi import FastAPI
+from fastapi import FastAPI, HTTPException
import asyncpg
import numpy as np
import os
+from datetime import datetime, timedelta
app = FastAPI()
@@ -12,102 +12,173 @@
DB_PASS = os.getenv("DB_PASS", "pg123")
DB_NAME = os.getenv("DB_NAME", "missions_db")
+
+# =========================================================
+# STARTUP – CREATE CONNECTION POOL (SAFE)
+# =========================================================
@app.on_event("startup")
async def startup():
- import asyncio
- max_retries = 10
- for attempt in range(max_retries):
- try:
- app.state.conn = await asyncpg.connect(
- user=DB_USER,
- password=DB_PASS,
- database=DB_NAME,
- host=DB_HOST,
- port=DB_PORT
- )
- print("✅ Connected to Postgres")
- return
- except Exception as e:
- print(f"⏳ Waiting for Postgres... attempt {attempt + 1}/{max_retries} ({e})")
- await asyncio.sleep(3)
- raise RuntimeError("❌ Could not connect to Postgres after several attempts")
+ print("⏳ Creating Postgres pool...")
+ try:
+ app.state.pool = await asyncpg.create_pool(
+ user=DB_USER,
+ password=DB_PASS,
+ host=DB_HOST,
+ port=DB_PORT,
+ database=DB_NAME,
+ min_size=1,
+ max_size=10,
+ )
+ # ensure pgvector exists
+ async with app.state.pool.acquire() as conn:
+ await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;")
+
+ print("✅ Postgres connection pool ready.")
+ except Exception as e:
+ print(f"❌ Failed to connect to Postgres: {e}")
+ raise
+
@app.on_event("shutdown")
async def shutdown():
- await app.state.conn.close()
+ await app.state.pool.close()
+ print("🛑 Connection pool closed")
+
+# =========================================================
+# Helper: Validate Embedding Vector
+# =========================================================
+def validate_vector(vec):
+ if not isinstance(vec, list):
+ raise HTTPException(400, "Vector must be a list of floats.")
+ if len(vec) != 5:
+ raise HTTPException(400, "Embedding vector must be length 5.")
+ try:
+ return [float(x) for x in vec]
+ except:
+ raise HTTPException(400, "Vector contains non-numeric values.")
+
+
+
+# =========================================================
+# INSERT VECTOR (sensor_embeddings)
+# =========================================================
@app.post("/add_embedding")
-async def add_embedding(vector: list[float]):
- vec_str = "[" + ",".join(str(x) for x in vector) + "]"
- await app.state.conn.execute("INSERT INTO embeddings (vec) VALUES ($1::vector)", vec_str)
+async def add_embedding(sensor_id: int, vector: list[float]):
+ vec = validate_vector(vector)
+ vec_str = "[" + ",".join(str(x) for x in vec) + "]"
+
+ async with app.state.pool.acquire() as conn:
+ await conn.execute(
+ """
+ INSERT INTO sensor_embeddings (sensor_id, vec)
+ VALUES ($1, $2::vector)
+ """,
+ sensor_id, vec_str
+ )
+
return {"status": "ok"}
+
+
+# =========================================================
+# VECTOR SEARCH
+# =========================================================
@app.post("/search")
async def search(vector: list[float], limit: int = 5):
- vec_str = "[" + ",".join(str(x) for x in vector) + "]"
- rows = await app.state.conn.fetch(
- "SELECT id, vec <-> $1::vector AS distance FROM embeddings ORDER BY vec <-> $1::vector LIMIT $2;",
- vec_str, limit
- )
+ vec = validate_vector(vector)
+ vec_str = "[" + ",".join(str(x) for x in vec) + "]"
+
+ async with app.state.pool.acquire() as conn:
+ rows = await conn.fetch(
+ """
+ SELECT id, vec <-> $1::vector AS distance
+ FROM sensor_embeddings
+ ORDER BY distance
+ LIMIT $2;
+ """,
+ vec_str, limit
+ )
+
return {"results": [{"id": r["id"], "distance": r["distance"]} for r in rows]}
+
+
+# =========================================================
+# GENERATE EMBEDDINGS FROM SENSORS
+# =========================================================
@app.post("/generate_embeddings_from_sensors")
async def generate_embeddings_from_sensors():
- """
- שולף נתונים מטבלת sensors, יוצר מהם embeddings, ושומר אותם ב-DB יחד עם sensor_id.
- """
- rows = await app.state.conn.fetch("SELECT id, sensor_name, sensor_type, lat, lon, status FROM sensors;")
- if not rows:
- return {"message": "No sensors found."}
-
- inserted = 0
- for r in rows:
- sensor_id = r["id"]
- lat = r["lat"] or 0.0
- lon = r["lon"] or 0.0
- name_len = len(r["sensor_name"] or "")
- type_len = len(r["sensor_type"] or "")
- status_score = 1.0 if (r["status"] or "").lower() == "active" else 0.0
-
- # יצירת embedding פשוט
- vector = np.array([lat, lon, name_len, type_len, status_score], dtype=float)
- vec_str = "[" + ",".join(str(x) for x in vector) + "]"
-
- # שמירה ל-DB כולל sensor_id
- await app.state.conn.execute(
- "INSERT INTO embeddings (sensor_id, vec) VALUES ($1, $2::vector)",
- sensor_id, vec_str
+
+ async with app.state.pool.acquire() as conn:
+ rows = await conn.fetch(
+ """
+ SELECT sensor_id, sensor_name, sensor_type, lat, lon, status
+ FROM sensors;
+ """
)
- inserted += 1
- print(f"✅ {inserted} embeddings inserted (with sensor_id).")
- return {"message": f"{inserted} embeddings generated from sensors (with sensor_id)."}
+ if not rows:
+ return {"message": "No sensors found."}
+
+ inserted = 0
+
+ for r in rows:
+ sensor_id = r["sensor_id"]
+ name_len = len(r["sensor_name"] or "")
+ type_len = len(r["sensor_type"] or "")
+ lat = r["lat"] or 0.0
+ lon = r["lon"] or 0.0
+ status_score = 1.0 if (r["status"] or "").lower() == "active" else 0.0
+
+ # vector(5) is still your default
+ vector = np.array([lat, lon, name_len, type_len, status_score], dtype=float)
+ vec_str = "[" + ",".join(str(x) for x in vector) + "]"
+
+ await conn.execute(
+ """
+ INSERT INTO sensor_embeddings (sensor_id, vec)
+ VALUES ($1, $2::vector)
+ """,
+ sensor_id, vec_str
+ )
+ inserted += 1
+
+ print(f"✅ {inserted} embeddings created.")
+ return {"message": f"{inserted} embeddings generated."}
+
+
+
+# =========================================================
+# FIND SIMILAR SENSORS BY SENSOR ID
+# =========================================================
@app.get("/similar_sensors/{sensor_id}")
async def similar_sensors(sensor_id: int, limit: int = 5):
- # שליפת ה-embedding של הסנסור שביקשנו
- row = await app.state.conn.fetchrow(
- "SELECT vec FROM embeddings WHERE sensor_id=$1;", sensor_id
- )
- if not row:
- return {"message": f"No embedding found for sensor_id {sensor_id}"}
- vec = row["vec"]
+ async with app.state.pool.acquire() as conn:
+
+ row = await conn.fetchrow(
+ "SELECT vec FROM sensor_embeddings WHERE sensor_id=$1;",
+ sensor_id
+ )
+ if not row:
+ return {"message": f"No embedding found for sensor_id {sensor_id}"}
+
+ vec = row["vec"]
+
+ results = await conn.fetch(
+ """
+ SELECT e.sensor_id, e.vec <-> $1 AS distance,
+ s.sensor_name, s.sensor_type, s.lat, s.lon, s.status
+ FROM sensor_embeddings e
+ JOIN sensors s ON e.sensor_id = s.sensor_id
+ WHERE e.sensor_id <> $2
+ ORDER BY distance
+ LIMIT $3;
+ """,
+ vec, sensor_id, limit
+ )
- # שליפת הסנסורים הכי דומים לפי המרחק הווקטורי
- results = await app.state.conn.fetch(
- """
- SELECT e.sensor_id, e.vec <-> $1 AS distance,
- s.sensor_name, s.sensor_type, s.lat, s.lon, s.status
- FROM embeddings e
- JOIN sensors s ON e.sensor_id = s.id
- WHERE e.sensor_id <> $2
- ORDER BY distance
- LIMIT $3;
- """,
- vec, sensor_id, limit
- )
-
- # בניית התשובה
return {
"similar_sensors": [
{
@@ -122,8 +193,12 @@ async def similar_sensors(sensor_id: int, limit: int = 5):
for r in results
]
}
-from datetime import datetime, timedelta
+
+
+# =========================================================
+# ADVANCED SIMILARITY SEARCH
+# =========================================================
@app.get("/similar_sensors_advanced")
async def similar_sensors_advanced(
sensor_id: int,
@@ -133,93 +208,93 @@ async def similar_sensors_advanced(
date_filter: str = None,
limit: int = 5
):
- """
- Generic endpoint for flexible similarity queries.
- Supports filters:
- - same_day (bool)
- - same_type (bool)
- - same_status (bool)
- - date_filter ('today', 'yesterday', 'monday', 'last_wednesday', etc.)
- """
-
- # Fetch base sensor
- sensor = await app.state.conn.fetchrow("SELECT * FROM sensors WHERE id=$1;", sensor_id)
- if not sensor:
- return {"message": f"Sensor {sensor_id} not found."}
-
- base_date = sensor["install_date"].date()
- sensor_type = sensor["sensor_type"]
- status = sensor["status"]
-
- # Fetch embedding
- row = await app.state.conn.fetchrow("SELECT vec FROM embeddings WHERE sensor_id=$1;", sensor_id)
- if not row:
- return {"message": f"No embedding found for sensor {sensor_id}."}
- vec = row["vec"]
-
- # --- date_filter support ---
- start_date, end_date = None, None
- today = datetime.utcnow().date()
- weekdays = {
- "monday": 0, "tuesday": 1, "wednesday": 2,
- "thursday": 3, "friday": 4, "saturday": 5, "sunday": 6
- }
- if date_filter:
- df = date_filter.lower()
- if df == "today":
- start_date, end_date = today, today
- elif df == "yesterday":
- start_date = today - timedelta(days=1)
- end_date = start_date
- elif df.startswith("last_"):
- day = df.replace("last_", "")
- if day in weekdays:
- today_weekday = today.weekday()
- days_back = (today_weekday - weekdays[day] + 7) % 7 + 7
- target_day = today - timedelta(days=days_back)
- start_date, end_date = target_day, target_day
- elif df in weekdays:
- today_weekday = today.weekday()
- days_back = (today_weekday - weekdays[df] + 7) % 7
- target_day = today - timedelta(days=days_back)
- start_date, end_date = target_day, target_day
-
- # --- dynamic query ---
- query = """
- SELECT e.sensor_id, e.vec <-> $1 AS distance,
- s.sensor_name, s.sensor_type, s.install_date, s.status
- FROM embeddings e
- JOIN sensors s ON e.sensor_id = s.id
- WHERE e.sensor_id <> $2
- """
- params = [vec, sensor_id]
- param_idx = 3
-
- if same_day:
- query += f" AND DATE(s.install_date) = ${param_idx}"
- params.append(base_date)
- param_idx += 1
-
- if same_type:
- query += f" AND s.sensor_type = ${param_idx}"
- params.append(sensor_type)
- param_idx += 1
-
- if same_status:
- query += f" AND s.status = ${param_idx}"
- params.append(status)
- param_idx += 1
-
- if start_date and end_date:
- query += f" AND DATE(s.install_date) BETWEEN ${param_idx} AND ${param_idx + 1}"
- params.extend([start_date, end_date])
- param_idx += 2
-
- query += f" ORDER BY distance LIMIT ${param_idx};"
- params.append(limit)
-
- results = await app.state.conn.fetch(query, *params)
+ async with app.state.pool.acquire() as conn:
+
+ sensor = await conn.fetchrow("SELECT * FROM sensors WHERE sensor_id=$1;", sensor_id)
+ if not sensor:
+ return {"message": f"Sensor {sensor_id} not found."}
+
+ base_date = sensor["install_date"].date()
+ sensor_type = sensor["sensor_type"]
+ status = sensor["status"]
+
+ row = await conn.fetchrow(
+ "SELECT vec FROM sensor_embeddings WHERE sensor_id=$1;",
+ sensor_id
+ )
+ if not row:
+ return {"message": f"No embedding found for sensor {sensor_id}."}
+
+ vec = row["vec"]
+
+ # --- date logic ---
+ today = datetime.utcnow().date()
+ weekdays = {
+ "monday": 0, "tuesday": 1, "wednesday": 2,
+ "thursday": 3, "friday": 4, "saturday": 5, "sunday": 6
+ }
+
+ start_date = end_date = None
+
+ if date_filter:
+ df = date_filter.lower()
+
+ if df == "today":
+ start_date = end_date = today
+
+ elif df == "yesterday":
+ start_date = end_date = today - timedelta(days=1)
+
+ elif df.startswith("last_"):
+ day = df.replace("last_", "")
+ if day in weekdays:
+ today_wd = today.weekday()
+ days_back = (today_wd - weekdays[day] + 7) % 7 + 7
+ target = today - timedelta(days=days_back)
+ start_date = end_date = target
+
+ elif df in weekdays:
+ today_wd = today.weekday()
+ days_back = (today_wd - weekdays[df] + 7) % 7
+ target = today - timedelta(days=days_back)
+ start_date = end_date = target
+
+ # Base query
+ query = """
+ SELECT e.sensor_id, e.vec <-> $1 AS distance,
+ s.sensor_name, s.sensor_type, s.install_date, s.status
+ FROM sensor_embeddings e
+ JOIN sensors s ON e.sensor_id = s.sensor_id
+ WHERE e.sensor_id <> $2
+ """
+ params = [vec, sensor_id]
+ idx = 3
+
+ if same_day:
+ query += f" AND DATE(s.install_date) = ${idx}"
+ params.append(base_date)
+ idx += 1
+
+ if same_type:
+ query += f" AND s.sensor_type = ${idx}"
+ params.append(sensor_type)
+ idx += 1
+
+ if same_status:
+ query += f" AND s.status = ${idx}"
+ params.append(status)
+ idx += 1
+
+ if start_date and end_date:
+ query += f" AND DATE(s.install_date) BETWEEN ${idx} AND ${idx+1}"
+ params.extend([start_date, end_date])
+ idx += 2
+
+ query += f" ORDER BY distance LIMIT ${idx}"
+ params.append(limit)
+
+ results = await conn.fetch(query, *params)
return {
"base_sensor": {
diff --git a/simulators/security/data_publisher.py b/simulators/security/data_publisher.py
index fe87a141e..30eb929de 100644
--- a/simulators/security/data_publisher.py
+++ b/simulators/security/data_publisher.py
@@ -1,128 +1,3 @@
-# #!/usr/bin/env python3
-# import os
-# import json
-# import time
-# import uuid
-# import hashlib
-# import mimetypes
-# from datetime import datetime, timezone
-# import paho.mqtt.client as mqtt
-
-# # ---- Configuration ----
-# IMAGES_DIR = os.getenv("IMAGES_DIR", "/data/images")
-# META_DIR = os.getenv("META_DIR", "/data/metadata")
-
-# MQTT_HOST_DATA = os.getenv("MQTT_HOST_DATA", "large-mosquitto")
-# MQTT_PORT_DATA = int(os.getenv("MQTT_PORT_DATA", "1885"))
-# MQTT_TOPIC_DATA = os.getenv("MQTT_TOPIC_DATA", "MQTT/imagery/security")
-
-# MQTT_HOST_META = os.getenv("MQTT_HOST_META", "mosquitto")
-# MQTT_PORT_META = int(os.getenv("MQTT_PORT_META", "1883"))
-# MQTT_TOPIC_META = os.getenv("MQTT_TOPIC_META", "dev-security-images-keys")
-
-# CAMERA_ID = os.getenv("CAMERA_ID", "CAM-482A")
-# INTERVAL_CHECK = int(os.getenv("INTERVAL_CHECK", "10"))
-# INTERVAL_PUBLISH = int(os.getenv("INTERVAL_PUBLISH", "10"))
-# QOS = int(os.getenv("MQTT_QOS", "1"))
-
-# # ---- MQTT Setup ----
-# client_images = mqtt.Client(client_id=f"camera-simulator-img-{uuid.uuid4().hex[:6]}")
-# client_images.connect(MQTT_HOST_DATA, MQTT_PORT_DATA, keepalive=60)
-# client_images.loop_start()
-
-# client_meta = mqtt.Client(client_id=f"camera-simulator-meta-{uuid.uuid4().hex[:6]}")
-# client_meta.connect(MQTT_HOST_META, MQTT_PORT_META, keepalive=60)
-# client_meta.loop_start()
-
-# # ---- Helpers ----
-# def sha256_hex(path: str):
-# with open(path, "rb") as f:
-# return hashlib.sha256(f.read()).hexdigest()
-
-# def iso_utc():
-# return datetime.utcnow().replace(tzinfo=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
-
-# def load_metadata_for(img_name):
-# base = os.path.splitext(os.path.basename(img_name))[0]
-# meta_path = os.path.join(META_DIR, f"{base}.json")
-# if os.path.exists(meta_path):
-# with open(meta_path, "r", encoding="utf-8") as f:
-# return json.load(f)
-# return {}
-
-# def generate_new_name(ext=".jpg",camera_id = CAMERA_ID):
-# timestamp = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
-# return f"{camera_id}_{timestamp}{ext}"
-
-# # ---- Core ----
-# def publish_image(image_path):
-# ext = os.path.splitext(image_path)[1].lower()
-# meta_data = load_metadata_for(image_path)
-# camera_id = meta_data["camera_id"] or CAMERA_ID
-# new_file_name = generate_new_name(ext,camera_id)
-# meta_data["file_name"] = new_file_name
-# meta_data["capture_time"] = iso_utc()
-
-# with open(image_path, "rb") as f:
-# data = f.read()
-
-# timestamp_ms = int(time.time() * 1000)
-
-# # Automatically detect content type based on file extension
-# guessed_type, _ = mimetypes.guess_type(image_path)
-# if guessed_type:
-# content_type = guessed_type.replace("/", "_") # e.g. image/jpeg → image_jpeg
-# else:
-# content_type = "application_octet-stream"
-
-# topic = f"{MQTT_TOPIC_DATA}/{timestamp_ms}/{content_type}/{new_file_name}"
-# client_images.publish(topic, payload=data, qos=QOS)
-# payload = json.dumps(meta_data, ensure_ascii=False)
-# client_meta.publish(MQTT_TOPIC_META, payload, qos=QOS)
-
-# print(f"Published image: {new_file_name} | topic: {topic} | type: {guessed_type}")
-
-# def get_all_images():
-# exts = {".jpg", ".jpeg", ".png", ".tif"}
-# return [os.path.join(IMAGES_DIR, f)
-# for f in sorted(os.listdir(IMAGES_DIR))
-# if os.path.splitext(f)[1].lower() in exts]
-
-# def main():
-# print("Camera simulator started")
-# print(f" Images broker: {MQTT_HOST_DATA}:{MQTT_PORT_DATA} | topic: {MQTT_TOPIC_DATA}")
-# print(f" Metadata broker: {MQTT_HOST_META}:{MQTT_PORT_META} | topic: {MQTT_TOPIC_META}")
-# sent_hashes = set()
-
-# while True:
-# all_imgs = get_all_images()
-# new_imgs = [p for p in all_imgs if sha256_hex(p) not in sent_hashes]
-
-# if not new_imgs:
-# print("No new images. Checking again...")
-# sent_hashes.clear()
-# time.sleep(INTERVAL_CHECK)
-# continue
-
-# for img in new_imgs:
-# publish_image(img)
-# sent_hashes.add(sha256_hex(img))
-# time.sleep(INTERVAL_PUBLISH)
-
-# print("Cycle completed. Restarting...")
-
-# if __name__ == "__main__":
-# try:
-# main()
-# except KeyboardInterrupt:
-# print("Stopped manually.")
-# client_images.loop_stop()
-# client_images.disconnect()
-# client_meta.loop_stop()
-# client_meta.disconnect()
-
-
-
#!/usr/bin/env python3
import os
@@ -148,7 +23,7 @@
MQTT_PORT_META = int(os.getenv("MQTT_PORT_META", "1883"))
MQTT_TOPIC_META = os.getenv("MQTT_TOPIC_META", "dev-security-images-keys")
-INTERVAL_PUBLISH = int(os.getenv("INTERVAL_PUBLISH", "10"))
+INTERVAL_PUBLISH = 0.8
QOS = int(os.getenv("MQTT_QOS", "1"))