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""" + + + + + + + + + + + + + + + {rows} + +
IDNameTypeInstall DateActiveDistance
+ + + """ + + 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"))