Files

422 lines
17 KiB
Plaintext

import os
import glob
from datetime import datetime
from typing import List, Optional
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.middleware.cors import CORSMiddleware
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(__file__), "data_logs")
os.makedirs(LOGS_DIR, exist_ok=True)
class HubManager:
def __init__(self):
self.esp32_socket: Optional[WebSocket] = None
self.client_sockets: List[WebSocket] = []
self.last_pos_x: int = 0
self.last_pos_y: int = 0
self.esp32_connected: bool = False
self.last_seen: Optional[datetime] = None
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().strftime('%Y-%m-%d %H:%M:%S')}] ESP32 Conectado con exito.")
await self.broadcast_status_to_clients()
def disconnect_esp32(self):
self.esp32_socket = None
self.esp32_connected = False
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] ESP32 Desconectado.")
async def connect_client(self, websocket: WebSocket):
await websocket.accept()
self.client_sockets.append(websocket)
# Enviar estado actual y última posición al cliente recién conectado
status_msg = f"STATUS:{'ONLINE' if self.esp32_connected else 'OFFLINE'}"
await websocket.send_text(status_msg)
pos_msg = f"POS:{self.last_pos_x}:{self.last_pos_y}"
await websocket.send_text(pos_msg)
# Enviar últimas 50 líneas del archivo de hoy
today_filename = f"log_{datetime.now().strftime('%Y-%m-%d')}.txt"
today_path = os.path.join(LOGS_DIR, today_filename)
if os.path.exists(today_path):
try:
with open(today_path, "r", encoding="utf-8", errors="ignore") as f:
lines = f.readlines()
tail = "".join(lines[-50:])
if tail:
await websocket.send_text(f"LOG:{tail}")
except Exception as e:
print(f"Error leyendo archivo de hoy: {e}")
def disconnect_client(self, websocket: WebSocket):
if websocket in self.client_sockets:
self.client_sockets.remove(websocket)
async def handle_esp32_message(self, message: str):
self.last_seen = datetime.now()
today_filename = f"log_{self.last_seen.strftime('%Y-%m-%d')}.txt"
today_path = os.path.join(LOGS_DIR, today_filename)
# 1. Registrar en el archivo diario en disco
with open(today_path, "a", encoding="utf-8") as f:
f.write(message.strip() + "\n")
# 2. Si es actualización de posición POS:X:Y
if message.startswith("POS:"):
parts = message.split(":")
if len(parts) >= 3:
try:
self.last_pos_x = int(parts[1])
self.last_pos_y = int(parts[2])
except ValueError:
pass
# 3. Retransmitir en tiempo real a todos los clientes web
for client in list(self.client_sockets):
try:
await client.send_text(message)
except Exception:
self.disconnect_client(client)
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(f"Error enviando comando a ESP32: {e}")
self.disconnect_esp32()
return False
async def broadcast_status_to_clients(self):
status_msg = f"STATUS:{'ONLINE' if self.esp32_connected else 'OFFLINE'}"
for client in list(self.client_sockets):
try:
await client.send_text(status_msg)
except Exception:
self.disconnect_client(client)
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_to_clients()
except Exception as e:
print(f"Excepcion en conexion ESP32: {e}")
hub.disconnect_esp32()
await hub.broadcast_status_to_clients()
@app.websocket("/ws/client")
async def client_ws_endpoint(websocket: WebSocket):
await hub.connect_client(websocket)
try:
while True:
cmd = await websocket.receive_text()
# Reenviar comando al ESP32
success = await hub.send_command_to_esp32(cmd)
if not success:
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,
"active_clients": len(hub.client_sockets)
}
@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):
# Seguridad básica contra traversal attacks
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")
# ==================== DASHBOARD WEB ====================
DASHBOARD_HTML = r"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cloud Hub - ESP32 Solar Tracker</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 20px; background: #0f172a; color: #f8fafc; }
header { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #334155; padding-bottom: 15px; margin-bottom: 20px; }
h1 { margin: 0; font-size: 22px; color: #38bdf8; display: flex; align-items: center; gap: 10px; }
.badge { padding: 4px 10px; border-radius: 9999px; font-size: 13px; font-weight: bold; }
.badge-online { background: #166534; color: #4ade80; border: 1px solid #22c55e; }
.badge-offline { background: #991b1b; color: #f87171; border: 1px solid #ef4444; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 18px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); }
.card h2 { font-size: 17px; margin-top: 0; margin-bottom: 12px; color: #94a3b8; border-bottom: 1px solid #334155; padding-bottom: 6px; }
#log { height: 280px; overflow-y: auto; background: #020617; color: #22c55e; font-family: monospace; font-size: 12px; padding: 10px; border-radius: 6px; border: 1px solid #334155; }
button { background: #0284c7; color: white; border: none; padding: 7px 12px; border-radius: 4px; font-weight: bold; cursor: pointer; transition: 0.15s; margin: 2px 1px; }
button:hover { background: #0369a1; }
.btn-danger { background: #dc2626; } .btn-danger:hover { background: #b91c1c; }
.btn-warning { background: #d97706; } .btn-warning:hover { background: #b45309; }
.btn-secondary { background: #475569; } .btn-secondary:hover { background: #334155; }
.input-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; font-size: 13px; }
input[type="number"], input[type="text"] { background: #0f172a; border: 1px solid #475569; color: #f8fafc; padding: 5px 8px; border-radius: 4px; width: 90px; }
canvas { background: #020617; border-radius: 6px; border: 1px solid #334155; }
table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 10px; }
th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid #334155; }
th { color: #94a3b8; }
</style>
</head>
<body>
<header>
<h1>☀️ ESP32 Solar Tracker Cloud Hub</h1>
<div>
<span id="trackerStatus" class="badge badge-offline">ESP32 OFFLINE</span>
</div>
</header>
<div class="grid">
<!-- Columna Izquierda: Log en vivo y Archivos diarios -->
<div style="display:flex; flex-direction:column; gap:20px;">
<div class="card">
<h2>Registro en Tiempo Real (Guardado Automático en Disco)</h2>
<div id="log"></div>
</div>
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:center;">
<h2 style="border:none; margin:0; padding:0;">Archivos Diarios de Log</h2>
<button onclick="loadDailyLogs()" class="btn-secondary" style="font-size:12px;">Actualizar Lista</button>
</div>
<table>
<thead>
<tr><th>Fecha</th><th>Tamaño</th><th>Acción</th></tr>
</thead>
<tbody id="logsTableBody">
<tr><td colspan="3" style="text-align:center; color:#64748b;">Cargando archivos...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Columna Derecha: Posición y Controles -->
<div style="display:flex; flex-direction:column; gap:20px;">
<div class="card">
<h2>Posición de Motores</h2>
<div style="display:flex; justify-content:space-around; margin-bottom:10px; font-size:15px;">
<span><strong>X:</strong> <span id="posXDisplay" style="color:#38bdf8;">0</span> pasos</span>
<span><strong>Y:</strong> <span id="posYDisplay" style="color:#38bdf8;">0</span> pasos</span>
</div>
<div style="display:flex; justify-content:center;">
<canvas id="positionPlot" width="340" height="340"></canvas>
</div>
</div>
<div class="card">
<h2>Comandos Remotos</h2>
<div style="margin-bottom:12px;">
<button onclick="sendRaw('CMD:2')">Track Sun (Tiempo)</button>
<button onclick="sendRaw('CMD:3')">Fine-Tune (nomegusta)</button>
<button onclick="sendRaw('CMD:4')" class="btn-danger">Homing XY (StallGuard)</button>
<button onclick="sendRaw('CMD:5')">Mover al Centro</button>
<button onclick="sendRaw('CMD:10')" class="btn-secondary">Config TMC</button>
</div>
<h2>Ajustes y Calibración</h2>
<div class="input-row">
<label>Mover Coordenadas (X, Y):</label>
<div>
<input type="number" id="inPosX" placeholder="X" style="width:70px;">
<input type="number" id="inPosY" placeholder="Y" style="width:70px;">
<button onclick="sendCoords()">Enviar</button>
</div>
</div>
<div class="input-row">
<label>Error Max:</label>
<div>
<input type="number" id="inErrMax" value="1500">
<button onclick="sendParam('errMax', document.getElementById('inErrMax').value)">Set</button>
</div>
</div>
<div class="input-row">
<label>Delay (s):</label>
<div>
<input type="number" id="inDelay" value="2">
<button onclick="sendParam('delay', document.getElementById('inDelay').value)">Set</button>
</div>
</div>
<div class="input-row">
<label>Azimuth Deviation (°):</label>
<div>
<input type="number" id="inAz" step="0.1" value="44.23">
<button onclick="sendParam('az', document.getElementById('inAz').value)">Set</button>
</div>
</div>
<div class="input-row">
<label>Tilt Angle (°):</label>
<div>
<input type="number" id="inTilt" step="0.1" value="23.23">
<button onclick="sendParam('tilt', document.getElementById('inTilt').value)">Set</button>
</div>
</div>
</div>
</div>
</div>
<script>
var ws;
function initWS() {
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(proto + '//' + location.host + '/ws/client');
ws.onmessage = function(event) {
var msg = event.data;
if (msg.startsWith("STATUS:")) {
var isOnline = msg.substring(7) === "ONLINE";
var el = document.getElementById("trackerStatus");
el.className = isOnline ? "badge badge-online" : "badge badge-offline";
el.innerText = isOnline ? "ESP32 ONLINE" : "ESP32 OFFLINE";
} else if (msg.startsWith("LOG:")) {
var logBox = document.getElementById("log");
logBox.innerHTML += msg.substring(4) + "<br>";
logBox.scrollTop = logBox.scrollHeight;
} else if (msg.startsWith("POS:")) {
var parts = msg.split(":");
if (parts.length >= 3) {
var x = parts[1];
var y = parts[2];
document.getElementById("posXDisplay").innerText = x;
document.getElementById("posYDisplay").innerText = y;
updatePlot(x, y);
}
}
};
ws.onclose = function() {
var el = document.getElementById("trackerStatus");
el.className = "badge badge-offline";
el.innerText = "DESCONECTADO DEL SERVIDOR";
setTimeout(initWS, 3000);
};
}
function updatePlot(x, y) {
var canvas = document.getElementById("positionPlot");
var ctx = canvas.getContext("2d");
var w = canvas.width, h = canvas.height, m = 35, maxX = 106000, maxY = 104000;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = "#334155"; ctx.lineWidth = 1;
for (var i = 0; i <= 10; i++) {
var xPos = m + (i * (w - 2 * m) / 10);
ctx.beginPath(); ctx.moveTo(xPos, m); ctx.lineTo(xPos, h - m); ctx.stroke();
var yPos = m + (i * (h - 2 * m) / 10);
ctx.beginPath(); ctx.moveTo(m, yPos); ctx.lineTo(w - m, yPos); ctx.stroke();
}
ctx.strokeStyle = "#64748b"; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(m, m); ctx.lineTo(m, h - m); ctx.lineTo(w - m, h - m); ctx.stroke();
var plotX = m + (Math.abs(x) / maxX) * (w - 2 * m);
var plotY = h - m - (Math.abs(y) / maxY) * (h - 2 * m);
ctx.fillStyle = "#ef4444"; ctx.beginPath(); ctx.arc(plotX, plotY, 6, 0, 2 * Math.PI); ctx.fill();
}
function sendRaw(cmd) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(cmd);
} else {
alert("Sin conexion al servidor WebSocket");
}
}
function sendCoords() {
var x = document.getElementById("inPosX").value;
var y = document.getElementById("inPosY").value;
if (x === "" || y === "") { alert("Ingresa X e Y"); return; }
sendRaw("SET_COORDS:" + x + ":" + y);
}
function sendParam(param, val) {
sendRaw("SET_PARAM:" + param + "=" + val);
}
function loadDailyLogs() {
fetch('/api/logs').then(r => r.json()).then(data => {
var tbody = document.getElementById("logsTableBody");
if (data.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center; color:#64748b;">No hay archivos de log aun</td></tr>';
return;
}
var html = '';
data.forEach(item => {
html += '<tr>' +
'<td><strong>' + item.date + '</strong></td>' +
'<td>' + item.size_kb + ' KB</td>' +
'<td><a href="/api/logs/' + item.filename + '" download><button style="font-size:11px; padding:3px 8px;">Descargar</button></a></td>' +
'</tr>';
});
tbody.innerHTML = html;
}).catch(console.error);
}
window.onload = function() {
initWS();
updatePlot(0, 0);
loadDailyLogs();
};
</script>
</body>
</html>
"""
@app.get("/", response_class=HTMLResponse)
async def get_dashboard():
return DASHBOARD_HTML