From 84d68777621610e85b0b36e75f9defc505355d4f Mon Sep 17 00:00:00 2001 From: Emre Date: Wed, 29 Jul 2026 10:27:53 +0300 Subject: [PATCH 1/3] Pano kilitlenmesini, fail-open karari ve kimlik dogrulamasiz yayini duzelt Web panosu: - socketserver.TCPServer tek is parcacikliydi ve /video_feed sonsuza kadar akan bir yanit oldugu icin tek worker'i kalici olarak blokluyordu. Tarayici video'yu actigi anda /api/status yoklamalari cevap alamiyor, panodaki durum/doluluk/FPS kutulari hicbir zaman guncellenmiyordu. ThreadingHTTPServer'a gecildi. - Varsayilan bind adresi 0.0.0.0 -> 127.0.0.1. Canli kamera goruntusu ve doluluk verisi artik varsayilan olarak agdaki herkese acik degil. - Istege bagli access_token eklendi (Bearer basligi veya ?token=). Token'siz sekilde 0.0.0.0'a acilirsa uyari log'lanir. - Access-Control-Allow-Origin: * kaldirildi. - Ayni kare tekrar tekrar gonderiliyordu; artik yalnizca yeni kare yayinlanir. Erisim karari: - Cikarim hatasi doluluk 0 gibi gorunuyor ve kapi aciliyordu. Tespit calismadiginda karar artik DENY (detection_unavailable). Monitor dongusu cikarim istisnasini yakalayip bu durumu karar motoruna bildiriyor. - total_grants/total_denials her karede artiyordu; 30 FPS'te saniyede 30 artan bu sayaclarin gercek erisim olaylariyla ilgisi yoktu. Artik karar degistiginde artiyorlar. CI: - GitHub Actions ile Python 3.10/3.12 uzerinde pytest kosuluyor (CPU-only torch). - README'deki sabit "build passing" rozeti gercek CI rozetiyle degistirildi. - pytest, [dev] opsiyonel bagimligi olarak tanimlandi. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 39 ++++++++++++++++ README.md | 21 ++++++++- config/default.yaml | 5 +- pyproject.toml | 5 ++ roomgate/cli.py | 6 ++- roomgate/config.py | 9 +++- roomgate/decision.py | 63 +++++++++++++++++++++---- roomgate/monitor.py | 15 +++++- roomgate/web.py | 99 ++++++++++++++++++++++++++++++++-------- tests/test_decision.py | 35 +++++++++++++- 10 files changed, 260 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6ae2682 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.12"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + # Ultralytics varsayilan olarak CUDA'li torch cekiyor (~2.5 GB). CI'da GPU + # olmadigi icin once CPU tekerlegini kuruyoruz; ayni surumu bulan pip + # sonraki adimda tekrar indirmiyor. + - name: Install CPU-only PyTorch + run: | + python -m pip install -U pip + python -m pip install torch --index-url https://download.pytorch.org/whl/cpu + + - name: Install package with dev extras + run: python -m pip install -e ".[dev]" + + - name: Run test suite + run: pytest tests/ -v diff --git a/README.md b/README.md index 6951a98..8b4f890 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/) [![YOLO Engine](https://img.shields.io/badge/Ultralytics-YOLO26-orange.svg)](https://github.com/ultralytics/ultralytics) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![Build Status](https://img.shields.io/badge/build-passing-brightgreen.svg)]() +[![CI](https://github.com/emrefbulut/RoomGate-AI/actions/workflows/ci.yml/badge.svg)](https://github.com/emrefbulut/RoomGate-AI/actions/workflows/ci.yml) [![Code Style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) **RoomGate AI** is an enterprise-grade, high-performance Smart Access & Occupancy Monitoring System engineered for real-time computer vision processing, spatial region-of-interest (ROI) filtering, and automated hardware access control. @@ -82,6 +82,12 @@ roomgate-ai monitor --web --web-port 8080 ``` *Access the live stream dashboard at `http://localhost:8080`.* +> **Security note.** The dashboard streams a live camera feed and occupancy data, +> and it binds to `127.0.0.1` by default. To reach it from another machine, set +> `web.host` in the config โ€” and set `web.access_token` at the same time, +> otherwise anyone on the network can watch the room. With a token configured, +> open `http://:8080/?token=`. + --- ### 2. Diagnostics & Performance Benchmarks @@ -155,6 +161,13 @@ logging: snapshot_dir: "snapshots" save_event_snapshots: false wal_mode: true + +web: + enabled: false + host: "127.0.0.1" # only set to 0.0.0.0 together with an access_token + port: 8080 + quality: 80 + access_token: null ``` --- @@ -176,12 +189,16 @@ relay: ## ๐Ÿงช Verification & Automated Testing -Execute the unit test suite: +Install the dev extras, then execute the unit test suite: ```powershell +python -m pip install -e ".[dev]" pytest tests/ -v ``` +The same suite runs in GitHub Actions on Python 3.10 and 3.12 for every push and +pull request against `main`. + --- ## ๐Ÿ“„ License diff --git a/config/default.yaml b/config/default.yaml index fe38544..e27a248 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -48,6 +48,9 @@ logging: web: enabled: false - host: "0.0.0.0" + # Pano canli kamera goruntusu yayinlar. "0.0.0.0" yapip agdaki herkese + # acacaksaniz mutlaka bir access_token tanimlayin. + host: "127.0.0.1" port: 8080 quality: 80 + access_token: null diff --git a/pyproject.toml b/pyproject.toml index 96e644f..92b2017 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,11 @@ dependencies = [ "ultralytics", ] +[project.optional-dependencies] +dev = [ + "pytest>=8.0", +] + [project.scripts] roomgate-ai = "roomgate.cli:main" roomgate = "roomgate.cli:main" diff --git a/roomgate/cli.py b/roomgate/cli.py index 69a11cc..9c7a981 100644 --- a/roomgate/cli.py +++ b/roomgate/cli.py @@ -214,7 +214,11 @@ def main(argv: list[str] | None = None) -> int: web_dashboard = None if args.command == "web" or args.web: port = getattr(args, "port", None) or getattr(args, "web_port", 8080) - web_dashboard = RoomGateWebDashboard(host=settings.web.host, port=port) + web_dashboard = RoomGateWebDashboard( + host=settings.web.host, + port=port, + access_token=settings.web.access_token, + ) no_win = args.no_window or (args.command == "web") RoomGateMonitor(settings, detector, relay, logger_inst, web_dashboard).run(show=not no_win) diff --git a/roomgate/config.py b/roomgate/config.py index f1564a4..41c291b 100644 --- a/roomgate/config.py +++ b/roomgate/config.py @@ -74,9 +74,13 @@ class LoggingSettings: @dataclass(frozen=True) class WebSettings: enabled: bool = False - host: str = "0.0.0.0" + # Pano canli kamera goruntusu ve doluluk verisi yayinlar. Varsayilan olarak + # yalnizca yerel makineden erisilebilir; agdaki herkese acmak icin host + # degeri acikca degistirilmeli ve access_token tanimlanmalidir. + host: str = "127.0.0.1" port: int = 8080 quality: int = 80 + access_token: str | None = None @dataclass(frozen=True) @@ -175,8 +179,9 @@ def load_settings(path: str | Path = "config/default.yaml") -> AppSettings: ), web=WebSettings( enabled=bool(web.get("enabled", False)), - host=str(web.get("host", "0.0.0.0")), + host=str(web.get("host", "127.0.0.1")), port=int(web.get("port", 8080)), quality=int(web.get("quality", 80)), + access_token=(str(web["access_token"]) if web.get("access_token") else None), ), ) diff --git a/roomgate/decision.py b/roomgate/decision.py index 4f35fd0..b54cb49 100644 --- a/roomgate/decision.py +++ b/roomgate/decision.py @@ -30,11 +30,60 @@ def __init__( self.max_occupancy = max_occupancy self.minimum_average_confidence = minimum_average_confidence self._history: deque[int] = deque(maxlen=confirmation_frames) + self._last_allowed: bool | None = None self.peak_occupancy: int = 0 self.total_grants: int = 0 self.total_denials: int = 0 - def decide(self, occupancy: int, average_confidence: float) -> AccessDecision: + def _finalize( + self, + *, + occupancy: int, + stable_occupancy: int, + allowed: bool, + command: str, + reason: str, + average_confidence: float, + ) -> AccessDecision: + # Sayaclar kare basina degil, karar DEGISTIGINDE artar. Onceden her kare + # sayildigi icin "total_grants" 30 FPS'te saniyede 30 artiyor ve gercek + # erisim olayi sayisiyla hicbir ilgisi kalmiyordu. + if self._last_allowed is None or self._last_allowed != allowed: + if allowed: + self.total_grants += 1 + else: + self.total_denials += 1 + self._last_allowed = allowed + + return AccessDecision( + occupancy=occupancy, + stable_occupancy=stable_occupancy, + allowed=allowed, + command=command, + reason=reason, + average_confidence=average_confidence, + peak_occupancy=self.peak_occupancy, + ) + + def decide( + self, + occupancy: int, + average_confidence: float, + detection_available: bool = True, + ) -> AccessDecision: + # Erisim kontrolu fail-safe olmalidir. Model yuklenemedi, kamera karesi + # bos geldi ya da cikarim hata verdiyse "kimse yok" sonucuna varmak + # kapiyi acmak demektir; boyle bir durumda karar DENY olmalidir. + if not detection_available: + return self._finalize( + occupancy=occupancy, + stable_occupancy=self.max_occupancy, + allowed=False, + command="DENY", + reason="detection_unavailable", + average_confidence=average_confidence, + ) + current_occ = max(0, occupancy) self._history.append(current_occ) stable_occupancy = int(median(self._history)) @@ -43,36 +92,30 @@ def decide(self, occupancy: int, average_confidence: float) -> AccessDecision: self.peak_occupancy = stable_occupancy if stable_occupancy >= self.max_occupancy: - self.total_denials += 1 - return AccessDecision( + return self._finalize( occupancy=occupancy, stable_occupancy=stable_occupancy, allowed=False, command="DENY", reason="occupancy_limit_reached", average_confidence=average_confidence, - peak_occupancy=self.peak_occupancy, ) if occupancy > 0 and average_confidence < self.minimum_average_confidence: - self.total_denials += 1 - return AccessDecision( + return self._finalize( occupancy=occupancy, stable_occupancy=stable_occupancy, allowed=False, command="DENY", reason="low_detection_confidence", average_confidence=average_confidence, - peak_occupancy=self.peak_occupancy, ) - self.total_grants += 1 - return AccessDecision( + return self._finalize( occupancy=occupancy, stable_occupancy=stable_occupancy, allowed=True, command="OPEN", reason="below_occupancy_limit", average_confidence=average_confidence, - peak_occupancy=self.peak_occupancy, ) diff --git a/roomgate/monitor.py b/roomgate/monitor.py index 56283c7..df38fb7 100644 --- a/roomgate/monitor.py +++ b/roomgate/monitor.py @@ -107,12 +107,23 @@ def run( last_fps_t = now # Run AI detection - detections = self.detector.detect(frame) + detection_available = True + try: + detections = self.detector.detect(frame) + except Exception as exc: + # Cikarim hatasi "kimse yok" ile ayni sey degildir. Bunu + # sessizce bos listeye cevirmek kapiyi acardi. + logger.error(f"Detection failed, denying access for this frame: {exc}") + detections = [] + detection_available = False + inside = self.roi.filter_detections(detections) avg_confidence = _average_confidence(inside) # Make access decision - decision = self.decision_engine.decide(len(inside), avg_confidence) + decision = self.decision_engine.decide( + len(inside), avg_confidence, detection_available=detection_available + ) self.relay.send(decision.command) # Async log decision event diff --git a/roomgate/web.py b/roomgate/web.py index 54e0d57..9a3c602 100644 --- a/roomgate/web.py +++ b/roomgate/web.py @@ -1,11 +1,12 @@ from __future__ import annotations -from http.server import BaseHTTPRequestHandler, HTTPServer +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import hmac import json import logging -import socketserver import threading import time +from urllib.parse import urlparse, parse_qs from typing import Any import cv2 @@ -18,6 +19,7 @@ class StreamState: def __init__(self) -> None: self.lock = threading.Lock() self.latest_jpeg: bytes | None = None + self.frame_version: int = 0 self.status_data: dict[str, Any] = { "system": "RoomGate AI", "status": "INITIALIZING", @@ -32,6 +34,7 @@ def update_frame(self, frame: np.ndarray, quality: int = 80) -> None: if ok: with self.lock: self.latest_jpeg = encoded.tobytes() + self.frame_version += 1 def update_status(self, data: dict[str, Any]) -> None: with self.lock: @@ -41,12 +44,40 @@ def update_status(self, data: dict[str, Any]) -> None: class RoomGateWebHandler(BaseHTTPRequestHandler): state: StreamState | None = None + access_token: str | None = None def log_message(self, format: str, *args: Any) -> None: pass # Suppress default server access logging to reduce terminal clutter + def _is_authorized(self) -> bool: + """Bir erisim anahtari tanimlanmissa her istekte dogrular. + + Canli kamera goruntusu ve doluluk verisi hassas oldugu icin, pano + localhost disina acildiginda anahtar zorunlu hale getirilebilir. + Anahtar ya `Authorization: Bearer ` basligiyla ya da + `?token=` sorgu parametresiyle verilir (img/MJPEG etiketleri + ozel baslik gonderemez). + """ + if not self.access_token: + return True + + header = self.headers.get("Authorization", "") + if header.startswith("Bearer "): + if hmac.compare_digest(header[len("Bearer ") :], self.access_token): + return True + + query = parse_qs(urlparse(self.path).query) + supplied = query.get("token", [""])[0] + return hmac.compare_digest(supplied, self.access_token) + def do_GET(self) -> None: - if self.path in ("/", "/index.html"): + if not self._is_authorized(): + self.send_error(401, "Unauthorized") + return + + route = urlparse(self.path).path + + if route in ("/", "/index.html"): self.send_response(200) self.send_header("Content-type", "text/html; charset=utf-8") self.end_headers() @@ -73,7 +104,7 @@ def do_GET(self) -> None:

RoomGate AI

AI-Powered Smart Access & Occupancy Live Dashboard

- RoomGate AI Live Video Feed + RoomGate AI Live Video Feed
Status
ALLOW
@@ -81,9 +112,14 @@ def do_GET(self) -> None:
FPS
0.0