614 lines
26 KiB
Python
614 lines
26 KiB
Python
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:<n> | SET_COORDS:<x>:<y> | SET_PARAM:<name>=<val>
|
|
_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()
|