#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ ESP32 Solar Tracker — Remote capture hub + real-time dashboard. The ESP32 opens a WebSocket to /ws/esp32 and streams: * periodic samples : [YYYY-MM-DD HH:MM:SS] X:x,Y:y,C:c,V:v,P:p,S:s,er:e * position changes : POS:x:y * events : SunPos / Az_deviation / ErrMax / FineTune / ... Every incoming line is appended to a daily file (data_logs/log_YYYY-MM-DD.txt) for persistence. Periodic samples are additionally converted to engineering units (identical to parse_log.py / the article) and streamed to the browser clients on /ws/client as pre-computed, unit-correct real-time series. CALIBRATION — ADS122C04 (23-bit, Vref 2.048 V), same as parse_log.py: G_POA [W/m2] = P_raw / ADC_MAX * VREF_MV / pyrano Voc [V] = V_raw / ADC_MAX * VREF_V * VOC_DIVIDER Isc [A] = (C_raw / ADC_MAX * VREF_MV / GAIN_SHUNT) / 1000 / R_SHUNT P_CPV[W] = Voc * Isc * FF `pyrano` is the pyranometer sensitivity in mV per W/m2. The 2025 field revision reports 0.2 mV per W/m2 (the previous firmware assumed 2.0). It is tunable at runtime via POST /api/calib {"mV_per_WM2": } so the live plot can be re-calibrated on the fly against a reference. """ import os import re import json import glob import hashlib import asyncio from collections import deque from datetime import datetime from typing import List, Optional from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, UploadFile, File, Form from fastapi.responses import HTMLResponse, FileResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles app = FastAPI(title="ESP32 Solar Tracker Remote Hub") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) LOGS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data_logs") os.makedirs(LOGS_DIR, exist_ok=True) # Serve the locally-bundled Chart.js so the dashboard works with NO internet # (neither the server nor the browser needs connectivity). STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") os.makedirs(STATIC_DIR, exist_ok=True) app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") # ---------------------------------------------------------------------------- # OTA firmware store. The hub keeps the uploaded image on disk (volume), # serves it over the SAME HTTPS origin the ESP32 already talks to, and pushes # an "OTA:" command down the existing WSS link so the tracker flashes + # reboots into the new build. The 16 MB board already has ota_0/ota_1 slots. # ---------------------------------------------------------------------------- FIRMWARE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "firmware") os.makedirs(FIRMWARE_DIR, exist_ok=True) FIRMWARE_BIN = os.path.join(FIRMWARE_DIR, "firmware.bin") OTA_MANIFEST_FILE = os.path.join(FIRMWARE_DIR, "manifest.json") OTA_STATE_FILE = os.path.join(FIRMWARE_DIR, "ota_state.json") # ota_0 / ota_1 slots in default_16MB.csv are 0x640000 = 6553600 bytes. OTA_SLOT_MAX = 0x640000 # Public base URL the ESP32 can reach to download the image. Defaults to the # same TLS host the tracker connects to; override with the OTA_PUBLIC_URL env. OTA_PUBLIC_URL = os.environ.get("OTA_PUBLIC_URL", "https://micro.ferrasdh.uk").rstrip("/") # ---------------------------------------------------------------------------- # Calibration / geometry (kept in sync with parse_log.py and the firmware) # ---------------------------------------------------------------------------- ADC_MAX = 8388607.0 # 2^23 - 1 (ADS122C04 code count) VREF_MV = 2048.0 # internal reference, mV VREF_V = 2.048 # internal reference, V GAIN_SHUNT = 20.0 # AD8418 instrumentation gain on the Isc channel R_SHUNT = 0.075 # current shunt, ohms FF = 0.82 # fill factor (characterized) VOC_DIVIDER = 2.0 # 10k/10k divider in front of the Voc channel PYRANO_MV_PER_WM2 = 0.2 # mV per W/m2 -> NEW calibration (was 2.0) AZ_DEV_DEFAULT = 5.0 # deg; azimuth coupling used by trackSunByTime STEPS_80 = 53000.0 # micro-steps that span 80 deg on one axis DEG_SPAN = 80.0 # mechanical span per axis, deg MAX_SAMPLES = 4000 # rolling in-memory history (bounds calib recompute) HISTORY_LIMIT = 2000 # most-recent points replayed to a new client # ---------------------------------------------------------------------------- # Log-line parsers (tolerate the legacy leading 'LOG:' prefix) # ---------------------------------------------------------------------------- RE_SAMPLE = re.compile( r"^(?:LOG:)?\[(?P[0-9\-/ :]+)\]\s*X:(?P-?\d+),Y:(?P-?\d+)," r"C:(?P-?\d+),V:(?P-?\d+),P:(?P

-?\d+),S:\d+,er:(?P-?\d+)\s*$" ) RE_PARAM = re.compile( r"^(?:LOG:)?\[(?P[0-9\-/ :]+)\]\s*(?PAz_deviation|ErrMax|" r"MinErrMax|TiltAngle|delay)\s*=\s*(?P[-\d.]+)\s*$" ) _TS_FORMATS = ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%d/%m/%Y %H:%M:%S", "%d/%m/%Y %H:%M") def parse_ts(s): s = s.strip() for fmt in _TS_FORMATS: try: return datetime.strptime(s, fmt) except Exception: pass return None class HubManager: """Bridges the ESP32 and the browser clients. It persists every raw line to a daily file, converts the periodic samples to engineering units (the article's calibration), keeps a rolling buffer of those samples, and streams them (plus the raw log / position) to every connected browser in real time. """ def __init__(self): self.esp32_socket: Optional[WebSocket] = None self.esp32_connected = False self.client_sockets: List[WebSocket] = [] self.last_pos_x = 0 self.last_pos_y = 0 self.last_seen: Optional[datetime] = None # runtime-tunable calibration (see /api/calib) self.pyrano = PYRANO_MV_PER_WM2 # mV per W/m2 self.azdev = AZ_DEV_DEFAULT # deg # rolling physical history, newest last self.samples: deque = deque(maxlen=MAX_SAMPLES) # ---- OTA / firmware-update state ---------------------------------- self.esp32_firmware_version: Optional[str] = None self.ota_manifest: Optional[dict] = None self.ota_auto_enabled: bool = False self.ota_url: str = OTA_PUBLIC_URL + "/ota/firmware" self._load_ota_state() # ---- ESP32 side ------------------------------------------------------- async def connect_esp32(self, websocket: WebSocket): await websocket.accept() self.esp32_socket = websocket self.esp32_connected = True self.last_seen = datetime.now() print(f"[{datetime.now():%Y-%m-%d %H:%M:%S}] ESP32 conectado." + (f" firmware={self.esp32_firmware_version}" if self.esp32_firmware_version else "")) await self.broadcast_status() await self.broadcast_ota() def disconnect_esp32(self): self.esp32_socket = None if self.esp32_connected: print(f"[{datetime.now():%Y-%m-%d %H:%M:%S}] ESP32 desconectado.") self.esp32_connected = False async def handle_esp32_message(self, message: str): self.last_seen = datetime.now() # 1) persist the raw line to today's file fn = f"log_{self.last_seen:%Y-%m-%d}.txt" with open(os.path.join(LOGS_DIR, fn), "a", encoding="utf-8") as f: f.write(message.strip() + "\n") line = message.strip() # 2) live position (steps) for the header / trajectory if line.startswith("POS:"): parts = line.split(":") if len(parts) >= 3: try: self.last_pos_x = int(parts[1]) self.last_pos_y = int(parts[2]) except ValueError: pass # 2b) firmware version reported by the tracker on (re)connect if line.startswith("VER:"): v = line[4:].strip() if v and v != self.esp32_firmware_version: self.esp32_firmware_version = v print(f"[{datetime.now():%Y-%m-%d %H:%M:%S}] Firmware en ESP32: {v}") await self._broadcast("FW:" + v) await self.broadcast_ota() self._maybe_auto_update() # 3) periodic sample -> engineering units + rolling history m = RE_SAMPLE.match(line) if m: pt = self.on_sample(m.groupdict()) if pt is not None: await self._broadcast("DATA:" + json.dumps(pt)) # 4) keep tunables up to date (azimuth coupling, thresholds, ...) pm = RE_PARAM.match(line) if pm: name, val = pm.group("name"), float(pm.group("val")) if name == "Az_deviation" and abs(val - self.azdev) > 1e-6: self.azdev = val self._recompute_position() await self._broadcast("CONFIG:" + self._config_json()) # 5) forward the raw line to the web (live log panel) await self._broadcast("LOG:" + line + "\n") # ---- unit conversion (article calibration) --------------------------- def _units(self, x, y, c, v, p): gpoa = p / ADC_MAX * (VREF_MV / self.pyrano) # W/m2 voc = v / ADC_MAX * VREF_V * VOC_DIVIDER # V isc = (c / ADC_MAX * (VREF_MV / GAIN_SHUNT)) / 1000.0 / R_SHUNT # A pcpv = voc * isc * FF # W return gpoa, voc, isc, pcpv def _pos_deg(self, x, y): x_base = x - self.azdev / 90.0 * y el = 90.0 - (x_base - STEPS_80) / STEPS_80 * DEG_SPAN az = 180.0 + (y - STEPS_80) / STEPS_80 * DEG_SPAN return az, el def on_sample(self, d): ts = parse_ts(d["ts"]) t = ts.timestamp() if ts else datetime.now().timestamp() x, y = int(d["x"]), int(d["y"]) c, v, p = int(d["c"]), int(d["v"]), int(d["p"]) gpoa, voc, isc, pcpv = self._units(x, y, c, v, p) az, el = self._pos_deg(x, y) pt = {"ts": round(t, 3), "x": x, "y": y, "p_raw": p, "gpoa": round(gpoa, 1), "voc": round(voc, 3), "isc_ma": round(isc * 1000, 2), "pcpv_mw": round(pcpv * 1000, 2), "az": round(az, 2), "el": round(el, 2)} self.samples.append(pt) return pt def _recompute_position(self): for pt in self.samples: az, el = self._pos_deg(pt["x"], pt["y"]) pt["az"], pt["el"] = round(az, 2), round(el, 2) def _config_json(self): return json.dumps({"pyrano": self.pyrano, "azdev": self.azdev, "adc_max": ADC_MAX, "vref_mv": VREF_MV, "vref_v": VREF_V, "gain_shunt": GAIN_SHUNT, "r_shunt": R_SHUNT, "ff": FF, "voc_div": VOC_DIVIDER, "steps80": STEPS_80, "deg_span": DEG_SPAN, "pos": [self.last_pos_x, self.last_pos_y]}) # ---- browser-client side --------------------------------------------- async def connect_client(self, websocket: WebSocket): await websocket.accept() self.client_sockets.append(websocket) try: await websocket.send_text("STATUS:%s" % ("ONLINE" if self.esp32_connected else "OFFLINE")) await websocket.send_text("CONFIG:" + self._config_json()) await websocket.send_text("HISTORY:" + json.dumps(list(self.samples)[-HISTORY_LIMIT:])) await websocket.send_text("POS:%d:%d" % (self.last_pos_x, self.last_pos_y)) if self.esp32_firmware_version: await websocket.send_text("FW:" + self.esp32_firmware_version) await websocket.send_text("OTA_STATE:" + json.dumps(self._ota_payload())) today = f"log_{datetime.now():%Y-%m-%d}.txt" path = os.path.join(LOGS_DIR, today) if os.path.exists(path): with open(path, "r", encoding="utf-8", errors="ignore") as f: tail = "".join(f.readlines()[-40:]) if tail: await websocket.send_text("LOG:" + tail) except Exception as e: print("connect_client error:", e) def disconnect_client(self, websocket): if websocket in self.client_sockets: self.client_sockets.remove(websocket) async def _broadcast(self, msg): for c in list(self.client_sockets): try: await c.send_text(msg) except Exception: self.disconnect_client(c) async def broadcast_status(self): await self._broadcast("STATUS:%s" % ("ONLINE" if self.esp32_connected else "OFFLINE")) async def send_command_to_esp32(self, command: str) -> bool: if self.esp32_socket and self.esp32_connected: try: await self.esp32_socket.send_text(command) return True except Exception as e: print("Error enviando comando a ESP32:", e) self.disconnect_esp32() return False # ---- runtime calibration ---------------------------------------------- def apply_calibration(self, mV_per_WM2=None, az_dev=None): if mV_per_WM2 is not None and float(mV_per_WM2) > 0: self.pyrano = float(mV_per_WM2) for pt in self.samples: if pt.get("p_raw") is not None: pt["gpoa"] = round(pt["p_raw"] / ADC_MAX * (VREF_MV / self.pyrano), 1) if az_dev is not None: self.azdev = float(az_dev) self._recompute_position() return True async def rebroadcast_config(self): await self._broadcast("CONFIG:" + self._config_json()) await self._broadcast("HISTORY:" + json.dumps(list(self.samples)[-HISTORY_LIMIT:])) # ---- OTA / firmware-update --------------------------------------------- def _ota_payload(self) -> dict: return { "device": self.esp32_firmware_version, "available": self.ota_manifest, "auto": self.ota_auto_enabled, "url": self.ota_url, "connected": self.esp32_connected, } def _load_ota_state(self): try: if os.path.exists(OTA_MANIFEST_FILE): with open(OTA_MANIFEST_FILE, "r", encoding="utf-8") as f: self.ota_manifest = json.load(f) if os.path.exists(OTA_STATE_FILE): with open(OTA_STATE_FILE, "r", encoding="utf-8") as f: self.ota_auto_enabled = bool(json.load(f).get("auto", False)) except Exception as e: print("No se pudo cargar el estado OTA:", e) def _save_ota_state(self): try: os.makedirs(FIRMWARE_DIR, exist_ok=True) with open(OTA_STATE_FILE, "w", encoding="utf-8") as f: json.dump({"auto": self.ota_auto_enabled}, f) except Exception as e: print("No se pudo guardar el estado OTA:", e) def _save_manifest(self): if self.ota_manifest: try: self.ota_manifest["url"] = self.ota_url os.makedirs(FIRMWARE_DIR, exist_ok=True) with open(OTA_MANIFEST_FILE, "w", encoding="utf-8") as f: json.dump(self.ota_manifest, f, indent=2) except Exception as e: print("No se pudo guardar el manifiesto OTA:", e) async def broadcast_ota(self): await self._broadcast("OTA_STATE:" + json.dumps(self._ota_payload())) async def push_ota_to_esp32(self) -> bool: if not (self.esp32_connected and self.esp32_socket): return False if not self.ota_manifest or not os.path.exists(FIRMWARE_BIN): return False ok = await self.send_command_to_esp32("OTA:" + self.ota_url) if ok: await self._broadcast( "LOG:[hub] Empujando firmware '%s' a la ESP32 (descarga + reinicio)...\n" % (self.ota_manifest.get("version", "?"))) return ok def _maybe_auto_update(self): try: if not self.ota_auto_enabled: return if not self.ota_manifest or not os.path.exists(FIRMWARE_BIN): return if self.esp32_firmware_version and \ self.esp32_firmware_version == self.ota_manifest.get("version"): return async def _do(): await asyncio.sleep(3) # let the WSS link settle before flashing if self.esp32_connected: await self.push_ota_to_esp32() asyncio.create_task(_do()) except Exception as e: print("Auto-update OTA error:", e) hub = HubManager() # ==================== WEBSOCKETS ==================== @app.websocket("/ws/esp32") async def esp32_ws_endpoint(websocket: WebSocket): await hub.connect_esp32(websocket) try: while True: data = await websocket.receive_text() await hub.handle_esp32_message(data) except WebSocketDisconnect: hub.disconnect_esp32() await hub.broadcast_status() await hub.broadcast_ota() except Exception as e: print(f"Excepcion en conexion ESP32: {e}") hub.disconnect_esp32() await hub.broadcast_status() await hub.broadcast_ota() @app.websocket("/ws/client") async def client_ws_endpoint(websocket: WebSocket): await hub.connect_client(websocket) try: while True: # Any text the browser sends is a command for the ESP32. cmd = await websocket.receive_text() if not await hub.send_command_to_esp32(cmd): await websocket.send_text("LOG:[ERROR] ESP32 no conectado en este momento\n") except WebSocketDisconnect: hub.disconnect_client(websocket) except Exception as e: print(f"Excepcion en cliente web: {e}") hub.disconnect_client(websocket) # ==================== REST APIS ==================== @app.get("/api/status") async def get_status(): return { "esp32_connected": hub.esp32_connected, "last_seen": hub.last_seen.isoformat() if hub.last_seen else None, "pos_x": hub.last_pos_x, "pos_y": hub.last_pos_y, "pyrano_mV_per_Wm2": hub.pyrano, "az_dev_deg": hub.azdev, "active_clients": len(hub.client_sockets), "samples_in_buffer": len(hub.samples), "firmware_device": hub.esp32_firmware_version, "ota": hub._ota_payload(), } @app.get("/api/calib") async def get_calib(): return {"mV_per_WM2": hub.pyrano, "az_dev": hub.azdev} @app.post("/api/calib") async def set_calib(payload: dict): hub.apply_calibration(payload.get("mV_per_WM2"), payload.get("az_dev")) await hub.rebroadcast_config() return {"ok": True, "mV_per_WM2": hub.pyrano, "az_dev": hub.azdev} @app.get("/api/logs") async def list_logs(): files = glob.glob(os.path.join(LOGS_DIR, "log_*.txt")) result = [] for f in sorted(files, reverse=True): basename = os.path.basename(f) size_bytes = os.path.getsize(f) mtime = os.path.getmtime(f) result.append({ "filename": basename, "size_kb": round(size_bytes / 1024, 2), "date": basename.replace("log_", "").replace(".txt", ""), "last_modified": datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S"), }) return result @app.get("/api/logs/{filename}") async def download_log(filename: str): clean_name = os.path.basename(filename) path = os.path.join(LOGS_DIR, clean_name) if not os.path.exists(path): raise HTTPException(status_code=404, detail="Archivo de log no encontrado") return FileResponse(path, filename=clean_name, media_type="text/plain") # ==================== OTA / FIRMWARE UPDATE ==================== @app.get("/ota/manifest") async def ota_manifest(): """Metadata of the currently-stored firmware image (version/size/sha/url).""" if hub.ota_manifest: return hub.ota_manifest return {"version": None, "size": 0, "sha256": None, "url": hub.ota_url} @app.get("/ota/firmware") async def ota_firmware(): """Serve the stored image to the ESP32 over HTTPS (same TLS origin).""" if not os.path.exists(FIRMWARE_BIN): raise HTTPException(status_code=404, detail="No hay firmware subido todavía") return FileResponse(FIRMWARE_BIN, media_type="application/octet-stream", filename="firmware.bin") @app.post("/ota/upload") async def ota_upload(file: UploadFile = File(...), version: Optional[str] = Form(None)): """Store a new firmware image uploaded from the dashboard.""" data = await file.read() if not data: raise HTTPException(status_code=400, detail="El archivo subido está vacío") if len(data) > OTA_SLOT_MAX: raise HTTPException(status_code=413, detail=f"El .bin ({len(data)} B) excede el slot OTA ({OTA_SLOT_MAX} B)") try: os.makedirs(FIRMWARE_DIR, exist_ok=True) with open(FIRMWARE_BIN, "wb") as f: f.write(data) except Exception as e: raise HTTPException(status_code=500, detail=f"No se pudo guardar el archivo: {e}") hub.ota_manifest = { "version": version or ("build-" + datetime.now().strftime("%Y%m%d-%H%M%S")), "size": len(data), "sha256": hashlib.sha256(data).hexdigest(), "uploaded_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "url": hub.ota_url, } hub._save_manifest() print(f"[{datetime.now():%Y-%m-%d %H:%M:%S}] Firmware OTA subido: {hub.ota_manifest}") await hub.broadcast_ota() hub._maybe_auto_update() # if auto-update is on and the tracker is connected, push now return {"ok": True, "manifest": hub.ota_manifest} @app.post("/ota/push") async def ota_push(): """Tell the connected ESP32 to download + flash the stored image right now.""" ok = await hub.push_ota_to_esp32() if not ok: raise HTTPException(status_code=400, detail="ESP32 no conectado o sin firmware subido") return {"ok": True, "url": hub.ota_url, "version": (hub.ota_manifest.get("version") if hub.ota_manifest else None)} @app.post("/ota/auto") async def ota_auto(payload: dict): """Enable/disable automatic OTA on (re)connect.""" hub.ota_auto_enabled = bool(payload.get("enabled", False)) hub._save_ota_state() await hub.broadcast_ota() if hub.ota_auto_enabled: hub._maybe_auto_update() return {"ok": True, "enabled": hub.ota_auto_enabled} # ==================== DASHBOARD WEB ==================== DASHBOARD_HTML = r""" Cloud Hub — ESP32 Solar Tracker

☀️ ESP32 Solar Tracker — Cloud Hub

-- ESP32 OFFLINE
Irradiancia
–
W/m²
Potencia P_CPV
–
mW
Voc
–
V
Isc
–
mA
Azimut
–
grados
Elevación
–
grados
Ventana
Piranómetro (mV/W/m²)

Irradiancia G_POA · W/m²

Potencia CPV P_CPV · mW

Posición solar azimut & elevación · grados

Señales del CPV Voc (V) · Isc (mA)

Trayectoria de la cabeza X–Y (pasos) · X 0 · Y 0

Registro en tiempo real

Esperando datos del ESP32…

Comandos remotos

Ajustes del tracker

Archivos de log

FechaTamaño
Cargando…

Firmware / OTA

--
sin imagen
""" # ==================== DASHBOARD ROUTE ==================== @app.get("/", response_class=HTMLResponse) async def get_dashboard(): return DASHBOARD_HTML