Files

833 lines
44 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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": <value>}
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:<url>" 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<ts>[0-9\-/ :]+)\]\s*X:(?P<x>-?\d+),Y:(?P<y>-?\d+),"
r"C:(?P<c>-?\d+),V:(?P<v>-?\d+),P:(?P<p>-?\d+),S:\d+,er:(?P<er>-?\d+)\s*$"
)
RE_PARAM = re.compile(
r"^(?:LOG:)?\[(?P<ts>[0-9\-/ :]+)\]\s*(?P<name>Az_deviation|ErrMax|"
r"MinErrMax|TiltAngle|delay)\s*=\s*(?P<val>[-\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"""<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cloud Hub — ESP32 Solar Tracker</title>
<script src="/static/chart.umd.min.js"></script>
<style>
*{box-sizing:border-box}
body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif;margin:0;padding:14px;background:#0b1120;color:#e2e8f0}
header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #1e293b;padding-bottom:12px;margin-bottom:14px}
h1{margin:0;font-size:20px;color:#38bdf8;display:flex;align-items:center;gap:8px}
h2{font-size:14px;margin:0 0 10px;color:#94a3b8;border-bottom:1px solid #1e293b;padding-bottom:6px;display:flex;align-items:center;justify-content:space-between}
.muted{color:#64748b;font-size:12px}
.badge{padding:4px 12px;border-radius:999px;font-size:12px;font-weight:700}
.badge-online{background:#14532d;color:#4ade80;border:1px solid #22c55e}
.badge-offline{background:#7f1d1d;color:#f87171;border:1px solid #ef4444}
.tiles{display:grid;grid-template-columns:repeat(6,1fr);gap:10px;margin-bottom:14px}
.tile{background:#111a2e;border:1px solid #1e293b;border-radius:8px;padding:10px 12px}
.tile .tl{font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.4px}
.tile .tv{font-size:22px;font-weight:700;margin-top:4px;font-variant-numeric:tabular-nums}
.tile .tu{font-size:11px;color:#64748b}
.card{background:#111a2e;border:1px solid #1e293b;border-radius:8px;padding:14px}
.controlbar{display:flex;flex-wrap:wrap;gap:18px;align-items:center;margin-bottom:14px}
.cb-group{display:flex;align-items:center;gap:6px}
.cb-label{font-size:11px;color:#94a3b8;text-transform:uppercase}
button{background:#0369a1;color:#fff;border:none;padding:6px 12px;border-radius:5px;font-weight:600;cursor:pointer;font-size:12px}
button:hover{filter:brightness(1.15)}
button.btn-active{background:#38bdf8;color:#0b1120}
.btn-danger{background:#b91c1c}.btn-warning{background:#b45309}.btn-secondary{background:#334155}
.mini{padding:2px 8px;font-size:11px}
input[type=number]{background:#0b1120;border:1px solid #334155;color:#e2e8f0;padding:5px 8px;border-radius:5px;width:74px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.span2{grid-column:1/-1}
.cwrap{position:relative;height:230px}
.unit{font-weight:400;font-size:11px;color:#64748b}
#log{height:230px;overflow-y:auto;background:#020617;color:#86efac;font-family:ui-monospace,Consolas,monospace;font-size:12px;padding:10px;border-radius:6px;border:1px solid #1e293b}
#log div{white-space:pre-wrap;word-break:break-word;line-height:1.5}
table{width:100%;border-collapse:collapse;font-size:12px;margin-top:6px}
th,td{text-align:left;padding:5px 8px;border-bottom:1px solid #1e293b}
th{color:#94a3b8}
.input-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;font-size:12px}
.cdnwarn{background:#7c2d12;border:1px solid #ea580c;color:#fde68a;padding:8px 12px;border-radius:6px;margin-bottom:14px;font-size:13px}
@media(max-width:900px){.tiles{grid-template-columns:repeat(3,1fr)}.grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<header>
<h1>☀️ ESP32 Solar Tracker — Cloud Hub</h1>
<div style="display:flex;align-items:center;gap:12px">
<span id="lastUpdate" class="muted">--</span>
<span id="trackerStatus" class="badge badge-offline">ESP32 OFFLINE</span>
</div>
</header>
<section class="tiles">
<div class="tile"><div class="tl">Irradiancia</div><div class="tv" id="t_gpoa">–</div><div class="tu">W/m²</div></div>
<div class="tile"><div class="tl">Potencia P_CPV</div><div class="tv" id="t_power">–</div><div class="tu">mW</div></div>
<div class="tile"><div class="tl">Voc</div><div class="tv" id="t_voc">–</div><div class="tu">V</div></div>
<div class="tile"><div class="tl">Isc</div><div class="tv" id="t_isc">–</div><div class="tu">mA</div></div>
<div class="tile"><div class="tl">Azimut</div><div class="tv" id="t_az">–</div><div class="tu">grados</div></div>
<div class="tile"><div class="tl">Elevación</div><div class="tv" id="t_el">–</div><div class="tu">grados</div></div>
</section>
<section class="card controlbar">
<div class="cb-group">
<span class="cb-label">Ventana</span>
<button data-win="300" onclick="setWindow(300,this)">5 min</button>
<button data-win="900" class="btn-active" onclick="setWindow(900,this)">15 min</button>
<button data-win="1800" onclick="setWindow(1800,this)">30 min</button>
<button data-win="3600" onclick="setWindow(3600,this)">60 min</button>
</div>
<div class="cb-group">
<span class="cb-label">Piranómetro (mV/W/m²)</span>
<input type="number" id="calPyrano" step="0.05" min="0.01" placeholder="0.2">
<button class="btn-warning" onclick="applyCalib()">Aplicar calibración</button>
<span id="calNote" class="muted"></span>
</div>
</section>
<div id="cdnWarn" class="cdnwarn" style="display:none">⚠️ Chart.js no cargó (¿sin internet en este equipo?). Los valores numéricos y el registro siguen funcionando.</div>
<main class="grid">
<section class="card chart"><h2>Irradiancia <span class="unit">G_POA · W/m²</span></h2><div class="cwrap"><canvas id="ch_gpoa"></canvas></div></section>
<section class="card chart"><h2>Potencia CPV <span class="unit">P_CPV · mW</span></h2><div class="cwrap"><canvas id="ch_power"></canvas></div></section>
<section class="card chart"><h2>Posición solar <span class="unit">azimut & elevación · grados</span></h2><div class="cwrap"><canvas id="ch_pos"></canvas></div></section>
<section class="card chart"><h2>Señales del CPV <span class="unit">Voc (V) · Isc (mA)</span></h2><div class="cwrap"><canvas id="ch_pv"></canvas></div></section>
<section class="card chart span2"><h2>Trayectoria de la cabeza <span class="unit">X–Y (pasos) · X <span id="posX" style="color:#38bdf8">0</span> · Y <span id="posY" style="color:#38bdf8">0</span></span></h2><div class="cwrap"><canvas id="ch_traj"></canvas></div></section>
<section class="card">
<h2>Registro en tiempo real <button class="mini btn-secondary" onclick="loadLogs()">↻ refrescar</button></h2>
<div id="log"><div style="color:#64748b">Esperando datos del ESP32…</div></div>
</section>
<section class="card">
<h2>Comandos remotos</h2>
<div style="margin-bottom:12px">
<button onclick="sendRaw('CMD:2')">Track solar</button>
<button onclick="sendRaw('CMD:3')">Fine-tune</button>
<button class="btn-danger" onclick="sendRaw('CMD:4')">Homing</button>
<button onclick="sendRaw('CMD:5')">Centro</button>
<button class="btn-secondary" onclick="sendRaw('CMD:10')">Estado TMC</button>
</div>
<h2>Ajustes del tracker</h2>
<div class="input-row"><label>Ir a coordenadas X,Y</label><div><input id="inPosX" type="number" placeholder="X"><input id="inPosY" type="number" placeholder="Y"><button onclick="sendCoords()">Enviar</button></div></div>
<div class="input-row"><label>ErrMax (counts)</label><div><input id="inErr" type="number"><button onclick="sendParam('errMax','inErr')">Aplicar</button></div></div>
<div class="input-row"><label>Az-dev (grados)</label><div><input id="inAz" type="number" step="0.5"><button onclick="sendParam('az','inAz')">Aplicar</button></div></div>
<div class="input-row"><label>Tilt (grados)</label><div><input id="inTilt" type="number" step="0.5"><button onclick="sendParam('tilt','inTilt')">Aplicar</button></div></div>
<div class="input-row"><label>Ciclo (ms)</label><div><input id="inDelay" type="number"><button onclick="sendParam('delay','inDelay')">Aplicar</button></div></div>
<h2 style="margin-top:14px">Archivos de log</h2>
<table><thead><tr><th>Fecha</th><th>Tamaño</th><th></th></tr></thead><tbody id="logsTableBody"><tr><td colspan="3" class="muted" style="text-align:center">Cargando…</td></tr></tbody></table>
</section>
<section class="card">
<h2>Firmware / OTA <button class="mini btn-secondary" onclick="loadOta()">↻ refrescar</button></h2>
<div class="input-row"><label>Versión en el dispositivo</label><span id="otaDevice" class="muted">--</span></div>
<div class="input-row"><label>Versión disponible</label><span id="otaAvail" class="muted">sin imagen</span></div>
<div id="otaMeta" class="muted" style="margin-bottom:10px"></div>
<div id="otaStatus" class="muted" style="margin-bottom:10px"></div>
<div class="input-row"><label>Nueva firmware</label><div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap"><input type="file" id="otaFile" accept=".bin,application/octet-stream"><input id="otaVersion" type="text" placeholder="vX.Y.Z" style="width:96px"></div></div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:10px">
<button class="btn-warning" onclick="uploadOta()">Subir firmware</button>
<button id="otaPush" class="btn-danger" onclick="pushOta()" disabled>Empujar a la ESP32</button>
<label style="display:flex;align-items:center;gap:5px;font-size:12px;color:#94a3b8;cursor:pointer"><input type="checkbox" id="otaAuto"> auto-actualizar al conectar</label>
</div>
<button class="btn-secondary mini" onclick="saveOtaAuto()">Guardar auto-update</button>
</section>
</main>
<script>
"use strict";
var ws = null, WIN = 900, WIN_MAX = 3600;
var samples = [], cfg = null, charts = {}, chartsOK = false;
function $(id){ return document.getElementById(id); }
function fmtTime(ts){ if(ts==null) return ''; var d=new Date(ts*1000); function p(n){return (''+n).padStart(2,'0');} return p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds()); }
function lastTs(){ return samples.length ? samples[samples.length-1].ts : null; }
function trimSamples(){ var t = lastTs(); if(t==null) return; var cut = t - WIN_MAX; while(samples.length && samples[0].ts < cut) samples.shift(); }
function axisTime(){ return { type:'linear', min:0, max:1, ticks:{color:'#94a3b8', maxTicksLimit:6, callback:function(v){ return fmtTime(v); }}, grid:{color:'#1e293b'} }; }
function buildCharts(){
if(!window.Chart){ chartsOK=false; if($('cdnWarn')) $('cdnWarn').style.display='block'; return; }
chartsOK = true;
function baseScales(yTitle, y1Title){
var s = { x: axisTime(), y:{ title:{display:!!yTitle,color:'#94a3b8',text:yTitle}, ticks:{color:'#94a3b8'}, grid:{color:'#1e293b'} } };
if(y1Title){ s.y1 = { position:'right', title:{display:true,color:'#22d3ee',text:y1Title}, ticks:{color:'#22d3ee'}, grid:{drawOnChartArea:false} }; }
return s;
}
function line(cvId, datasets, yT, y1T){
return new Chart($(cvId), { type:'line', data:{ datasets:datasets },
options:{ responsive:true, maintainAspectRatio:false, animation:false,
interaction:{mode:'nearest',intersect:false},
scales: baseScales(yT, y1T),
plugins:{ legend:{ labels:{color:'#e2e8f0', boxWidth:12} } } } });
}
charts.gpoa = line('ch_gpoa', [ { label:'G_POA', data:[], borderColor:'#f59e0b', backgroundColor:'rgba(245,158,11,0.15)', fill:true, borderWidth:1.6, pointRadius:0, tension:0.25 } ], 'W/m²');
charts.power = line('ch_power', [ { label:'P_CPV', data:[], borderColor:'#ef4444', backgroundColor:'rgba(239,68,68,0.15)', fill:true, borderWidth:1.6, pointRadius:0, tension:0.25 } ], 'mW');
charts.pos = line('ch_pos', [ { label:'Azimut', data:[], borderColor:'#38bdf8', borderWidth:1.6, pointRadius:0, tension:0.25 }, { label:'Elevación', data:[], borderColor:'#4ade80', borderWidth:1.6, pointRadius:0, tension:0.25 } ], 'grados');
charts.pv = line('ch_pv', [ { label:'Voc (V)', data:[], borderColor:'#a78bfa', borderWidth:1.4, pointRadius:0, yAxisID:'y' }, { label:'Isc (mA)', data:[], borderColor:'#22d3ee', borderWidth:1.4, pointRadius:0, yAxisID:'y1' } ], 'V', 'mA');
charts.traj = new Chart($('ch_traj'), { type:'scatter', data:{ datasets:[ { label:'X–Y', data:[], backgroundColor:'#38bdf8', pointRadius:1.5 } ] },
options:{ responsive:true, maintainAspectRatio:false, animation:false,
scales:{ x:{ title:{display:true,color:'#94a3b8',text:'X (pasos)'}, min:0, max:106000, ticks:{color:'#94a3b8'}, grid:{color:'#1e293b'} },
y:{ title:{display:true,color:'#94a3b8',text:'Y (pasos)'}, min:0, max:104000, ticks:{color:'#94a3b8'}, grid:{color:'#1e293b'} } },
plugins:{ legend:{display:false} } } });
}
function setT(id, v){ var e=$(id); if(e && v!=null) e.textContent = v; }
function renderAll(){
var t = lastTs();
var s = (t!=null) ? samples[samples.length-1] : null;
if(chartsOK){
if(charts.gpoa) charts.gpoa.data.datasets[0].data = samples.map(function(x){return {x:x.ts,y:x.gpoa};});
if(charts.power) charts.power.data.datasets[0].data = samples.map(function(x){return {x:x.ts,y:x.pcpv_mw};});
if(charts.pos){ charts.pos.data.datasets[0].data = samples.map(function(x){return {x:x.ts,y:x.az};}); charts.pos.data.datasets[1].data = samples.map(function(x){return {x:x.ts,y:x.el};}); }
if(charts.pv){ charts.pv.data.datasets[0].data = samples.map(function(x){return {x:x.ts,y:x.voc};}); charts.pv.data.datasets[1].data = samples.map(function(x){return {x:x.ts,y:x.isc_ma};}); }
if(charts.traj) charts.traj.data.datasets[0].data = samples.map(function(x){return {x:x.x,y:x.y};});
if(t!=null){ ['gpoa','power','pos','pv'].forEach(function(id){ if(charts[id]){ charts[id].options.scales.x.min = t-WIN; charts[id].options.scales.x.max = t; } }); }
for(var k in charts){ charts[k].update('none'); }
}
if(s){ setT('t_gpoa',s.gpoa); setT('t_power',s.pcpv_mw); setT('t_voc',s.voc); setT('t_isc',s.isc_ma); setT('t_az',s.az); setT('t_el',s.el); if($('posX')) $('posX').textContent=s.x; if($('posY')) $('posY').textContent=s.y; }
}
function pushSample(s){ samples.push(s); trimSamples(); if(lastTs()!==null){ $('lastUpdate').textContent = 'última: '+fmtTime(lastTs()); } renderAll(); }
function seedHistory(arr){ samples = (arr||[]).slice(); trimSamples(); renderAll(); }
function setStatus(on){ var b=$('trackerStatus'); if(!b) return; b.className='badge '+(on?'badge-online':'badge-offline'); b.textContent = on?'ESP32 ONLINE':'ESP32 OFFLINE'; }
function appendLog(text){
var box=$('log'); if(!box) return;
if(box.firstChild && box.firstChild.style && box.firstChild.style.color==='rgb(100, 116, 139)') box.innerHTML='';
var d=document.createElement('div'); d.textContent = text.replace(/\n$/,''); box.appendChild(d);
while(box.children.length>400) box.removeChild(box.firstChild);
box.scrollTop = box.scrollHeight;
}
function onMsg(msg){
if(msg.indexOf('STATUS:')===0){ setStatus(msg.split(':')[1]==='ONLINE'); }
else if(msg.indexOf('CONFIG:')===0){ try{ cfg=JSON.parse(msg.slice(7)); if($('calPyrano')) $('calPyrano').value=cfg.pyrano; if($('calNote')) $('calNote').textContent='mV/W/m² = '+cfg.pyrano+' · az-dev = '+cfg.azdev; }catch(e){} }
else if(msg.indexOf('HISTORY:')===0){ try{ seedHistory(JSON.parse(msg.slice(8))); }catch(e){} }
else if(msg.indexOf('DATA:')===0){ try{ pushSample(JSON.parse(msg.slice(5))); }catch(e){} }
else if(msg.indexOf('LOG:')===0){ appendLog(msg.slice(4)); }
else if(msg.indexOf('POS:')===0){ var p=msg.split(':'); if(p.length>=3){ if($('posX'))$('posX').textContent=p[1]; if($('posY'))$('posY').textContent=p[2]; } }
else if(msg.indexOf('FW:')===0){ ota.device = msg.slice(3); renderOta(); }
else if(msg.indexOf('OTA_STATE:')===0){ try{ var d=JSON.parse(msg.slice(10)); if(d) ota=d; renderOta(); }catch(e){} }
}
function connect(){
var proto = location.protocol==='https:' ? 'wss' : 'ws';
ws = new WebSocket(proto+'://'+location.host+'/ws/client');
ws.onmessage = function(ev){ try{ onMsg(ev.data); }catch(e){ console.error(e); } };
ws.onclose = function(){ setStatus(false); if($('lastUpdate')) $('lastUpdate').textContent='reconectando…'; setTimeout(connect, 3000); };
ws.onerror = function(){ try{ ws.close(); }catch(e){} };
}
function setWindow(sec, btn){ WIN=sec; var bs=document.querySelectorAll('.cb-group button[data-win]'); bs.forEach(function(b){ b.classList.toggle('btn-active', b===btn); }); renderAll(); }
function sendRaw(cmd){ if(ws && ws.readyState===1){ ws.send(cmd); } }
function sendCoords(){ var x=$('inPosX').value, y=$('inPosY').value; if(x===''||y===''){ alert('Ingresa X y Y'); return; } sendRaw('SET_COORDS:'+x+':'+y); }
function sendParam(p, id){ var v=$(id).value; if(v==='') return; sendRaw('SET_PARAM:'+p+'='+v); }
function applyCalib(){ var f=parseFloat($('calPyrano').value); if(!(f>0)){ alert('Factor inválido'); return; } $('calNote').textContent='aplicando…'; fetch('/api/calib',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mV_per_WM2:f})}).then(function(r){return r.json();}).then(function(d){ $('calNote').textContent='mV/W/m² = '+d.mV_per_WM2; }).catch(function(e){ $('calNote').textContent='error: '+e; }); }
function loadLogs(){ fetch('/api/logs').then(function(r){return r.json();}).then(function(rows){ var tb=$('logsTableBody'); if(!tb) return; if(!rows.length){ tb.innerHTML='<tr><td colspan="3" class="muted" style="text-align:center">Sin archivos de log</td></tr>'; return; } tb.innerHTML = rows.map(function(r){ return '<tr><td><strong>'+r.date+'</strong></td><td>'+r.size_kb+' KB</td><td><a href="/api/logs/'+r.filename+'" download><button class="mini">Descargar</button></a></td></tr>'; }).join(''); }).catch(function(e){ console.error(e); }); }
// ---- OTA / firmware update -------------------------------------------------
var ota = { device:null, available:null, auto:false, url:null, connected:false };
function renderOta(){
var devEl=$('otaDevice'), avEl=$('otaAvail'), push=$('otaPush'), meta=$('otaMeta'), autoCb=$('otaAuto');
if(!devEl) return;
devEl.textContent = ota.device || 'sin informar';
if(ota.available && ota.available.version){
var upToDate = ota.device && ota.device === ota.available.version;
avEl.textContent = ota.available.version + (upToDate ? ' (al día ✓)' : ' → dispositivo: ' + (ota.device||'--'));
avEl.style.color = upToDate ? '#4ade80' : '#fbbf24';
if(meta){ var m=ota.available;
meta.textContent = 'Tamaño: ' + (m.size!=null ? m.size+' B' : '?')
+ (m.sha256 ? ' · sha256 '+m.sha256.substring(0,12) : '')
+ (m.uploaded_at ? ' · subido '+m.uploaded_at : ''); }
} else { avEl.textContent = 'sin imagen subida'; avEl.style.color = '#64748b'; if(meta) meta.textContent=''; }
if(push) push.disabled = !(ota.connected && ota.available && ota.available.version && ota.device && ota.device !== ota.available.version);
if(autoCb) autoCb.checked = !!ota.auto;
}
function loadOta(){
fetch('/api/status').then(function(r){ return r.json(); }).then(function(s){
if(s && s.ota) ota = s.ota;
renderOta();
}).catch(function(){});
}
function uploadOta(){
var fileEl=$('otaFile'), verEl=$('otaVersion'), st=$('otaStatus');
if(!fileEl || !fileEl.files || !fileEl.files.length){ if(st) st.textContent='Selecciona un archivo .bin primero.'; return; }
if(st) st.textContent='Subiendo firmware…';
var fd=new FormData(); fd.append('file', fileEl.files[0]);
if(verEl && verEl.value.trim()) fd.append('version', verEl.value.trim());
fetch('/ota/upload',{method:'POST',body:fd}).then(function(r){ return r.json().then(function(j){ return {ok:r.ok, j:j}; }); })
.then(function(res){
if(!res.ok){ if(st) st.textContent='Error: '+ (res.j.detail||JSON.stringify(res.j)); return; }
ota.available=res.j.manifest; if(res.j.manifest) ota.url=res.j.manifest.url;
if(st) st.textContent='Subido: '+(res.j.manifest.version||'')+' ('+(res.j.manifest.size||0)+' B). '+(ota.connected?'Pulsa «Empujar a la ESP32».' :'La ESP32 no está conectada.');
fileEl.value=''; renderOta();
}).catch(function(e){ if(st) st.textContent='Error de red: '+e; });
}
function pushOta(){
var st=$('otaStatus');
if(!ota.connected){ if(st) st.textContent='La ESP32 no está conectada.'; return; }
if(!confirm('¿Empujar el firmware a la ESP32? El dispositivo descargará la imagen y se reiniciará.')) return;
if(st) st.textContent='Comando OTA enviado a la ESP32…';
fetch('/ota/push',{method:'POST'}).then(function(r){ return r.json().then(function(j){ return {ok:r.ok, j:j}; }); })
.then(function(res){
if(!res.ok){ if(st) st.textContent='No se pudo: '+(res.j.detail||JSON.stringify(res.j)); return; }
if(st) st.textContent='OTA en curso: la ESP32 descarga la imagen y se reiniciará en unos segundos.';
setTimeout(loadOta, 4000);
}).catch(function(e){ if(st) st.textContent='Error de red: '+e; });
}
function saveOtaAuto(){
var st=$('otaStatus'), val = $('otaAuto') ? $('otaAuto').checked : false;
fetch('/ota/auto',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:val})})
.then(function(r){ return r.json(); }).then(function(j){
ota.auto=!!j.enabled; renderOta();
if(st) st.textContent='Auto-update '+(j.enabled?'ACTIVADO: se actualizará solo al (re)conectar.':'desactivado.');
}).catch(function(e){ if(st) st.textContent='Error de red: '+e; });
}
window.addEventListener('load', function(){ buildCharts(); setStatus(false); connect(); loadLogs(); loadOta(); });
</script>
</body>
</html>
"""
# ==================== DASHBOARD ROUTE ====================
@app.get("/", response_class=HTMLResponse)
async def get_dashboard():
return DASHBOARD_HTML