422 lines
17 KiB
Plaintext
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
|