833 lines
44 KiB
Python
833 lines
44 KiB
Python
#!/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 |