import asyncio import glob import hashlib import hmac import json import logging import os import re import sys import sqlite3 import tempfile from contextlib import asynccontextmanager from datetime import datetime, timezone from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, UploadFile, File, Form, Query from fastapi.responses import FileResponse, JSONResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from app.config import ( LOGS_DIR, STATIC_DIR, FIRMWARE_DIR, FIRMWARE_BIN, OTA_MANIFEST_FILE, OTA_SLOT_MAX, OTA_PUBLIC_URL, LOCAL_TZ, get_local_now, PCB_VERSIONS, DATABASE_PATH, STATE_DIR, MAX_DEVICES, WS_MESSAGE_MAX, LOG_RETENTION_DAYS, CREDENTIAL_POLL_SECONDS, PRIMARY_DEVICE_ID, ALLOWED_ORIGINS, is_valid_device_id, ) from app.device_store import DeviceStore, StoreError from app.fleet import FleetManager from app.hub import HubManager # --------------------------- logging estructurado --------------------------- class _JsonFormatter(logging.Formatter): _FIELDS = ("event", "device_id", "connection_id", "result", "error_type") def format(self, record: logging.LogRecord) -> str: entry = { "ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"), "level": record.levelname, "msg": record.getMessage(), } for f in self._FIELDS: v = record.__dict__.get(f) if v is not None: entry[f] = v if record.exc_info: entry["exc"] = self.formatException(record.exc_info) return json.dumps(entry, ensure_ascii=False) def _setup_logging(): logger = logging.getLogger("tracker_hub") if not logger.handlers: h = logging.StreamHandler(sys.stdout) h.setFormatter(_JsonFormatter()) logger.addHandler(h) logger.setLevel(logging.INFO) logger.propagate = False return logger app_log = _setup_logging() # ----------------------- estado global (un solo worker) --------------------- store = DeviceStore(DATABASE_PATH) fleet = FleetManager(store) # Manifiesto OTA GLOBAL (el artefacto puede ser global; la versión instalada, # la conexión y el destino del push NO). OTA_MANIFEST = None _upload_lock = asyncio.Lock() def _load_global_manifest(): global OTA_MANIFEST try: if os.path.exists(OTA_MANIFEST_FILE): with open(OTA_MANIFEST_FILE, "r", encoding="utf-8") as f: OTA_MANIFEST = json.load(f) # Upgrade the old mutable download URL without changing its image. if os.path.exists(FIRMWARE_BIN): with open(FIRMWARE_BIN, "rb") as f: data = f.read(OTA_SLOT_MAX + 1) digest = hashlib.sha256(data).hexdigest() if data and len(data) <= OTA_SLOT_MAX and digest == OTA_MANIFEST.get("sha256"): path = os.path.join(FIRMWARE_DIR, digest + ".bin") if not os.path.exists(path): _write_atomic(path, data) OTA_MANIFEST["url"] = OTA_PUBLIC_URL + "/ota/images/" + digest else: OTA_MANIFEST = None except Exception as e: app_log.error("ota_manifest_load_failed", extra={"event": "ota_manifest_load_failed", "error_type": type(e).__name__}) def _write_atomic(path: str, data: bytes): os.makedirs(os.path.dirname(path), exist_ok=True) fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path)) try: with os.fdopen(fd, "wb") as f: f.write(data) f.flush() os.fsync(f.fileno()) os.replace(tmp, path) finally: if os.path.exists(tmp): os.unlink(tmp) async def _reject_ws(websocket: WebSocket, code: int = 1008): """Rechazo del handshake (403) antes de accept.""" try: await websocket.close(code=code) except Exception: pass def _bearer_token(headers) -> str: auth = (headers.get("authorization") or "").strip() if auth.lower().startswith("bearer "): return auth[7:].strip() return "" # --------------------------- validación de comandos -------------------------- # Formatos que realmente usa la UI (texto plano): # CMD: | SET_COORDS:: | SET_PARAM:= _RE_CMD = re.compile(r"^CMD:(\d{1,3})$") _RE_COORDS = re.compile(r"^SET_COORDS:(-?\d{1,6}):(-?\d{1,6})$") _RE_PARAM = re.compile(r"^SET_PARAM:([A-Za-z_][A-Za-z0-9_]{0,15})=(-?\d+(?:\.\d+)?)$") COMMAND_MAX_LEN = 128 def _valid_command(cmd: str) -> bool: if not isinstance(cmd, str) or len(cmd) > COMMAND_MAX_LEN: return False if _RE_CMD.fullmatch(cmd): return cmd in {"CMD:2", "CMD:3", "CMD:4", "CMD:5", "CMD:10"} if _RE_COORDS.fullmatch(cmd): return True m = _RE_PARAM.fullmatch(cmd) if not m: return False if m.group(1) not in { "delay", "tilt", "errMax", "az", "minIrradiance", "errMin", "coarseStep", "fineStep", "angleThreshold", "resetThreshold", "abruptThreshold", "minErrMax", "lpfBeta", "smallThreshold", "peakProximity", "cicleMax", "axisXFactor", "axisYFactor", }: return False try: v = float(m.group(2)) except ValueError: return False if not (v == v and v not in (float("inf"), float("-inf"))): return False if m.group(1) in {"axisXFactor", "axisYFactor"}: return m.group(2).isdigit() and 1 <= v <= 8 if m.group(1) == "cicleMax": return m.group(2).isdigit() and 1 <= v <= 64 return True def _validate_device_id_param(device_id: str): if not is_valid_device_id(device_id): raise HTTPException(status_code=400, detail="device_id inválido") # --------------------------------- app --------------------------------------- def create_app(store_=None, fleet_=None) -> FastAPI: st = store_ if store_ is not None else store if fleet_ is not None: fl = fleet_ elif store_ is not None: fl = FleetManager(st) else: fl = fleet app_log.warning("single_worker_required: este hub requiere UN SOLO worker de Uvicorn; " "múltiples replicas rompen el aislamiento de sockets", extra={"event": "single_worker_required"}) def _get_hub_or_404(device_id: str) -> HubManager: _validate_device_id_param(device_id) h = fl.get(device_id) if h is None: raise HTTPException(status_code=404, detail="device_id no registrado") h.ota_manifest = OTA_MANIFEST if OTA_MANIFEST: h.ota_url = OTA_MANIFEST["url"] return h def _legacy_hub_ok() -> bool: return bool(PRIMARY_DEVICE_ID) and is_valid_device_id(PRIMARY_DEVICE_ID) def _legacy_hub() -> HubManager: """Aliases legacy: solo con PRIMARY_DEVICE_ID explícito. Nunca 'la primera conectada'.""" if not _legacy_hub_ok(): raise HTTPException( status_code=400, detail="Endpoint legacy sin destino: configura PRIMARY_DEVICE_ID o usa /api/devices/{device_id}/...", ) return _get_hub_or_404(PRIMARY_DEVICE_ID) def _sync_hubs_manifest(): for h in fl.hubs.values(): h.ota_manifest = OTA_MANIFEST if OTA_MANIFEST: h.ota_url = OTA_MANIFEST["url"] @asynccontextmanager async def lifespan(_app: FastAPI): app_log.warning("operator_proxy_required", extra={"event": "operator_proxy_required"}) fl.prune_logs(LOG_RETENTION_DAYS) async def _poll_loop(): while True: await asyncio.sleep(CREDENTIAL_POLL_SECONDS) try: await fl.poll_credentials() except Exception as e: app_log.error("credential_poll_error", extra={"event": "credential_poll_error", "error_type": type(e).__name__}) async def _retention_loop(): while True: await asyncio.sleep(3600) try: removed = fl.prune_logs(LOG_RETENTION_DAYS) if removed: app_log.info("log_retention_pruned", extra={"event": "log_retention_pruned", "result": removed}) except Exception: pass tasks = [asyncio.create_task(_poll_loop()), asyncio.create_task(_retention_loop())] try: yield finally: for t in tasks: t.cancel() await asyncio.gather(*tasks, return_exceptions=True) await asyncio.gather(*(h.force_close() for h in fl.hubs.values()), return_exceptions=True) st.close() app = FastAPI(title="ESP32 Solar Tracker Remote Hub", lifespan=lifespan) @app.exception_handler(sqlite3.Error) async def database_error(request, error): app_log.error("database_unavailable", extra={"event": "database_unavailable", "error_type": type(error).__name__}) return JSONResponse(status_code=503, content={"detail": "Almacenamiento no disponible"}) @app.exception_handler(StoreError) async def store_error(request, error): return JSONResponse(status_code=400, content={"detail": str(error)}) app.add_middleware( CORSMiddleware, allow_origins=sorted(ALLOWED_ORIGINS), allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.middleware("http") async def reject_cross_origin_writes(request, call_next): # CORS alone does not stop a simple POST from reaching the endpoint. origin = request.headers.get("origin") if (request.method not in {"GET", "HEAD", "OPTIONS"} and origin is not None and origin.rstrip("/") not in ALLOWED_ORIGINS): return JSONResponse(status_code=403, content={"detail": "Origen no autorizado"}) return await call_next(request) os.makedirs(LOGS_DIR, exist_ok=True) os.makedirs(STATIC_DIR, exist_ok=True) app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") @app.get("/") async def get_dashboard(): return FileResponse(os.path.join(STATIC_DIR, "index.html")) @app.get("/health") async def health(): return { "ok": st.last_write_error is None, "store_readable": True, "store_write_error": st.last_write_error, "max_devices": MAX_DEVICES, "registered": len(st.list_devices()), } # ==================== WEBSOCKETS ==================== @app.websocket("/ws/esp32") async def esp32_ws_endpoint(websocket: WebSocket): device_id = websocket.query_params.get("device_id", "") if not is_valid_device_id(device_id): app_log.warning("esp32_reject_bad_id", extra={"event": "esp32_reject_bad_id"}) await _reject_ws(websocket, 1008) return token = _bearer_token(websocket.headers) if not token: app_log.warning("esp32_reject_no_token", extra={"event": "esp32_reject_no_token", "device_id": device_id}) await _reject_ws(websocket, 1008) return if not fl.authenticate(device_id, token): # Rechazo genérico: no revelar si la identidad existe. app_log.warning("esp32_auth_failed", extra={"event": "esp32_auth_failed", "device_id": device_id}) await _reject_ws(websocket, 1008) return h = _get_hub_or_404(device_id) if h is None: await _reject_ws(websocket, 1008) return conn_id = None try: conn_id = await h.connect_esp32(websocket, lambda cid: fl.bind_session(device_id, cid, token)) del token while True: data = await websocket.receive_text() if not fl.session_valid(device_id, conn_id): await websocket.close(code=1008) return if len(data.encode("utf-8", errors="ignore")) > WS_MESSAGE_MAX: app_log.warning("esp32_message_too_large", extra={"event": "esp32_message_too_large", "device_id": device_id}) await websocket.close(code=1009) return if not fl.check_rate(device_id): app_log.warning("esp32_rate_limited", extra={"event": "esp32_rate_limited", "device_id": device_id}) await websocket.close(code=1008) return await h.handle_esp32_message(data, conn_id) except WebSocketDisconnect: pass except Exception as e: app_log.error("esp32_session_error", extra={"event": "esp32_session_error", "device_id": device_id, "error_type": type(e).__name__}) finally: # Guardia por connection_id: un finally tardío de una sesión # reemplazada no desconecta a la nueva. if conn_id is not None: h.disconnect_esp32(conn_id) fl.forget_session(device_id, conn_id) await h.broadcast_status() await h.broadcast_ota() @app.websocket("/ws/client") async def client_ws_endpoint(websocket: WebSocket): device_id = websocket.query_params.get("device_id", "") origin = websocket.headers.get("origin") if not origin or origin.rstrip("/") not in ALLOWED_ORIGINS: app_log.warning("client_origin_rejected", extra={"event": "client_origin_rejected"}) await _reject_ws(websocket, 1008) return if not is_valid_device_id(device_id): await _reject_ws(websocket, 1008) return if not fl.is_registered(device_id): await _reject_ws(websocket, 1008) return h = _get_hub_or_404(device_id) await h.connect_client(websocket) try: while True: cmd = await websocket.receive_text() if not _valid_command(cmd): await websocket.send_text("LOG:[ERROR] Comando inválido (formato/tamaño)...\n") continue if (not fl.session_valid(device_id, h.connection_id) or not await h.send_command_to_esp32(cmd)): await websocket.send_text("LOG:[ERROR] ESP32 no conectada en este momento\n") except WebSocketDisconnect: pass except Exception as e: app_log.error("client_session_error", extra={"event": "client_session_error", "device_id": device_id, "error_type": type(e).__name__}) finally: h.disconnect_client(websocket) # ==================== REST: flota ==================== @app.get("/api/devices") async def list_devices(): return fl.list_devices() def _device_status(device_id: str): h = _get_hub_or_404(device_id) dev = st.get_device(device_id) return h.status_payload(alias=dev["alias"] if dev else None) @app.get("/api/devices/{device_id}/status") async def device_status(device_id: str): return _device_status(device_id) @app.get("/api/devices/{device_id}/calib") async def get_calib(device_id: str): h = _get_hub_or_404(device_id) return {"device_id": device_id, "mV_per_WM2": h.pyrano, "az_dev": h.azdev} @app.post("/api/devices/{device_id}/calib") async def set_calib(device_id: str, payload: dict): h = _get_hub_or_404(device_id) try: result = h.apply_calibration(payload.get("mV_per_WM2"), payload.get("az_dev")) except (ValueError, TypeError) as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: # Persistencia fallida: se conserva el estado previo, sin ok ficticio. app_log.error("calib_persist_failed", extra={"event": "calib_persist_failed", "device_id": device_id, "error_type": type(e).__name__}) raise HTTPException(status_code=500, detail="No se pudo persistir; se conserva el estado previo") await h.rebroadcast_config() return {"ok": True, "device_id": device_id, **result} @app.get("/api/devices/{device_id}/pcb") async def get_pcb(device_id: str): h = _get_hub_or_404(device_id) return {"device_id": device_id, "version": h.pcb_version, "r_shunt": PCB_VERSIONS[h.pcb_version], "versions": PCB_VERSIONS} @app.post("/api/devices/{device_id}/pcb") async def set_pcb(device_id: str, payload: dict): h = _get_hub_or_404(device_id) if not h.set_pcb_version(payload.get("version")): raise HTTPException(status_code=400, detail="Versión de PCB inválida") await h.rebroadcast_config() return {"ok": True, "device_id": device_id, "version": h.pcb_version, "r_shunt": PCB_VERSIONS[h.pcb_version]} _LOG_FILE_RE = re.compile(r"^log_\d{4}-\d{2}-\d{2}\.txt$") def _device_logs_dir(device_id: str): _validate_device_id_param(device_id) d = os.path.join(LOGS_DIR, device_id) return d @app.get("/api/devices/{device_id}/logs") async def list_logs(device_id: str): d = _device_logs_dir(device_id) if not st.get_device(device_id): raise HTTPException(status_code=404, detail="device_id no registrado") files = glob.glob(os.path.join(d, "log_*.txt")) if os.path.isdir(d) else [] result = [] for f in sorted(files, reverse=True): basename = os.path.basename(f) result.append({ "filename": basename, "size_kb": round(os.path.getsize(f) / 1024, 2), "date": basename.replace("log_", "").replace(".txt", ""), "last_modified": datetime.fromtimestamp(os.path.getmtime(f), tz=LOCAL_TZ).strftime("%Y-%m-%d %H:%M:%S"), }) return result @app.get("/api/devices/{device_id}/logs/{filename}") async def download_log(device_id: str, filename: str): if not _LOG_FILE_RE.fullmatch(filename): raise HTTPException(status_code=400, detail="Nombre de archivo inválido") d = _device_logs_dir(device_id) if not st.get_device(device_id): raise HTTPException(status_code=404, detail="device_id no registrado") path = os.path.realpath(os.path.join(d, filename)) if not path.startswith(os.path.realpath(d) + os.sep) or not os.path.exists(path): raise HTTPException(status_code=404, detail="Archivo no encontrado") return FileResponse(path, filename=filename, media_type="text/plain") # ==================== REST: OTA por dispositivo ==================== @app.post("/api/devices/{device_id}/ota/push") async def ota_push_device(device_id: str, payload: dict): h = _get_hub_or_404(device_id) sha = str(payload.get("sha256") or "").strip().lower() cur = str((OTA_MANIFEST or {}).get("sha256") or "").lower() if not re.fullmatch(r"[a-f0-9]{64}", sha) or not cur or not hmac.compare_digest(sha, cur): raise HTTPException(status_code=409, detail="sha256 no coincide con el manifiesto actual") url = OTA_MANIFEST["url"] connection_id = h.connection_id if not fl.session_valid(device_id, connection_id) or not await h.push_ota(sha, connection_id): raise HTTPException(status_code=400, detail="Dispositivo no conectado") return {"ok": True, "device_id": device_id, "url": url} @app.post("/api/devices/{device_id}/ota/auto") async def ota_auto_device(device_id: str, payload: dict): h = _get_hub_or_404(device_id) if not isinstance(payload.get("enabled"), bool): raise HTTPException(status_code=400, detail="enabled debe ser booleano") try: await h.set_ota_auto(bool(payload.get("enabled", False))) except Exception as e: app_log.error("ota_auto_persist_failed", extra={"event": "ota_auto_persist_failed", "device_id": device_id, "error_type": type(e).__name__}) raise HTTPException(status_code=500, detail="No se pudo persistir el estado OTA") return {"ok": True, "device_id": device_id, "enabled": h.ota_auto_enabled} # ==================== REST: aliases legacy ==================== @app.get("/api/status") async def get_status(): return _legacy_hub().status_payload() @app.get("/api/calib") async def get_calib_legacy(): h = _legacy_hub() return {"mV_per_WM2": h.pyrano, "az_dev": h.azdev} @app.post("/api/calib") async def set_calib_legacy(payload: dict): h = _legacy_hub() try: result = h.apply_calibration(payload.get("mV_per_WM2"), payload.get("az_dev")) except (ValueError, TypeError) as e: raise HTTPException(status_code=400, detail=str(e)) except Exception: raise HTTPException(status_code=500, detail="No se pudo persistir; se conserva el estado previo") await h.rebroadcast_config() return {"ok": True, **result} @app.get("/api/pcb") async def get_pcb_legacy(): h = _legacy_hub() return {"version": h.pcb_version, "r_shunt": PCB_VERSIONS[h.pcb_version], "versions": PCB_VERSIONS} @app.post("/api/pcb") async def set_pcb_legacy(payload: dict): h = _legacy_hub() if not h.set_pcb_version(payload.get("version")): raise HTTPException(status_code=400, detail="Versión de PCB inválida") await h.rebroadcast_config() return {"ok": True, "version": h.pcb_version, "r_shunt": PCB_VERSIONS[h.pcb_version]} @app.get("/api/logs") async def list_logs_legacy(): _legacy_hub() # valida destino explícito (400/404 si no hay primaria) return await list_logs(PRIMARY_DEVICE_ID) @app.get("/api/logs/{filename}") async def download_log_legacy(filename: str): if not _legacy_hub_ok(): raise HTTPException(status_code=400, detail="Endpoint legacy sin destino: usa /api/devices/{device_id}/logs/...") return await download_log(PRIMARY_DEVICE_ID, filename) # ==================== OTA global (artefacto + manifiesto) ==================== @app.get("/ota/manifest") async def ota_manifest(): return OTA_MANIFEST or {"version": None, "size": 0, "sha256": None, "url": OTA_PUBLIC_URL + "/ota/firmware"} @app.get("/ota/firmware") async def ota_firmware(): if not os.path.exists(FIRMWARE_BIN): raise HTTPException(status_code=404, detail="Sin firmware subido") return FileResponse(FIRMWARE_BIN, media_type="application/octet-stream", filename="firmware.bin") @app.get("/ota/images/{digest}") async def ota_image(digest: str): if not re.fullmatch(r"[a-f0-9]{64}", digest): raise HTTPException(status_code=404, detail="Imagen no encontrada") path = os.path.join(FIRMWARE_DIR, digest + ".bin") if not os.path.isfile(path): raise HTTPException(status_code=404, detail="Imagen no encontrada") return FileResponse(path, media_type="application/octet-stream", filename="firmware.bin") @app.post("/ota/upload") async def ota_upload(file: UploadFile = File(...), version: str = Form(None)): global OTA_MANIFEST data = await file.read(OTA_SLOT_MAX + 1) if not data or len(data) > OTA_SLOT_MAX: raise HTTPException(status_code=400, detail="Archivo inválido o excede slot") if version and len(version) > 128: raise HTTPException(status_code=400, detail="Versión demasiado larga") async with _upload_lock: digest = hashlib.sha256(data).hexdigest() path = os.path.join(FIRMWARE_DIR, digest + ".bin") if not os.path.exists(path): _write_atomic(path, data) manifest = { "version": (version or "").strip() or ("build-" + get_local_now().strftime("%Y%m%d-%H%M%S")), "size": len(data), "sha256": digest, "uploaded_at": get_local_now().strftime("%Y-%m-%d %H:%M:%S"), "url": OTA_PUBLIC_URL + "/ota/images/" + digest, } _write_atomic(OTA_MANIFEST_FILE, json.dumps(manifest, indent=2).encode("utf-8")) _write_atomic(FIRMWARE_BIN, data) OTA_MANIFEST = manifest _sync_hubs_manifest() for h in fl.hubs.values(): await h.broadcast_ota() # La subida NUNCA dispara auto-actualización ni cambia tarjetas objetivo. app_log.info("ota_uploaded", extra={"event": "ota_uploaded", "result": manifest["sha256"][:16]}) return {"ok": True, "manifest": manifest} @app.post("/ota/push") async def ota_push_legacy(): raise HTTPException(status_code=400, detail="Endpoint legacy sin destino: usa /api/devices/{device_id}/ota/push") @app.post("/ota/auto") async def ota_auto_legacy(): raise HTTPException(status_code=400, detail="Endpoint legacy sin destino: usa /api/devices/{device_id}/ota/auto") return app _load_global_manifest() app = create_app()