import os import cv2 import sys import time import numpy as np import serial from serial.tools import list_ports import subprocess from collections import deque from datetime import datetime, timedelta import mysql.connector # Connect to database conn = mysql.connector.connect( host="localhost", user="root", password="phytomatrix", database="phytomatrixdb" ) cursor = conn.cursor() # SQL UPDATE query query = "UPDATE statuses SET status = %s WHERE id = %s" values = (".667", 11) # Execute query cursor.execute(query, values) # Commit changes from PyQt5.QtWidgets import ( QApplication, QMainWindow, QMessageBox, QWidget, QPushButton, QLabel, QVBoxLayout, QHBoxLayout, QStackedWidget, QSlider, QSizePolicy, QSpacerItem ) from PyQt5.QtCore import Qt, QTimer, QThread, pyqtSignal, QRect, QPoint from PyQt5.QtGui import QFont, QPixmap, QImage, QPainter, QPen, QColor, QCursor import matplotlib matplotlib.use('Qt5Agg') from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.figure import Figure import matplotlib.dates as mdates sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from shared import config if sys.platform == "linux": OS = config.OS_LINUX elif sys.platform == "win32": OS = config.OS_WINDOWS elif sys.platform == "darwin": OS = config.OS_MACOS else: raise Exception("Unsupported OS") # ─── Paths (relative to python/main/ working directory) ───────────────────── ROI_FILE = "roi.txt" SENSITIVITY_FILE = "sensitivity.txt" LOG_DIR = "../../log" # ─── Defaults ──────────────────────────────────────────────────────────────── DEFAULT_ROI = [0.25, 0.25, 0.75, 0.75] # normalised x1, y1, x2, y2 DEFAULT_SENSITIVITY = 50 # 0-100 HANDLE_SIZE = 14 # px, ROI drag-handle square GREEN_UPDATE_MS = 10000 # update every 10 s GREEN_AVG_WINDOW = 5.0 # average last 5 s of frames # ═══════════════════════════════════════════════════════════════════════════════ # Utility helpers # ═══════════════════════════════════════════════════════════════════════════════ def load_roi(): """Load ROI from file or return default.""" try: with open(ROI_FILE, 'r') as f: parts = f.read().strip().split(',') roi = [float(x) for x in parts] if len(roi) == 4: return roi except Exception: pass return list(DEFAULT_ROI) def save_roi(roi): with open(ROI_FILE, 'w') as f: f.write(','.join(f'{v:.6f}' for v in roi)) def load_sensitivity(): try: with open(SENSITIVITY_FILE, 'r') as f: return int(float(f.read().strip())) except Exception: return DEFAULT_SENSITIVITY def save_sensitivity(value): with open(SENSITIVITY_FILE, 'w') as f: f.write(str(value)) def compute_green(frame, roi_norm, sensitivity_pct): """ Compute average saturated-green score inside the ROI. For each pixel the score is: clamp(1 − |hue − 60| / hue_width, 0, 1) × (saturation / 255) sensitivity_pct (0–100) widens the hue acceptance window so the user can tune what counts as "green". Returns a float in [0.0, 1.0]. """ h_img, w_img = frame.shape[:2] x1 = max(0, int(roi_norm[0] * w_img)) y1 = max(0, int(roi_norm[1] * h_img)) x2 = min(w_img, int(roi_norm[2] * w_img)) y2 = min(h_img, int(roi_norm[3] * h_img)) if x2 <= x1 or y2 <= y1: return 0.0 roi_bgr = frame[y1:y2, x1:x2] hsv = cv2.cvtColor(roi_bgr, cv2.COLOR_BGR2HSV) h = hsv[:, :, 0].astype(np.float32) # 0-179 s = hsv[:, :, 1].astype(np.float32) / 255.0 sensitivity = sensitivity_pct / 100.0 # Hue distance from pure green (60 in OpenCV's 0-179 scale) hue_dist = np.minimum(np.abs(h - 60), 180 - np.abs(h - 60)) hue_width = 35 # fixed ~35° window for green detection hue_score = np.clip(1.0 - hue_dist / hue_width, 0, 1) raw = float(np.mean(hue_score * s)) # raw green score (usually low) # Sensitivity acts as a gain multiplier: # slider 0 → 1× (raw), slider 50 → 2.5×, slider 100 → 5× gain = 1.0 + sensitivity * 4.0 # 1.0 … 5.0 return min(raw * gain, 1.0) def log_value(value): """Append a single green reading to the daily log folder.""" now = datetime.now() date_dir = os.path.join(LOG_DIR, now.strftime('%Y-%m-%d')) os.makedirs(date_dir, exist_ok=True) filename = now.strftime('%H-%M-%S') + '.txt' filepath = os.path.join(date_dir, filename) with open(filepath, 'w') as f: f.write(f'{value:.6f}') def read_log_data(time_range): """ Read logged values for a time range. Returns list of (datetime, float) sorted by time. """ now = datetime.now() deltas = { '5min': timedelta(minutes=5), '1hour': timedelta(hours=1), '1day': timedelta(days=1), '1week': timedelta(weeks=1), } start = now - deltas.get(time_range, timedelta(hours=1)) data = [] current_date = start.date() end_date = now.date() while current_date <= end_date: date_dir = os.path.join(LOG_DIR, current_date.strftime('%Y-%m-%d')) if os.path.isdir(date_dir): for fname in sorted(os.listdir(date_dir)): if not fname.endswith('.txt'): continue try: time_str = fname[:-4] dt = datetime.strptime( f"{current_date.strftime('%Y-%m-%d')} {time_str}", '%Y-%m-%d %H-%M-%S', ) if start <= dt <= now: with open(os.path.join(date_dir, fname)) as f: val = float(f.read().strip()) data.append((dt, val)) except (ValueError, IOError): continue current_date += timedelta(days=1) return data # ═══════════════════════════════════════════════════════════════════════════════ # LivePreviewWidget – video display + interactive ROI drawing / editing # ═══════════════════════════════════════════════════════════════════════════════ class LivePreviewWidget(QWidget): """ Shows the camera frame with a green ROI overlay. Supports interactive ROI drawing (click-drag) and editing (handles + move). """ def __init__(self, parent=None): super().__init__(parent) self.pixmap = None self.roi = list(DEFAULT_ROI) # normalised [x1, y1, x2, y2] self.image_rect = QRect() # Editing state machine self.editing = False self.drawing = False # first click-drag to create rect self.draw_start = None self.drag_handle = None # 'tl','t','tr','r','br','b','bl','l','move' self.drag_offset = None self.roi_backup = None # for cancel self.setMouseTracking(True) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.setMinimumSize(320, 240) # ── public API ── def setFrame(self, frame): """Accept a new BGR frame from the video thread.""" rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) h, w, ch = rgb.shape qimg = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888) self.pixmap = QPixmap.fromImage(qimg) self.update() def startEditing(self): self.roi_backup = list(self.roi) self.editing = True self.drawing = True self.setCursor(QCursor(Qt.CrossCursor)) self.update() def stopEditing(self, save=True): if not save and self.roi_backup is not None: self.roi = list(self.roi_backup) self.editing = False self.drawing = False self.drag_handle = None self.roi_backup = None self.setCursor(QCursor(Qt.ArrowCursor)) self.update() # ── coordinate helpers ── def _widget_to_norm(self, pos): ir = self.image_rect if ir.width() == 0 or ir.height() == 0: return (0.5, 0.5) x = max(0.0, min(1.0, (pos.x() - ir.x()) / ir.width())) y = max(0.0, min(1.0, (pos.y() - ir.y()) / ir.height())) return (x, y) def _norm_to_widget(self, nx, ny): ir = self.image_rect return QPoint(int(ir.x() + nx * ir.width()), int(ir.y() + ny * ir.height())) def _roi_widget_rect(self): p1 = self._norm_to_widget(self.roi[0], self.roi[1]) p2 = self._norm_to_widget(self.roi[2], self.roi[3]) return QRect(p1, p2) def _get_handles(self): """8 handle rects (corners + edge midpoints) in widget coords.""" r = self._roi_widget_rect() hs = HANDLE_SIZE // 2 cx = (r.left() + r.right()) // 2 cy = (r.top() + r.bottom()) // 2 return { 'tl': QRect(r.left() - hs, r.top() - hs, HANDLE_SIZE, HANDLE_SIZE), 'tr': QRect(r.right() - hs, r.top() - hs, HANDLE_SIZE, HANDLE_SIZE), 'bl': QRect(r.left() - hs, r.bottom() - hs, HANDLE_SIZE, HANDLE_SIZE), 'br': QRect(r.right() - hs, r.bottom() - hs, HANDLE_SIZE, HANDLE_SIZE), 't': QRect(cx - hs, r.top() - hs, HANDLE_SIZE, HANDLE_SIZE), 'b': QRect(cx - hs, r.bottom() - hs, HANDLE_SIZE, HANDLE_SIZE), 'l': QRect(r.left() - hs, cy - hs, HANDLE_SIZE, HANDLE_SIZE), 'r': QRect(r.right() - hs, cy - hs, HANDLE_SIZE, HANDLE_SIZE), } def _hit_test(self, pos): """Return handle name, 'move', or None.""" if self.drawing: return None for name, rect in self._get_handles().items(): if rect.contains(pos): return name if self._roi_widget_rect().contains(pos): return 'move' return None # ── painting ── def paintEvent(self, event): painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) painter.fillRect(self.rect(), QColor(0, 0, 0)) if self.pixmap: scaled = self.pixmap.scaled(self.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) x = (self.width() - scaled.width()) // 2 y = (self.height() - scaled.height()) // 2 self.image_rect = QRect(x, y, scaled.width(), scaled.height()) painter.drawPixmap(x, y, scaled) # Green ROI rectangle if self.roi and not self.image_rect.isEmpty(): roi_rect = self._roi_widget_rect() painter.setPen(QPen(QColor(0, 255, 0), 2)) painter.setBrush(Qt.NoBrush) painter.drawRect(roi_rect) # Handles while editing (after initial draw) if self.editing and not self.drawing: painter.setBrush(QColor(255, 255, 255)) painter.setPen(QPen(QColor(0, 200, 0), 1)) for rect in self._get_handles().values(): painter.drawRect(rect) painter.end() # ── mouse interaction ── def mousePressEvent(self, event): if not self.editing or event.button() != Qt.LeftButton: return if self.drawing: self.draw_start = self._widget_to_norm(event.pos()) nx, ny = self.draw_start self.roi = [nx, ny, nx, ny] return hit = self._hit_test(event.pos()) if hit == 'move': self.drag_handle = 'move' self.drag_offset = self._widget_to_norm(event.pos()) elif hit: self.drag_handle = hit def mouseMoveEvent(self, event): if not self.editing: return nx, ny = self._widget_to_norm(event.pos()) # Drawing a new rectangle if self.drawing and self.draw_start is not None: sx, sy = self.draw_start self.roi = [min(sx, nx), min(sy, ny), max(sx, nx), max(sy, ny)] self.update() return # Dragging a handle or the whole rectangle if self.drag_handle: x1, y1, x2, y2 = self.roi if self.drag_handle == 'move': dx = nx - self.drag_offset[0] dy = ny - self.drag_offset[1] w, h = x2 - x1, y2 - y1 new_x1 = max(0, min(1 - w, x1 + dx)) new_y1 = max(0, min(1 - h, y1 + dy)) self.roi = [new_x1, new_y1, new_x1 + w, new_y1 + h] self.drag_offset = (nx, ny) elif self.drag_handle == 'tl': self.roi = [min(nx, x2 - 0.01), min(ny, y2 - 0.01), x2, y2] elif self.drag_handle == 'tr': self.roi = [x1, min(ny, y2 - 0.01), max(nx, x1 + 0.01), y2] elif self.drag_handle == 'bl': self.roi = [min(nx, x2 - 0.01), y1, x2, max(ny, y1 + 0.01)] elif self.drag_handle == 'br': self.roi = [x1, y1, max(nx, x1 + 0.01), max(ny, y1 + 0.01)] elif self.drag_handle == 't': self.roi = [x1, min(ny, y2 - 0.01), x2, y2] elif self.drag_handle == 'b': self.roi = [x1, y1, x2, max(ny, y1 + 0.01)] elif self.drag_handle == 'l': self.roi = [min(nx, x2 - 0.01), y1, x2, y2] elif self.drag_handle == 'r': self.roi = [x1, y1, max(nx, x1 + 0.01), y2] self.update() return # Hover cursor feedback hit = self._hit_test(event.pos()) cursors = { 'tl': Qt.SizeFDiagCursor, 'br': Qt.SizeFDiagCursor, 'tr': Qt.SizeBDiagCursor, 'bl': Qt.SizeBDiagCursor, 't': Qt.SizeVerCursor, 'b': Qt.SizeVerCursor, 'l': Qt.SizeHorCursor, 'r': Qt.SizeHorCursor, 'move': Qt.SizeAllCursor, } self.setCursor(cursors.get(hit, Qt.ArrowCursor)) def mouseReleaseEvent(self, event): if not self.editing: return if self.drawing and self.draw_start is not None: self.drawing = False self.draw_start = None # enforce minimum size w = abs(self.roi[2] - self.roi[0]) h = abs(self.roi[3] - self.roi[1]) if w < 0.02 or h < 0.02: self.roi = list(DEFAULT_ROI) self.setCursor(QCursor(Qt.ArrowCursor)) self.update() return self.drag_handle = None # ═══════════════════════════════════════════════════════════════════════════════ # Background threads # ═══════════════════════════════════════════════════════════════════════════════ class VideoThread(QThread): frame = pyqtSignal(np.ndarray) def __init__(self, parent=None): super().__init__(parent) def run(self): if (OS in config.OS_DESKTOP) and config.CAM_USE_FILE: img = cv2.imread(config.CAM_TEST_FILE) if img is None: print("Test image not found:", config.CAM_TEST_FILE) return while True: time.sleep(0.033) self.frame.emit(img.copy()) return # Auto-detect camera index cam_index = None for idx in range(10): try: cap = cv2.VideoCapture(idx) ret, test_frame = cap.read() if ret and test_frame is not None and test_frame.shape[0] > 0: cam_index = idx cap.release() break cap.release() except Exception: continue if cam_index is None: print("No camera found") return cap = cv2.VideoCapture(cam_index) cap.set(cv2.CAP_PROP_FRAME_WIDTH, config.CAMERA_WIDTH) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, config.CAMERA_HEIGHT) while True: time.sleep(0.01) ret, f = cap.read() if not ret: time.sleep(1) continue self.frame.emit(f) class SerialListenerThread(QThread): message = pyqtSignal(str) def __init__(self, parent=None): super().__init__(parent) self.parent_window = parent def run(self): while True: time.sleep(0.01) ser = self.parent_window.serialObj if ser is not None: try: if ser.in_waiting > 0: line = ser.readline().decode('utf-8').strip() print("Serial:", line) self.message.emit(line) except Exception: time.sleep(1) else: time.sleep(1) class UpdateThread(QThread): done = pyqtSignal(dict) def run(self): try: result = os.popen('cd ../../ && git pull').read() print(result) if "Updating" in result: self.done.emit({'status': True, 'message': "Updated successfully!"}) else: self.done.emit({'status': False, 'message': "No updates found."}) except Exception as e: self.done.emit({'status': False, 'message': f"Error: {e}"}) # ═══════════════════════════════════════════════════════════════════════════════ # Main window # ═══════════════════════════════════════════════════════════════════════════════ class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Phytomatrix") self.setFixedSize(800, 416) # ── State ── self.serialObj = None self.currentFrame = None self.roi = load_roi() self.sensitivity = load_sensitivity() self.green_values = deque(maxlen=3000) # (timestamp, value) self.current_green_avg = 0.0 os.makedirs(LOG_DIR, exist_ok=True) # ── Sensitivity save debounce (must exist before _build_ui) ── self._sens_save_timer = QTimer(self) self._sens_save_timer.setSingleShot(True) self._sens_save_timer.timeout.connect(lambda: save_sensitivity(self.sensitivity)) # ── Build UI ── self._build_ui() # ── Arduino ── self._init_arduino() self._display_commit_hash() # Sync loaded ROI to preview widget self.livePreview.roi = list(self.roi) # Sync sensitivity slider self.sliderSensitivity.setValue(self.sensitivity) self.lblSensitivityVal.setText(str(self.sensitivity)) # ── Threads ── self.videoThread = VideoThread(self) self.videoThread.frame.connect(self._on_frame) self.videoThread.start() self.serialThread = SerialListenerThread(self) self.serialThread.message.connect(self._on_serial) self.serialThread.start() self.updateThread = UpdateThread() self.updateThread.done.connect(self._on_update_done) # ── Green measurement timer — fires every 10 s ── self.greenTimer = QTimer(self) self.greenTimer.timeout.connect(self._update_green_avg) self.greenTimer.start(GREEN_UPDATE_MS) # ───────────────────────────────────────────────────────────────────────── # UI construction # ───────────────────────────────────────────────────────────────────────── def _build_ui(self): central = QWidget() self.setCentralWidget(central) root_layout = QVBoxLayout(central) root_layout.setContentsMargins(0, 0, 0, 0) # Top-level stack: Home ↔ Graph self.mainStack = QStackedWidget() root_layout.addWidget(self.mainStack) # ── HOME PAGE ────────────────────────────────────────────────────── self.pgHome = QWidget() home_layout = QHBoxLayout(self.pgHome) home_layout.setContentsMargins(5, 5, 5, 5) home_layout.setSpacing(5) # Live preview (left, stretch=3) self.livePreview = LivePreviewWidget() home_layout.addWidget(self.livePreview, stretch=3) # Right-side button panel stack (normal / ROI-edit) self.rightStack = QStackedWidget() self.rightStack.setFixedWidth(185) home_layout.addWidget(self.rightStack) # -- Normal buttons page (index 0) -- normal_page = QWidget() nl = QVBoxLayout(normal_page) nl.setContentsMargins(0, 5, 5, 5) nl.setSpacing(6) self.btnDrawROI = QPushButton("DRAW ROI") self.btnDrawROI.setFixedHeight(35) self.btnDrawROI.clicked.connect(self._on_draw_roi) nl.addWidget(self.btnDrawROI) self.btnGraph = QPushButton("GRAPH") self.btnGraph.setFixedHeight(35) self.btnGraph.clicked.connect(self._on_graph) nl.addWidget(self.btnGraph) nl.addSpacing(4) # Large green value self.lblGreenValue = QLabel("0.00") self.lblGreenValue.setAlignment(Qt.AlignCenter) self.lblGreenValue.setFont(QFont("Arial", 36, QFont.Bold)) self.lblGreenValue.setStyleSheet("color: #00cc00;") nl.addWidget(self.lblGreenValue) # Sensitivity lbl_sens = QLabel("Sensitivity:") lbl_sens.setFont(QFont("Arial", 10)) nl.addWidget(lbl_sens) sens_row = QHBoxLayout() self.sliderSensitivity = QSlider(Qt.Horizontal) self.sliderSensitivity.setRange(0, 100) self.sliderSensitivity.valueChanged.connect(self._on_sensitivity) sens_row.addWidget(self.sliderSensitivity) self.lblSensitivityVal = QLabel("50") self.lblSensitivityVal.setFixedWidth(30) self.lblSensitivityVal.setAlignment(Qt.AlignCenter) sens_row.addWidget(self.lblSensitivityVal) nl.addLayout(sens_row) nl.addStretch() self.btnUpdate = QPushButton("UPDATE") self.btnUpdate.setFixedHeight(32) self.btnUpdate.clicked.connect(self._on_update) nl.addWidget(self.btnUpdate) self.btnReboot = QPushButton("REBOOT") self.btnReboot.setFixedHeight(32) self.btnReboot.clicked.connect(self._on_reboot) nl.addWidget(self.btnReboot) self.btnShutdown = QPushButton("SHUTDOWN") self.btnShutdown.setFixedHeight(32) self.btnShutdown.clicked.connect(self._on_shutdown) nl.addWidget(self.btnShutdown) nl.addSpacing(4) self.lblArduinoStatus = QLabel("") self.lblArduinoStatus.setFont(QFont("Arial", 9)) nl.addWidget(self.lblArduinoStatus) self.lblCommitHash = QLabel("") self.lblCommitHash.setFont(QFont("Arial", 9)) nl.addWidget(self.lblCommitHash) self.rightStack.addWidget(normal_page) # index 0 # -- ROI editing page (index 1) -- roi_page = QWidget() rl = QVBoxLayout(roi_page) rl.setContentsMargins(0, 5, 5, 5) rl.addStretch() lbl_hint = QLabel("Draw a rectangle\non the preview") lbl_hint.setAlignment(Qt.AlignCenter) lbl_hint.setFont(QFont("Arial", 11)) rl.addWidget(lbl_hint) rl.addSpacing(20) self.btnROIOk = QPushButton("OK") self.btnROIOk.setFixedHeight(50) self.btnROIOk.setFont(QFont("Arial", 14, QFont.Bold)) self.btnROIOk.clicked.connect(self._on_roi_ok) rl.addWidget(self.btnROIOk) rl.addSpacing(10) self.btnROICancel = QPushButton("CANCEL") self.btnROICancel.setFixedHeight(50) self.btnROICancel.setFont(QFont("Arial", 14, QFont.Bold)) self.btnROICancel.clicked.connect(self._on_roi_cancel) rl.addWidget(self.btnROICancel) rl.addStretch() self.rightStack.addWidget(roi_page) # index 1 self.mainStack.addWidget(self.pgHome) # ── GRAPH PAGE ───────────────────────────────────────────────────── self.pgGraph = QWidget() gl = QVBoxLayout(self.pgGraph) gl.setContentsMargins(5, 5, 5, 5) self.figure = Figure(figsize=(7, 3.5), dpi=100) self.figure.patch.set_facecolor('#f0f0f0') self.canvas = FigureCanvas(self.figure) gl.addWidget(self.canvas) btn_row = QHBoxLayout() self.btn5Min = QPushButton("5 MIN") self.btn1Hour = QPushButton("1 HOUR") self.btn1Day = QPushButton("1 DAY") self.btn1Week = QPushButton("1 WEEK") self.btnBackGraph = QPushButton("BACK") for b in (self.btn5Min, self.btn1Hour, self.btn1Day, self.btn1Week, self.btnBackGraph): b.setFixedHeight(35) btn_row.addWidget(b) self.btn5Min.clicked.connect(lambda: self._show_graph('5min')) self.btn1Hour.clicked.connect(lambda: self._show_graph('1hour')) self.btn1Day.clicked.connect(lambda: self._show_graph('1day')) self.btn1Week.clicked.connect(lambda: self._show_graph('1week')) self.btnBackGraph.clicked.connect(self._on_back_graph) gl.addLayout(btn_row) self.mainStack.addWidget(self.pgGraph) # Default view self.mainStack.setCurrentWidget(self.pgHome) self.rightStack.setCurrentIndex(0) # ───────────────────────────────────────────────────────────────────────── # Frame handling & green measurement # ───────────────────────────────────────────────────────────────────────── def _on_frame(self, frame): self.currentFrame = frame self.livePreview.setFrame(frame) # Per-frame green score (uses the *saved* ROI, not the one being edited) val = compute_green(frame, self.roi, self.sensitivity) self.green_values.append((time.time(), val)) def _update_green_avg(self): """Called every 10 s — average the last 5 s of per-frame green values.""" now = time.time() cutoff = now - GREEN_AVG_WINDOW recent = [v for t, v in self.green_values if t >= cutoff] avg = sum(recent) / len(recent) if recent else 0.0 self.current_green_avg = avg self.lblGreenValue.setText(f"{avg:.2f}") self._send_to_arduino(avg) log_value(avg) def _send_to_arduino(self, value): if self.serialObj is not None: try: self.serialObj.write(f"{value:.4f}\n".encode()) except Exception as e: print(f"Serial write error: {e}") def _on_serial(self, message): print("Arduino:", message) # ───────────────────────────────────────────────────────────────────────── # ROI # ───────────────────────────────────────────────────────────────────────── def _on_draw_roi(self): self.rightStack.setCurrentIndex(1) self.livePreview.startEditing() def _on_roi_ok(self): self.livePreview.stopEditing(save=True) self.roi = list(self.livePreview.roi) save_roi(self.roi) self.rightStack.setCurrentIndex(0) def _on_roi_cancel(self): self.livePreview.stopEditing(save=False) self.roi = list(self.livePreview.roi) # restored by stopEditing self.rightStack.setCurrentIndex(0) # ───────────────────────────────────────────────────────────────────────── # Sensitivity # ───────────────────────────────────────────────────────────────────────── def _on_sensitivity(self, value): self.sensitivity = value self.lblSensitivityVal.setText(str(value)) self._sens_save_timer.start(300) # debounced file write # ───────────────────────────────────────────────────────────────────────── # Graph # ───────────────────────────────────────────────────────────────────────── def _on_graph(self): self.mainStack.setCurrentWidget(self.pgGraph) self._show_graph('5min') def _on_back_graph(self): self.mainStack.setCurrentWidget(self.pgHome) def _show_graph(self, time_range): data = read_log_data(time_range) self.figure.clear() ax = self.figure.add_subplot(111) if data: times = [d[0] for d in data] values = [d[1] for d in data] ax.plot(times, values, color='green', linewidth=1) ax.fill_between(times, values, alpha=0.3, color='green') ax.set_ylim(0, 1) ax.set_ylabel('Green Value') ax.set_xlabel('Time') titles = { '5min': 'Last 5 Minutes', '1hour': 'Last Hour', '1day': 'Last Day', '1week': 'Last Week', } ax.set_title(titles.get(time_range, '')) if data: fmt = '%H:%M' if time_range in ('5min', '1hour') else '%m/%d %H:%M' ax.xaxis.set_major_formatter(mdates.DateFormatter(fmt)) self.figure.autofmt_xdate() self.figure.tight_layout() self.canvas.draw() # ───────────────────────────────────────────────────────────────────────── # Arduino # ───────────────────────────────────────────────────────────────────────── def _init_arduino(self): for port in list_ports.comports(): keywords = ['Arduino', 'USB0', 'ACM0', 'ACM1'] if any(k in port.description for k in keywords) or \ any(k in port.device for k in keywords): try: self.serialObj = serial.Serial(port.device, 9600) print("Arduino connected:", port.device) self.lblArduinoStatus.setText("Arduino Connected") self.lblArduinoStatus.setStyleSheet("color: green;") return except Exception as e: print("Serial open error:", e) self.serialObj = None print("Arduino not found — running without serial.") self.lblArduinoStatus.setText("Arduino Not Connected") self.lblArduinoStatus.setStyleSheet("color: red;") # ───────────────────────────────────────────────────────────────────────── # Update / Reboot / Shutdown # ───────────────────────────────────────────────────────────────────────── def _on_update(self): self.btnUpdate.setEnabled(False) self.updateThread.start() def _on_update_done(self, result): self.btnUpdate.setEnabled(True) if result['status']: ret = QMessageBox.question( self, "Update", "Update completed. Restart the app?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if ret == QMessageBox.Yes: if os.path.exists("../../desktop.txt"): sys.exit() else: os.system("sudo reboot") else: QMessageBox.warning(self, "Update", result['message']) def _on_reboot(self): ret = QMessageBox.question( self, "Reboot", "Are you sure you want to reboot?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if ret == QMessageBox.Yes: if os.path.exists("../../desktop.txt"): sys.exit() else: os.system("sudo reboot") def _on_shutdown(self): ret = QMessageBox.question( self, "Shutdown", "Are you sure you want to shutdown?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if ret == QMessageBox.Yes: if os.path.exists("../../desktop.txt"): sys.exit() else: os.system("sudo shutdown -h now") def _display_commit_hash(self): repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) try: h = subprocess.check_output( ['git', 'rev-parse', '--short', 'HEAD'], cwd=repo_root, stderr=subprocess.STDOUT, text=True, ).strip() self.lblCommitHash.setText(f"Commit: {h}") except Exception: self.lblCommitHash.setText("Commit: unknown") # ═══════════════════════════════════════════════════════════════════════════════ # Entry point # ═══════════════════════════════════════════════════════════════════════════════ if __name__ == "__main__": app = QApplication(sys.argv) window = MainWindow() window.show() sys.exit(app.exec_())