Spaces:
Sleeping
Sleeping
| """ | |
| api_service.py | |
| Thin wrapper around the Railway backend API. | |
| All HTTP calls to the remote backend go through here. | |
| """ | |
| import requests | |
| import time | |
| from flask import current_app | |
| # ββ Cooldown tracking ββββββββββββββββββββββββββββββββββββββββ | |
| _plate_last_seen: dict[str, float] = {} | |
| import os | |
| def _base_url() -> str: | |
| try: | |
| from flask import current_app | |
| return current_app.config["BACKEND_API_URL"].rstrip("/") | |
| except: | |
| return os.getenv("BACKEND_API_URL", "https://pant0x-ain-el-aql-backend.hf.space").rstrip("/") | |
| def _headers(token: str | None = None) -> dict: | |
| h = {"Accept": "application/json"} | |
| hf_token = os.getenv("HF_TOKEN") | |
| # Hugging Face needs this to let the request through the Private Space proxy | |
| if hf_token: | |
| h["Authorization"] = f"Bearer {hf_token}" | |
| # Backend will read the user session from this new header | |
| if token: | |
| h["X-Supabase-Auth"] = f"Bearer {token}" | |
| return h | |
| def normalize_arabic_plate(text: str) -> str: | |
| """Converts Hindi digits to Western digits and handles formatting.""" | |
| if not text: return "" | |
| hindi_to_western = { | |
| 'Ω ': '0', 'Ω‘': '1', 'Ω’': '2', 'Ω£': '3', 'Ω€': '4', | |
| 'Ω₯': '5', 'Ω¦': '6', 'Ω§': '7', 'Ω¨': '8', 'Ω©': '9' | |
| } | |
| # Convert digits | |
| res = "" | |
| for char in text: | |
| res += hindi_to_western.get(char, char) | |
| if '|' in res: | |
| parts = res.split('|') | |
| letters = parts[0].strip() | |
| numbers = parts[1].strip().replace(" ", "") | |
| # Reverse numbers for correct LTR display in the browser | |
| return f"{letters} | {numbers[::-1]}" | |
| return res | |
| def extract_match_key(text: str) -> str: | |
| """ | |
| Extracts alphanumeric characters and sorts them (Letters then Numbers) | |
| to ensure 'SQR 777' and '777 SQR' generate the same key 'SQR777'. | |
| """ | |
| if not text: return "" | |
| text = text.replace(" ", "").replace("|", "").replace("-", "") | |
| import re | |
| letters = "".join(re.findall(r'[a-zA-Z\u0600-\u06FF]+', text)) | |
| numbers = "".join(re.findall(r'[0-9]+', text)) | |
| # Egyptian system usually prefers Letters then Numbers | |
| return f"{letters}{numbers}" | |
| # ββ Health βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def check_health() -> dict: | |
| try: | |
| r = requests.get(f"{_base_url()}/health", timeout=5) | |
| return r.json() | |
| except Exception as e: | |
| return {"status": "error", "detail": str(e)} | |
| def check_supabase_health() -> dict: | |
| try: | |
| r = requests.get(f"{_base_url()}/supabase/health", timeout=5) | |
| return r.json() | |
| except Exception as e: | |
| return {"status": "error", "detail": str(e)} | |
| # ββ Auth βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def login(identifier: str, password: str) -> dict: | |
| """POST /auth/login β returns user + session.""" | |
| try: | |
| r = requests.post( | |
| f"{_base_url()}/auth/login", | |
| json={"identifier": identifier, "password": password}, | |
| headers=_headers(), | |
| timeout=30, | |
| ) | |
| if r.status_code != 200: | |
| try: | |
| error_msg = r.json().get("detail", r.json().get("error", "Unknown error")) | |
| except: | |
| error_msg = f"Error {r.status_code}: {r.text[:100]}" | |
| return {"error": error_msg} | |
| return r.json() | |
| except Exception as e: | |
| return {"error": str(e)} | |
| def register(payload: dict) -> dict: | |
| """POST /auth/register""" | |
| try: | |
| r = requests.post( | |
| f"{_base_url()}/auth/register", | |
| json=payload, | |
| headers=_headers(), | |
| timeout=10, | |
| ) | |
| return r.json() | |
| except Exception as e: | |
| return {"error": str(e)} | |
| # ββ Predict / OCR ββββββββββββββββββββββββββββββββββββββββββββ | |
| def predict(image_bytes: bytes, filename: str, event_type: str = "entry", | |
| location: str = "OPERA", token: str | None = None) -> dict: | |
| """ | |
| POST /process-image (Railway) β send image for OCR. | |
| Then optionally call /gate/decision to get registration/gate status. | |
| """ | |
| try: | |
| # 1. Get OCR from the Main Engine | |
| # Backend expects 'image' field for the file, and 'camera_source' | |
| files = {"image": (filename, image_bytes, "image/jpeg")} | |
| data = { | |
| "camera_source": location, | |
| "parking_location": location, | |
| "event_type": event_type | |
| } | |
| ocr_resp = requests.post( | |
| f"{_base_url()}/predict", | |
| files=files, | |
| data=data, | |
| headers=_headers(token) if token else _headers(), | |
| timeout=30, | |
| ) | |
| if ocr_resp.status_code != 200: | |
| return {"error": f"Backend OCR failed: {ocr_resp.text[:100]}"} | |
| resp = ocr_resp.json() | |
| # 2. Map OCR results (Robust mapping) | |
| det = resp.get("detection") or resp | |
| urls = resp.get("urls") or resp.get("images") or {} | |
| # Try different possible plate field names including new backend format | |
| plate_info_dict = resp.get("plate_info", {}) | |
| plate_text = det.get("plate_text") or det.get("plate_ar") or det.get("plate") or plate_info_dict.get("arabic") or "" | |
| en_text = det.get("en_text") or det.get("plate_en") or plate_info_dict.get("english") or "" | |
| if plate_text or en_text: | |
| normalized_ar = normalize_arabic_plate(plate_text) | |
| match_key = extract_match_key(en_text) or extract_match_key(normalized_ar) | |
| # Use the registration info directly if the backend already provided it | |
| # Different backends might use 'app_registration' or top-level 'matched' | |
| reg_info = resp.get("app_registration") or resp.get("registration") or {} | |
| is_registered = False | |
| if isinstance(reg_info, dict): | |
| is_registered = reg_info.get("is_registered", resp.get("matched", False)) | |
| elif isinstance(reg_info, bool): | |
| is_registered = reg_info | |
| resp["matched"] = is_registered | |
| resp["plate_text_ar"] = normalized_ar | |
| resp["plate_info"] = { | |
| "plate_ar": normalized_ar, | |
| "plate_en": en_text, | |
| "plate_key": match_key, | |
| "confidence": det.get("confidence", det.get("score", 0)) | |
| } | |
| # Map Images | |
| admin_page_data = resp.get("admin_page", {}) | |
| if "admin_page" not in resp: | |
| resp["admin_page"] = {} | |
| # If backend returns base64, create a data URL | |
| if admin_page_data.get("annotated_image_base64"): | |
| b64 = admin_page_data["annotated_image_base64"] | |
| # Sometimes it might already have the prefix | |
| if b64.startswith("data:"): | |
| resp["admin_page"]["processed_image_url"] = b64 | |
| else: | |
| resp["admin_page"]["processed_image_url"] = f"data:image/jpeg;base64,{b64}" | |
| else: | |
| resp["admin_page"]["processed_image_url"] = urls.get("processed") or urls.get("annotated") or "" | |
| resp["admin_page"]["raw_image_url"] = urls.get("raw") or urls.get("original") or "" | |
| # 3. Call Gate Decision (Business Logic) | |
| if "gate_decision" not in resp: | |
| try: | |
| decision_payload = { | |
| "plate_key": resp["plate_info"]["plate_key"], | |
| "event_type": event_type, | |
| "parking_location": location | |
| } | |
| dr = requests.post( | |
| f"{_base_url()}/gate/decision", | |
| json=decision_payload, | |
| headers=_headers(token) if token else _headers(), | |
| timeout=10 | |
| ) | |
| if dr.status_code == 200: | |
| decision_data = dr.json() | |
| resp.update(decision_data) | |
| except Exception as e: | |
| resp["backend_warning"] = f"Gate decision failed: {str(e)}" | |
| return resp | |
| except Exception as e: | |
| return {"error": str(e)} | |
| def is_on_cooldown(plate_key: str) -> bool: | |
| """Returns True if plate was scanned within PLATE_COOLDOWN_SECONDS.""" | |
| cooldown = current_app.config["PLATE_COOLDOWN_SECONDS"] | |
| now = time.time() | |
| if plate_key in _plate_last_seen: | |
| if now - _plate_last_seen[plate_key] < cooldown: | |
| return True | |
| _plate_last_seen[plate_key] = now | |
| return False | |
| # ββ Parking / Events (staff endpoints) βββββββββββββββββββββ | |
| def get_entered_cars(token: str) -> dict: | |
| try: | |
| r = requests.get(f"{_base_url()}/events/entered-cars", | |
| headers=_headers(token), timeout=30) | |
| return r.json() | |
| except Exception as e: | |
| return {"error": str(e)} | |
| def get_inside_cars(token: str) -> dict: | |
| try: | |
| r = requests.get(f"{_base_url()}/events/inside-cars", | |
| headers=_headers(token), timeout=30) | |
| return r.json() | |
| except Exception as e: | |
| return {"error": str(e)} | |
| def get_left_cars(token: str) -> dict: | |
| try: | |
| r = requests.get(f"{_base_url()}/events/left-cars", | |
| headers=_headers(token), timeout=30) | |
| return r.json() | |
| except Exception as e: | |
| return {"error": str(e)} | |
| def get_leaving_soon(token: str) -> dict: | |
| try: | |
| r = requests.get(f"{_base_url()}/events/leaving-cars-within-5-minutes", | |
| headers=_headers(token), timeout=30) | |
| return r.json() | |
| except Exception as e: | |
| return {"error": str(e)} | |
| def get_new_cars(token: str) -> dict: | |
| """GET /events/new-cars β staff-only real-time polling feed.""" | |
| try: | |
| r = requests.get(f"{_base_url()}/events/new-cars", | |
| headers=_headers(token), timeout=30) | |
| return r.json() | |
| except Exception as e: | |
| return {"error": str(e)} | |
| def get_parking_history(token: str) -> dict: | |
| try: | |
| r = requests.get(f"{_base_url()}/parking/history", | |
| headers=_headers(token), timeout=30) | |
| return r.json() | |
| except Exception as e: | |
| return {"error": str(e)} | |
| def get_all_users(token: str) -> dict: | |
| try: | |
| r = requests.get(f"{_base_url()}/users", | |
| headers=_headers(token), timeout=30) | |
| return r.json() | |
| except Exception as e: | |
| return {"error": str(e)} | |
| def get_parking_locations(token: str) -> dict: | |
| try: | |
| r = requests.get(f"{_base_url()}/parking/locations", | |
| headers=_headers(token), timeout=30) | |
| return r.json() | |
| except Exception as e: | |
| return {"error": str(e)} | |
| def get_parking_occupancy(token: str) -> dict: | |
| try: | |
| r = requests.get(f"{_base_url()}/parking/occupancy", | |
| headers=_headers(token), timeout=30) | |
| return r.json() | |
| except Exception as e: | |
| return {"error": str(e)} | |
| # ββ Vehicles βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_my_vehicles(token: str) -> dict: | |
| try: | |
| r = requests.get(f"{_base_url()}/vehicles/my", | |
| headers=_headers(token), timeout=30) | |
| return r.json() | |
| except Exception as e: | |
| return {"error": str(e)} | |
| def add_vehicle(token: str, payload: dict) -> dict: | |
| try: | |
| r = requests.post(f"{_base_url()}/vehicles/add", | |
| json=payload, headers=_headers(token), timeout=30) | |
| return r.json() | |
| except Exception as e: | |
| return {"error": str(e)} | |
| def edit_vehicle(token: str, vehicle_id: str, payload: dict) -> dict: | |
| try: | |
| r = requests.put(f"{_base_url()}/vehicles/{vehicle_id}", | |
| json=payload, headers=_headers(token), timeout=30) | |
| return r.json() | |
| except Exception as e: | |
| return {"error": str(e)} | |
| def delete_vehicle(token: str, vehicle_id: str) -> dict: | |
| try: | |
| r = requests.delete(f"{_base_url()}/vehicles/{vehicle_id}", | |
| headers=_headers(token), timeout=30) | |
| return r.json() | |
| except Exception as e: | |
| return {"error": str(e)} | |
| # ββ Profile ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def update_profile(token: str, payload: dict) -> dict: | |
| try: | |
| r = requests.put(f"{_base_url()}/profile/update", | |
| json=payload, headers=_headers(token), timeout=30) | |
| return r.json() | |
| except Exception as e: | |
| return {"error": str(e)} | |
| # ββ Payments βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def confirm_cash_payment(token: str, payload: dict) -> dict: | |
| """POST /payments/manual/cash-confirm β staff-only.""" | |
| try: | |
| r = requests.post(f"{_base_url()}/payments/manual/cash-confirm", | |
| json=payload, headers=_headers(token), timeout=30) | |
| return r.json() | |
| except Exception as e: | |
| return {"error": str(e)} | |
| def get_gate_decision(token: str, payload: dict) -> dict: | |
| try: | |
| r = requests.post(f"{_base_url()}/gate/decision", | |
| json=payload, headers=_headers(token), timeout=30) | |
| return r.json() | |
| except Exception as e: | |
| return {"error": str(e)} | |
| # ββ Gate hardware (ESP) ββββββββββββββββββββββββββββββββββββββ | |
| def trigger_gate_open(plate: str = "", location: str = "OPERA") -> dict: | |
| """Queue a gate-open command on the cloud backend for ESP32 devices to poll.""" | |
| try: | |
| r = requests.post( | |
| f"{_base_url()}/gate/trigger", | |
| json={ | |
| "parking_location": location, | |
| "duration_seconds": 10, | |
| "plate": plate, | |
| }, | |
| timeout=5, | |
| ) | |
| return {"status": "sent", "backend_response": r.json()} | |
| except Exception as e: | |
| return {"status": "error", "detail": str(e)} | |
| # ββ Admin maintenance ββββββββββββββββββββββββββββββββββββββββ | |
| def weekly_refresh(token: str) -> dict: | |
| try: | |
| r = requests.post(f"{_base_url()}/admin/maintenance/weekly-refresh", | |
| headers=_headers(token), timeout=15) | |
| return r.json() | |
| except Exception as e: | |
| return {"error": str(e)} | |