Initial import of Microtracking project

This commit is contained in:
Fernando Dávalos Hernández committed 2026-11-04 19:43:33 -06:00
commit 60720c388c
2265 files changed
+1464696

No files matched your search

@@ -0,0 +1,75 @@
#pragma once
#include <cstdint>
#include <type_traits>
#ifndef PCB_ID
#error "Define PCB_ID in platformio.ini, for example -DPCB_ID=0x01"
#endif
static_assert(std::is_integral<decltype(PCB_ID)>::value, "PCB_ID must be an integer");
static_assert(PCB_ID > 0 && PCB_ID <= 0xffffffffffffULL, "PCB_ID must be between 0x01 and 0xffffffffffff");
constexpr char cloudIdHexDigit(unsigned shift) {
return "0123456789abcdef"[(static_cast<std::uint64_t>(PCB_ID) >> shift) & 0x0f];
}
// Fixed 12-digit representation accepted by the existing hub registry.
constexpr char CLOUD_DEVICE_ID[] = {
'e', 's', 'p', '3', '2', '-',
cloudIdHexDigit(44), cloudIdHexDigit(40), cloudIdHexDigit(36),
cloudIdHexDigit(32), cloudIdHexDigit(28), cloudIdHexDigit(24),
cloudIdHexDigit(20), cloudIdHexDigit(16), cloudIdHexDigit(12),
cloudIdHexDigit(8), cloudIdHexDigit(4), cloudIdHexDigit(0), '\0'
};
#if !defined(CLOUD_DEVICE_TOKEN) && __has_include("cloud_credentials.h")
#include "cloud_credentials.h"
#endif
#ifndef CLOUD_DEVICE_TOKEN
#error "Define CLOUD_DEVICE_TOKEN in include/cloud_credentials.h; see cloud_credentials.example.h"
#else
constexpr bool cloudTokenIsHex(const char* token, unsigned index = 0) {
return index == 64 ? token[index] == '\0' :
(((token[index] >= '0' && token[index] <= '9') ||
(token[index] >= 'a' && token[index] <= 'f')) && cloudTokenIsHex(token, index + 1));
}
static_assert(sizeof(CLOUD_DEVICE_TOKEN) == 65 && cloudTokenIsHex(CLOUD_DEVICE_TOKEN),
"CLOUD_DEVICE_TOKEN must be the 64 lowercase hex characters issued for this PCB_ID");
#endif
// ISRG Root X1: https://letsencrypt.org/certs/isrgrootx1.pem (expires 2035).
// Both WSS and OTA verify the hostname/certificate; a valid NTP clock is needed.
constexpr char CLOUD_ROOT_CA_PEM[] = R"PEM(-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----
)PEM";
@@ -0,0 +1,5 @@
#pragma once
// Copy to cloud_credentials.h and use the 64-character token issued by the hub.
// Never commit the provisioned token.
#define CLOUD_DEVICE_TOKEN "REPLACE_WITH_64_LOWERCASE_HEX_CHARACTERS_FROM_THE_HUB"
+133
View File
@@ -0,0 +1,133 @@
#pragma once
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
// Guard RAII: toma el mutex al entrar al alcance y lo libera al salir.
// Garantiza emparejamiento lock/unlock incluso con return/early-exit.
// No copiable: un handle tomado debe liberarse exactamente una vez.
struct ScopedMutexGuard {
SemaphoreHandle_t _m = nullptr;
ScopedMutexGuard(SemaphoreHandle_t m) : _m(m) {
if (_m && xSemaphoreTake(_m, portMAX_DELAY) != pdTRUE) {
Serial.println("[FATAL] xSemaphoreTake fallo (mutex destruido?)");
_m = nullptr; // no liberar lo que no se tomo
}
}
~ScopedMutexGuard() {
if (_m && xSemaphoreGive(_m) != pdTRUE) {
Serial.println("[FATAL] xSemaphoreGive fallo (mutex no tomado?)");
}
}
ScopedMutexGuard(const ScopedMutexGuard&) = delete;
ScopedMutexGuard& operator=(const ScopedMutexGuard&) = delete;
// Move: transfiere la posesion (evita doble liberacion).
ScopedMutexGuard(ScopedMutexGuard&& other) : _m(other._m) { other._m = nullptr; }
};
// Maximo de bytes de log copiados fuera de PSRAM hacia el heap interno
// (String no usa PSRAM; el heap interno es ~320KB).
constexpr size_t LOG_SEND_CHUNK = 60 * 1024;
// ==============================================================================
// CONFIGURACIÓN GENERAL Y HARDWARE (Waveshare ESP32-S3-Nano)
// ==============================================================================
#define VERBO 0
// --- Version de PCB: seleccionada mediante build_flags ---
#ifndef PCB_VERSION
#error "Define PCB_VERSION=1 o PCB_VERSION=2 en platformio.ini"
#endif
// --- Pines Motores Paso a Paso (GPIO nativos ESP32-S3) ---
#if PCB_VERSION == 1
constexpr int MOTOR_X_STEP_PIN = 48;
constexpr int MOTOR_X_DIRECTION_PIN = 47;
#elif PCB_VERSION == 2
constexpr int MOTOR_X_STEP_PIN = 8;
constexpr int MOTOR_X_DIRECTION_PIN = 7;
#else
#error "PCB_VERSION no soportada: utiliza 1 o 2"
#endif
constexpr int MOTOR_Y_STEP_PIN = 6;
constexpr int MOTOR_Y_DIRECTION_PIN = 5;
constexpr int PIN_MOTOR_EN = 21;
constexpr int PIN_VOC = 18;
// --- Pines I2C (ADS122C04) ---
constexpr int SDA_PIN = 11;
constexpr int SCL_PIN = 12;
// --- Parámetros de UART para TMC2209 ---
constexpr long TMC_SERIAL_BAUD_RATE = 250000;
constexpr uint8_t TMC_REPLY_DELAY = 15;
// Configuración Driver X (Motor 0)
constexpr uint8_t RUN_CURRENT_PERCENT0 = 29;
constexpr int32_t VELOCITY0 = 4200;
constexpr uint8_t STALL_GUARD_THRESHOLD0 = 200;
// Configuración Driver Y (Motor 1)
constexpr uint8_t RUN_CURRENT_PERCENT1 = 29;
constexpr int32_t VELOCITY1 = 4200;
constexpr uint8_t STALL_GUARD_THRESHOLD1 = 200;
// Límites de carrera mecánicos
constexpr int32_t LIMITX = -120000;
constexpr int32_t LIMITY = -116000;
constexpr int32_t HIST_VAL = 100;
// Velocidades de operación normal
constexpr float MOTOR_MAX_SPEED = 6200.0f;
constexpr float MOTOR_ACCELERATION = 9000.0f;
// --- Red WiFi y NTP (WPA2-Enterprise / PEAP) ---
extern const char* WIFI_SSID;
extern const char* WIFI_EAP_IDENTITY;
extern const char* WIFI_EAP_USERNAME;
extern const char* WIFI_EAP_PASSWORD;
constexpr const char* NTP_SERVER = "pool.ntp.org";
constexpr long GMT_OFFSET_SEC = -6 * 3600; // UTC-6 (Aguascalientes)
constexpr int DAYLIGHT_OFFSET_SEC = 0;
// --- Coordenadas Geográficas (Aguascalientes) ---
constexpr float SITE_LATITUDE = 21.88f; // °N
constexpr float SITE_LONGITUDE = -102.29f; // °E (negativo = Oeste)
// --- Capacidad de Log en PSRAM ---
constexpr size_t PSRAM_LOG_CAPACITY = 2 * 1024 * 1024; // 2 MB
// --- NVS (Persistencia de coordenadas) ---
constexpr int32_t NVS_THRESHOLD = 1000;
constexpr unsigned long NVS_CHECK_PERIOD_MS = 500;
// --- Servidor Cloud / Docker Hub (WebSocket Inverso) ---
constexpr bool CLOUD_SYNC_ENABLED = true;
extern const char* CLOUD_SERVER_HOST; // Definido en main.cpp
constexpr uint16_t CLOUD_SERVER_PORT = 443;
constexpr const char* CLOUD_SERVER_PATH = "/ws/esp32";
// --- F5: Re-alineación astronómica periódica --------------------------------
constexpr uint32_t F5_DRIFT_PERIOD_MS = 300000UL; // 5 min entre chequeos de deriva
constexpr int32_t F5_DRIFT_THRESHOLD = 2000; // Pasos máx. de deriva antes de re-alinear
// --- OTA / version de firmware ---------------------------------------------
// Se reporta al hub al (re)conectar como linea "VER:<version>". El hub la
// compara con la imagen subida; cuando hay diferencia (o auto-update ON) manda
// "OTA:<url>" por el canal WSS para que el dispositivo descargue + flashee +
// se reinicie con la nueva firmware. Sube esta version antes de cada build.
#define FIRMWARE_VERSION "1.1.5-ws"
// 1.1.4: corrige dC, rampa cerca del pico y escape de la zona muerta.
// 1.1.3: corrige el terminador duplicado del encabezado de autenticacion WebSocket.
// 1.1.2: PCB_ID configurable, identidad/token por tarjeta y TLS validado para cloud/OTA.
// la 1.0.1 cambie valores de control del tuning engine para mejorar la estabilidad y evitar sobrepasos.
// la 1.0.2 agrega soporte OTA y comandos remotos desde el hub (Fine-Tune, Set Coords, etc.)
// la 1.0.3 corrige el orden de los ejes en la visualización del canvas y en el log de posición enviada al hub.
// la 1.1.0: F1 reset CmaxN, F2 auto-arranque, F3 anti-oscilación, F4 filtro picos, F5 re-alineación, F6 telemetría
// la 1.1.1: mutex en buffer de log y movimiento (evita corrupción por acceso concurrente),
// elimina objetos FlexyStepper/TMC2209 duplicados y aplica extensión de signo
// (24 bits) a los canales del ADC: V/P/C ya no se leen como ~2^24 de noche.
@@ -0,0 +1,207 @@
#pragma once
#include <Arduino.h>
static const char INDEX_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 Logger & Stepper Control</title>
<style>
body { font-family: Arial, sans-serif; margin: 15px; background: #f8f9fa; color: #333; }
h2, h3 { margin-top: 10px; margin-bottom: 8px; }
.container { display:flex; flex-wrap:wrap; gap:20px; margin-top:15px; }
.column { flex:1; min-width:320px; max-width:48%; background: #ffffff; padding: 15px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
@media (max-width:850px){ .column{ max-width:100%; } }
.param-group { margin-bottom:8px; display: flex; align-items: center; justify-content: space-between; }
.param-group label { flex: 1; }
.param-group input { width: 90px; padding: 4px; border: 1px solid #ccc; border-radius: 4px; }
.param-group button { margin-left: 6px; padding: 4px 10px; cursor: pointer; }
button { padding: 6px 12px; margin: 3px 2px; cursor: pointer; border-radius: 4px; border: 1px solid #007bff; background: #007bff; color: white; font-weight: bold; }
button:hover { background: #0056b3; }
.btn-danger { background: #dc3545; border-color: #dc3545; }
.btn-danger:hover { background: #bd2130; }
.btn-secondary { background: #6c757d; border-color: #6c757d; }
.btn-secondary:hover { background: #5a6268; }
#log { height:260px; overflow-y:auto; border:1px solid #ccc; background:#1e1e1e; color:#00ff66; padding:8px; font-family: monospace; font-size: 13px; border-radius: 4px; }
canvas { background: #ffffff; border-radius: 4px; }
.input-coord { width: 140px; padding: 5px; margin: 3px; border: 1px solid #ccc; border-radius: 4px; }
</style>
<script>
var ws = new WebSocket("ws://" + location.hostname + ":81/");
ws.onmessage = function(event){
var data = event.data;
if (data.startsWith("LOG:")) {
var log = document.getElementById("log");
log.innerHTML += data.substring(4) + "<br>";
log.scrollTop = log.scrollHeight;
} else if (data.startsWith("POS:")) {
var parts = data.split(":");
if (parts.length === 3) {
document.getElementById("posXDisplay").innerText = parts[1];
document.getElementById("posYDisplay").innerText = parts[2];
updatePlot(parts[1], parts[2]);
}
}
};
function updatePlot(x, y){
var canvas = document.getElementById("positionPlot");
var ctx = canvas.getContext("2d");
var w = canvas.width, h = canvas.height, m=40, maxX=106000, maxY=104000;
ctx.clearRect(0,0,w,h);
ctx.strokeStyle="#ccc"; 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();
ctx.fillStyle="#555"; ctx.font="11px Arial"; ctx.fillText(Math.round(i*maxX/10), xPos-12, h-m+15);
var yPos=m+(i*(h-2*m)/10);
ctx.beginPath(); ctx.moveTo(m,yPos); ctx.lineTo(w-m,yPos); ctx.stroke();
ctx.fillText(Math.round((10-i)*maxY/10), m-35, yPos+4);
}
ctx.strokeStyle="#000"; 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+(x/maxX)*(w-2*m);
var plotY=h-m-(y/maxY)*(h-2*m);
ctx.fillStyle="red"; ctx.beginPath(); ctx.arc(plotX,plotY,6,0,2*Math.PI); ctx.fill();
}
function sendCommand(s){ fetch('/command?status='+s).catch(console.error); }
function setCoords(){
var x=document.getElementById("posX").value;
var y=document.getElementById("posY").value;
fetch('/setCoords?x='+x+'&y='+y).catch(console.error);
}
function setCurrentPos(){
var x=document.getElementById("posX").value;
var y=document.getElementById("posY").value;
if(x===""||y===""){ alert('Fill X and Y'); return; }
fetch('/command?status=6&x='+x+'&y='+y).catch(console.error);
}
function setLPFBeta(){ var v=document.getElementById("lpfBeta").value; fetch('/setLPFBeta?beta='+v); }
function setErrMax(){ var v=document.getElementById("errMax").value; fetch('/setErrMax?err='+v); }
function setDelay(){ var v=document.getElementById("delayyy").value; fetch('/setDelay?delay='+v); }
function setMinErrMax(){ var v=document.getElementById("minErrMax").value; fetch('/setMinErrMax?minErr='+v); }
function setAbruptThreshold(){ var v=document.getElementById("abruptThreshold").value; fetch('/setAbruptThreshold?abrupt='+v); }
function setSmallThreshold(){ var v=document.getElementById("smallThreshold").value; fetch('/setSmallThreshold?small='+v); }
function setResetThreshold(){ var v=document.getElementById("resetThreshold").value; fetch('/setResetThreshold?reset='+v); }
function setPeakProximity(){ var v=document.getElementById("peakProximity").value; fetch('/setPeakProximity?peak='+v); }
function setCicleMax(){ var v=document.getElementById("cicleMax").value; fetch('/setCicleMax?cicle='+v); }
function setAxisFactor(axis){ var v=document.getElementById(axis+'Factor').value; if(!/^\d+$/.test(v)||+v<1||+v>8){alert('Factor must be an integer from 1 to 8');return;} fetch('/setAxis'+axis+'Factor?factor='+v); }
function setAzDeviation(){ var v=document.getElementById("azDeviation").value; fetch('/setAzDeviation?az='+v); }
function setTiltAngle(){ var v=document.getElementById("tiltAngle").value; fetch('/setTiltAngle?tilt='+v); }
function toggleAutoApply(){ var ck=document.getElementById("autoApply").checked ? 1 : 0; fetch('/setAutoApply?on='+ck); }
function recomputeAT(){ fetch('/recomputeAutoTune'); }
function applyAT(){ fetch('/applyAutoTune'); }
function updateNVSButton(enabled) {
var btn = document.getElementById("btnNVS");
btn.innerText = "NVS Autosave: " + (enabled ? "ACTIVADO" : "DESACTIVADO");
btn.style.background = enabled ? "#28a745" : "#6c757d";
}
function toggleNVS() {
fetch('/toggleNVS').then(r => r.json()).then(data => {
updateNVSButton(data.nvsEnabled);
}).catch(console.error);
}
window.onload=function(){
document.getElementById("lpfBeta").value="0.85";
document.getElementById("errMax").value="1500";
document.getElementById("delayyy").value="2";
document.getElementById("minErrMax").value="100";
document.getElementById("abruptThreshold").value="9000";
document.getElementById("smallThreshold").value="3000";
document.getElementById("resetThreshold").value="25000";
document.getElementById("peakProximity").value="0.015";
document.getElementById("cicleMax").value="3";
fetch('/getNVS').then(r => r.json()).then(data => {
updateNVSButton(data.nvsEnabled);
document.getElementById("cicleMax").value=data.cicleMax;
document.getElementById("XFactor").value=data.axisXFactor;
document.getElementById("YFactor").value=data.axisYFactor;
}).catch(() => updateNVSButton(true));
};
</script>
</head>
<body>
<h2>ESP32 Solar Tracker & Stepper Control</h2>
<div id="log"></div>
<div style="margin:10px 0;">
<button id="btnNVS" onclick="toggleNVS()" class="btn-secondary">NVS Autosave: (cargando...)</button>
</div>
<div class="container">
<div class="column">
<h2>Posiciones Actuales</h2>
<p><strong>X (Pasos):</strong> <span id="posXDisplay">0</span></p>
<p><strong>Y (Pasos):</strong> <span id="posYDisplay">0</span></p>
<h2>Visualización</h2>
<canvas id="positionPlot" width="380" height="380" style="border:1px solid #ccc;"></canvas>
</div>
<div class="column">
<h2>Control Manual y Coordenadas</h2>
<div>
<input type="number" id="posX" class="input-coord" placeholder="X (0 a 106000)" min="0" max="106000">
<input type="number" id="posY" class="input-coord" placeholder="Y (0 a 104000)" min="0" max="104000"><br>
<button onclick="setCoords()">Mover a Coordenadas</button>
<button onclick="setCurrentPos()" class="btn-secondary">Calibrar Pos Actual</button>
</div>
<div style="margin-top:12px;">
<h3>Parámetros de Fine-Tuning</h3>
<div class="param-group"><label>LPF Beta:</label> <input type="number" id="lpfBeta" step="0.01" min="0" max="1"> <button onclick="setLPFBeta()">Set</button></div>
<div class="param-group"><label>Error Max:</label> <input type="number" id="errMax" min="0"> <button onclick="setErrMax()">Set</button></div>
<div class="param-group"><label>Delay (s):</label> <input type="number" id="delayyy" min="0"> <button onclick="setDelay()">Set</button></div>
<div class="param-group"><label>Min Error Max:</label> <input type="number" id="minErrMax" min="0"> <button onclick="setMinErrMax()">Set</button></div>
<div class="param-group"><label>Abrupt Threshold:</label> <input type="number" id="abruptThreshold" min="0"> <button onclick="setAbruptThreshold()">Set</button></div>
<div class="param-group"><label>Small Threshold:</label> <input type="number" id="smallThreshold" min="0"> <button onclick="setSmallThreshold()">Set</button></div>
<div class="param-group"><label>Reset Threshold:</label> <input type="number" id="resetThreshold" min="0"> <button onclick="setResetThreshold()">Set</button></div>
<div class="param-group"><label>Peak Proximity (0-1):</label> <input type="number" id="peakProximity" step="0.005" min="0" max="1"> <button onclick="setPeakProximity()">Set</button></div>
<div class="param-group"><label>Cycle Max:</label> <input type="number" id="cicleMax" min="1"> <button onclick="setCicleMax()">Set</button></div>
<div class="param-group"><label>X allocation factor:</label> <input type="number" id="XFactor" min="1" max="8" value="3"> <button onclick="setAxisFactor('X')">Set</button></div>
<div class="param-group"><label>Y allocation factor:</label> <input type="number" id="YFactor" min="1" max="8" value="1"> <button onclick="setAxisFactor('Y')">Set</button></div>
<div class="param-group"><label>Azimuth Deviation (°):</label> <input type="number" id="azDeviation" step="0.01"> <button onclick="setAzDeviation()">Set</button></div>
<div class="param-group"><label>Tilt Angle (°):</label> <input type="number" id="tiltAngle" step="0.01"> <button onclick="setTiltAngle()">Set</button></div>
</div>
<div style="margin-top:12px;">
<h3>Comandos de Seguimiento</h3>
<button onclick="sendCommand(2)">Track Sun (Tiempo)</button>
<button onclick="sendCommand(3)">Fine-Tune (nomegusta)</button>
<button onclick="sendCommand(4)" class="btn-danger">Homing XY (StallGuard)</button>
<button onclick="sendCommand(5)">Mover a Centro</button>
<button onclick="sendCommand(10)" class="btn-secondary">Ver Config TMC</button>
<button onclick="sendCommand(11)">Modo Demo</button>
</div>
<div style="margin-top:12px;">
<h3>Auto-Tune Adaptativo</h3>
<div class="param-group"><label><input type="checkbox" id="autoApply" onchange="toggleAutoApply()"> Auto-apply cada 60s</label></div>
<div class="param-group">
<button onclick="recomputeAT()" class="btn-secondary">Recalcular (Log)</button>
<button onclick="applyAT()">Aplicar Umbrales</button>
</div>
</div>
<h2>Actualización de Firmware (OTA)</h2>
<form method="POST" action="/update" enctype="multipart/form-data">
<input type="file" name="update" accept=".bin">
<input type="submit" value="Subir Firmware" class="btn-secondary">
</form>
<h2>Opciones de Registro</h2>
<a href="/download" download="log.txt"><button class="btn-secondary">Descargar Log</button></a>
<a href="/clear"><button class="btn-danger">Limpiar Log</button></a>
</div>
</div>
</body>
</html>
)rawliteral";
@@ -0,0 +1,7 @@
#pragma once
// Copy to wifi_credentials.h and configure locally. Never commit credentials.
const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_EAP_IDENTITY = "YOUR_EAP_IDENTITY";
const char* WIFI_EAP_USERNAME = "YOUR_EAP_USERNAME";
const char* WIFI_EAP_PASSWORD = "YOUR_EAP_PASSWORD";