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

+7
View File
@@ -0,0 +1,7 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
include/cloud_credentials.h
include/.cloud_credentials-*.tmp
+11
View File
@@ -0,0 +1,11 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack",
"pioarduino.pioarduino-ide"
]
}
@@ -0,0 +1,103 @@
"""Offline cross-compiler tests: no hardware, network, or real credentials."""
import hashlib
import http.client
import io
import json
import os
from pathlib import Path
import re
import ssl
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parent
COMPILER = (Path.home() / ".platformio" / "packages" / "toolchain-xtensa-esp32s3" /
"bin" / ("xtensa-esp32s3-elf-g++.exe" if os.name == "nt" else "xtensa-esp32s3-elf-g++"))
HEADER = ROOT / "include" / "cloud_config.h"
TOKEN = "a" * 64 # Synthetic test value, never registered in any hub.
class CloudIdentityTests(unittest.TestCase):
def compile(self, identity, token=TOKEN, expected=None):
if not COMPILER.is_file():
self.skipTest("ESP32-S3 toolchain not installed")
# Isolate header lookup: never read a developer's private credential file.
with tempfile.TemporaryDirectory(prefix="cloud-id-test-") as directory:
Path(directory, "cloud_config.h").write_bytes(HEADER.read_bytes())
arguments = [str(COMPILER), "-std=gnu++11", "-fsyntax-only", "-x", "c++", "-", "-I", directory]
if identity is not None:
arguments.append("-DPCB_ID=" + identity)
if token is not None:
arguments.append('-DCLOUD_DEVICE_TOKEN="' + token + '"')
source = '#include "cloud_config.h"\n'
if expected is not None:
source += (
"constexpr bool same(const char* a, const char* b) { "
"return *a == *b && (*a == '\\0' || same(a + 1, b + 1)); }\n"
'static_assert(same(CLOUD_DEVICE_ID, "' + expected + '"), "ID mismatch");\n'
)
return subprocess.run(arguments, input=source, text=True, capture_output=True, timeout=30, cwd=directory)
def test_01_exact_hub_ids(self):
for value, expected in (("0x01", "esp32-000000000001"), ("0x02", "esp32-000000000002"),
("0xabcdef123456ULL", "esp32-abcdef123456"),
("0xffffffffffffULL", "esp32-ffffffffffff")):
with self.subTest(value=value):
result = self.compile(value, expected=expected)
self.assertEqual(result.returncode, 0, result.stderr)
def test_02_invalid_ids_cannot_produce_firmware(self):
for value in (None, "0", "-1", "0x1000000000000ULL", "1.5"):
with self.subTest(value=value):
result = self.compile(value)
self.assertNotEqual(result.returncode, 0)
self.assertIn("PCB_ID", result.stderr)
def test_03_missing_or_invalid_token_is_a_build_error(self):
for token in (None, "", "a" * 63, "a" * 65, "G" * 64, "A" * 64):
with self.subTest(length=None if token is None else len(token)):
result = self.compile("0x01", token)
self.assertNotEqual(result.returncode, 0)
self.assertIn("CLOUD_DEVICE_TOKEN", result.stderr)
def test_04_root_certificate_matches_isrg_root_x1(self):
text = HEADER.read_text(encoding="utf-8")
pem = re.search(r"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", text, re.S).group()
digest = hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest()
self.assertEqual(digest, "96bcec06264976f37460779acf28c5a7cfe8a3c0aae11a8ffcee05c0bddf08c6")
def test_05_transport_keeps_token_out_of_url_and_verifies_tls(self):
text = (ROOT / "src" / "cloud_client.cpp").read_text(encoding="utf-8")
self.assertIn('"?device_id="', text)
self.assertIn("devicePath += CLOUD_DEVICE_ID", text)
self.assertIn('setExtraHeaders("Authorization: Bearer " CLOUD_DEVICE_TOKEN)', text)
self.assertIn("beginSslWithCA(host, port, devicePath.c_str(), CLOUD_ROOT_CA_PEM)", text)
self.assertIn("client.setCACert(CLOUD_ROOT_CA_PEM)", text)
self.assertNotIn("setInsecure()", text)
def test_06_websocket_headers_have_no_bytes_after_http_terminator(self):
text = (ROOT / "src/cloud_client.cpp").read_text(encoding="utf-8")
arguments = re.search(r"setExtraHeaders\((.*?)\);", text, re.S).group(1)
before, after = arguments.split("CLOUD_DEVICE_TOKEN")
def literals(fragment):
return "".join(json.loads(value) for value in re.findall(r'"(?:[^"\\]|\\.)*"', fragment))
extra = literals(before) + TOKEN + literals(after)
# WebSockets 2.7.3 appends CRLF to extraHeaders, then User-Agent, then CRLF.
request = (
"GET /ws/esp32?device_id=esp32-000000000002 HTTP/1.1\r\n"
"Host: micro.ferrasdh.uk:443\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n"
"Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
+ extra + "\r\nUser-Agent: arduino-WebSocket-Client\r\n\r\n"
).encode("ascii")
stream = io.BytesIO(request)
stream.readline()
headers = http.client.parse_headers(stream)
self.assertEqual(headers.get("Authorization"), "Bearer " + TOKEN)
self.assertEqual(headers.get("User-Agent"), "arduino-WebSocket-Client")
self.assertEqual(stream.read(), b"", "HTTP header bytes would become invalid WebSocket frames")
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -0,0 +1,290 @@
"""Execute the real tuning engine against offline Arduino/motor test doubles."""
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parent
COMPILER = (Path.home() / ".platformio" / "packages" / "toolchain-gccmingw32" /
"bin" / "g++.exe")
ARDUINO = r'''
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <map>
#include <string>
#include <vector>
struct String {
std::string text;
String(const char* s) : text(s) {}
String(const std::string& s) : text(s) {}
String(int n) : text(std::to_string(n)) {}
};
inline String operator+(const char* a, const String& b) { return String(a + b.text); }
inline String operator+(const String& a, const char* b) { return String(a.text + b); }
extern unsigned long now_ms;
inline unsigned long millis() { return now_ms; }
extern std::vector<std::string> messages;
'''
CONFIG = r'''
#pragma once
struct ScopedMutexGuard { ScopedMutexGuard(int) {} };
constexpr int32_t LIMITX = -114000;
constexpr int32_t LIMITY = -116000;
'''
MOTOR = r'''
#pragma once
#include "Arduino.h"
struct Stepper { void moveToPositionInSteps(int32_t) {} };
extern Stepper stepperX, stepperY;
extern std::vector<std::string> nvs_keys;
extern std::map<std::string, int32_t> nvs_values;
class MotorDriver {
public:
static MotorDriver& instance() { static MotorDriver driver; return driver; }
int guard() { return 0; }
int32_t getPosX() const { return x; }
int32_t getPosY() const { return y; }
void setPosX(int32_t value) { x = value; }
void setPosY(int32_t value) { y = value; }
void waitMotionComplete() {}
void sendPositionUpdate() {}
void nvsPutInt(const char* key, int32_t) { nvs_keys.push_back(key); }
void nvsPutFloat(const char*, float) {}
void nvsPutBool(const char*, bool) {}
int32_t nvsGetInt(const char* key, int32_t fallback) { auto it=nvs_values.find(key); return it==nvs_values.end() ? fallback : it->second; }
float nvsGetFloat(const char*, float fallback) { return fallback; }
bool nvsGetBool(const char*, bool fallback) { return fallback; }
private:
int32_t x = -50000, y = -70000;
};
'''
HARNESS = r'''
#include "Arduino.h"
#include <limits>
#define private public
#include "tuning_engine.h"
#undef private
#include "motor_driver.h"
unsigned long now_ms = 1000;
std::vector<std::string> messages;
std::vector<std::string> nvs_keys;
std::map<std::string, int32_t> nvs_values;
Stepper stepperX, stepperY;
void require(bool condition, const char* message) {
if (!condition) { std::fprintf(stderr, "%s\n", message); std::exit(1); }
}
void prepare(TuningEngine& e, int32_t previous = 5000000) {
e.setLPFBeta(0.0f);
e.setErrMax(500);
e.setMinErrMax(100);
e.setPeakProximity(0.05f);
e.setCicleMax(20);
e._adcOld = -previous;
e._lpfValue = previous;
e._thermalInit = true;
e._irradBaseline = 100.0f;
e._cmaxNorm = 50000000;
e._spikeInit = true;
e._spikeLastC = previous;
e._spikeVar = 1.0e12f;
e._resetThreshold = std::numeric_limits<int32_t>::max();
e._abruptThreshold = std::numeric_limits<int32_t>::max();
}
int32_t tick(TuningEngine& e, int32_t current, float irradiance = 100.0f) {
int32_t before = e.getTotalSteps();
e.nomegusta(-current, e.getErrMax(), irradiance);
now_ms += 2000;
return e.getTotalSteps() - before;
}
int main(int argc, char** argv) {
require(argc == 2, "Supply one scenario");
std::string test = argv[1];
auto& e = TuningEngine::instance();
prepare(e);
if (test == "delta") {
prepare(e, 5080492);
tick(e, 5083030);
require(messages.back().find("dC=2538,") != std::string::npos,
"Current change must be 2538, not the sum 10163522");
} else if (test == "filtered_delta") {
prepare(e, 4900000);
e.setLPFBeta(0.5f);
tick(e, 5000000);
require(messages.back().find("dC=100000,") != std::string::npos,
"Current must be compared with the previous accepted filtered reference");
} else if (test == "peak") {
require(tick(e, 5000000) == 100, "Peak must use minimum step");
} else if (test == "middle") {
require(tick(e, 4875000) == 300, "Mid-ramp must use midpoint step");
} else if (test == "boundary") {
require(tick(e, 4750000) == 500, "Near-window boundary must use maximum step");
} else if (test == "outside") {
e._smallThreshold = 0;
require(tick(e, 4700000) == 500, "Far branch must not jump back to a small step");
} else if (test == "new_peak") {
require(tick(e, 5100000) == 100, "New maximum must use minimum step");
} else if (test == "monotonic") {
int32_t previous_step = 0;
e._smallThreshold = 0;
for (int32_t current : {5000000, 4950000, 4900000, 4850000, 4800000, 4750000, 4700000, 4600000}) {
e._cicles = 0;
int32_t step = tick(e, current);
require(step >= previous_step && step >= 100 && step <= 500,
"Step must increase continuously with distance and remain bounded");
previous_step = step;
}
} else if (test == "zero_window" || test == "nan_window") {
e.setPeakProximity(test == "zero_window" ? 0.0f : std::numeric_limits<float>::quiet_NaN());
require(tick(e, 5000000) == 100, "Invalid window must not cause division by zero or invalid steps");
require(tick(e, 4900000) >= 100, "Invalid window must also be safe away from the peak");
} else if (test == "spike") {
e._spikeVar = 1.0f;
require(tick(e, 6000000) == 0, "Transient spike must not move motors");
require(e._adcOld == -5000000, "Rejected spike must not replace or invert the accepted reference");
require(e._lpfValue == 5000000, "Rejected spike must not contaminate the LPF");
} else if (test == "deadband_escape") {
e._cmaxNorm = 100000000;
e._cicles = 1;
e._smallThreshold = std::numeric_limits<int32_t>::max();
for (int i = 0; i < 9; ++i)
require(tick(e, 5000000) == 0, "Deadband should initially suppress motion");
require(tick(e, 5000000) == 500, "Tenth deadband cycle must escape instead of freezing forever");
} else if (test == "steady_peak") {
for (int i = 0; i < 20; ++i)
require(tick(e, 5000000) == 100, "Healthy near-peak tracking must not trigger coarse escape steps");
} else if (test == "standby") {
require(tick(e, 5000000, 0.0f) == 0, "Low-light standby must not move motors");
require(e.isLowLight(), "Low-light state must still be reported");
} else if (test == "axis_base1_3to1") {
require(e.setCicleMax(1), "base 1 accepted");
e.setAxisXFactor(3); e.setAxisYFactor(1); e._smallThreshold=0;
for (int i=0;i<3;++i) tick(e, 4900000);
require(!e._xActive && e._axisXCount==3, "base 1 ratio 3:1 actuates 3 X iterations");
tick(e, 4900000);
require(e._xActive && e._axisXCount==0 && e._axisYCount==0, "base 1 ratio 3:1 actuates 1 Y iteration");
} else if (test == "axis_base4_3to1") {
e.setCicleMax(4); require(e.setAxisXFactor(3) && e.setAxisYFactor(1), "3:1 factors accepted");
require(e.getAxisXBudget() == 12 && e.getAxisYBudget() == 4, "base 4 yields 12 X and 4 Y iterations");
e._smallThreshold=0;
for (int i=0;i<12;++i) tick(e, 4900000);
require(!e._xActive && e._axisXCount==12, "X receives twelve actual fine-tune actuation iterations");
for (int i=0;i<4;++i) tick(e, 4900000);
require(e._xActive && e._axisXCount==0 && e._axisYCount==0, "Y receives four iterations then schedule repeats");
} else if (test == "axis_symmetric") {
e.setCicleMax(1); e.setAxisXFactor(1); e.setAxisYFactor(1); e._smallThreshold=0;
tick(e, 4900000); require(!e._xActive && e._axisXCount==1, "1:1 schedules one X before Y");
tick(e, 4900000); require(e._xActive && e._axisXCount==0 && e._axisYCount==0, "1:1 schedules one Y then repeats");
} else if (test == "axis_runtime_change") {
e.setCicleMax(4); e.setAxisXFactor(3); e.setAxisYFactor(1); e._smallThreshold=0;
for (int i=0;i<3;++i) tick(e, 4900000);
require(e._axisXCount==3, "initial schedule has pending X iterations");
require(e.setAxisXFactor(2), "runtime factor change accepted");
require(e._xActive && e._axisXCount==0 && e._axisYCount==0 && e.getAxisXBudget()==8,
"runtime change restarts with exact recalculated budget");
require(!e.setCicleMax(0) && !e.setCicleMax(-1) && !e.setCicleMax(65), "invalid base counts rejected");
require(!e.setAxisXFactor(9) && !e.setAxisYFactor(0), "invalid factors rejected");
} else if (test == "axis_nvs_restore") {
nvs_values["ciclemx"]=4; nvs_values["axisxf"]=3; nvs_values["axisyf"]=1;
e.loadFromNVS();
require(e.getCicleMax()==4 && e.getAxisXFactor()==3 && e.getAxisYFactor()==1 &&
e.getAxisXBudget()==12 && e.getAxisYBudget()==4, "valid saved schedule is restored unchanged");
nvs_values["ciclemx"]=10; nvs_values["axisxf"]=8; nvs_values["axisyf"]=1;
e.loadFromNVS();
require(e.getCicleMax()==10 && e.getAxisXFactor()==1 && e.getAxisYFactor()==1 && e.isAxisConfigValid(),
"conflicting saved factors sanitize to safe 1:1 while retaining valid base");
require(messages.back().find("AXISCFG:1:1:10")!=std::string::npos, "sanitized state reports only actual applied schedule");
nvs_values["ciclemx"]=0; nvs_values["axisxf"]=3; nvs_values["axisyf"]=1;
e.loadFromNVS();
require(e.getCicleMax()==5 && e.getAxisXFactor()==3 && e.getAxisYFactor()==1,
"invalid saved base falls back safely and retains compatible valid factors");
} else if (test == "axis_skipped_samples") {
e.setCicleMax(4); e.setAxisXFactor(3); e.setAxisYFactor(1);
e._spikeVar=1.0f;
tick(e, 6000000);
require(e._axisXCount==0 && e._axisYCount==0, "rejected spike does not consume axis budget");
e._spikeVar=1.0e12f; e._cicles=1; e._cmaxNorm=100000000;
e._smallThreshold=std::numeric_limits<int32_t>::max();
for(int i=0;i<9;++i) tick(e,5000000);
require(e._axisXCount==0 && e._axisYCount==0, "deadband-suppressed reads do not consume axis budget");
} else if (test == "axis_standby_recovery") {
e.setCicleMax(4); e.setAxisXFactor(3); e.setAxisYFactor(1); e._smallThreshold=0;
tick(e,4900000);
require(e._axisXCount==1, "first lit sample actuates X");
tick(e,4900000,0.0f);
require(e._axisXCount==0 && e._axisYCount==0 && e._xActive, "standby clears partial schedule");
tick(e,4900000,100.0f);
require(e._axisXCount==1 && e._xActive, "light recovery starts a fresh X schedule");
} else if (test == "axis_limit_release") {
e.setCicleMax(4); e.setAxisXFactor(3); e.setAxisYFactor(1); e._smallThreshold=0;
MotorDriver::instance().setPosX(-100);
require(tick(e,4700000)>0, "limit test performs a fine-tune actuation");
require(MotorDriver::instance().getPosX()==0 && !e._xActive,
"X travel limit immediately yields the next turn to Y");
} else if (test == "constant_step") {
e.setErrMax(200);
e.setMinErrMax(200);
e._smallThreshold = 0;
for (int32_t current : {5000000, 4875000, 4750000, 4700000})
require(tick(e, current) == 200, "Equal minimum/maximum must preserve fixed-step operation");
} else {
require(false, "Unknown scenario");
}
}
'''
class TuningEngineTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
if not COMPILER.is_file():
raise unittest.SkipTest("PlatformIO native MinGW compiler is not installed")
cls.temp = tempfile.TemporaryDirectory(prefix="tuning-engine-test-")
cls.addClassCleanup(cls.temp.cleanup)
directory = Path(cls.temp.name)
# Copy only the control code; never include private network credentials or hardware drivers.
for name in ("tuning_engine.cpp", "tuning_engine.h"):
(directory / name).write_bytes((ROOT / "src" / name).read_bytes())
for name, text in {"Arduino.h": ARDUINO, "config.h": CONFIG,
"motor_driver.h": MOTOR, "harness.cpp": HARNESS,
"log_manager.h": '#pragma once\n#include "Arduino.h"\ninline void agregarLog(const String& s) { messages.push_back(s.text); }\n'}.items():
(directory / name).write_text(text, encoding="utf-8")
cls.exe = directory / "tuning-test.exe"
arguments = [str(COMPILER), "-std=c++11", "-static", "-O0", "-I", str(directory), "harness.cpp",
"tuning_engine.cpp", "-o", str(cls.exe)]
environment = os.environ.copy()
environment["PATH"] = str(COMPILER.parent) + os.pathsep + environment.get("PATH", "")
result = subprocess.run(arguments, cwd=directory, env=environment,
capture_output=True, text=True, timeout=90)
if result.returncode:
raise AssertionError(f"Compiler exit {result.returncode}:\n{result.stdout}{result.stderr}")
def scenario(self, name):
result = subprocess.run([str(self.exe), name], capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
for _name in ("delta", "filtered_delta", "peak", "middle", "boundary", "outside", "new_peak",
"monotonic", "zero_window", "nan_window", "spike", "deadband_escape", "steady_peak",
"standby", "constant_step", "axis_base1_3to1", "axis_base4_3to1", "axis_symmetric",
"axis_runtime_change", "axis_nvs_restore", "axis_skipped_samples", "axis_standby_recovery",
"axis_limit_release"):
setattr(TuningEngineTests, "test_" + _name, lambda self, name=_name: self.scenario(name))
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -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";
+34
View File
@@ -0,0 +1,34 @@
[env:waveshare_esp32_s3_nano]
platform = espressif32
framework = arduino
board = esp32-s3-devkitc-1
board_build.mcu = esp32s3
board_build.f_cpu = 240000000L
board_build.flash_mode = qio
board_build.psram_type = opi
board_upload.flash_size = 16MB
board_upload.maximum_size = 16777216
board_build.partitions = default_16MB.csv
build_flags =
-DBOARD_HAS_PSRAM
-DARDUINO_USB_CDC_ON_BOOT=1
-DARDUINO_USB_MODE=1
-DPCB_VERSION=1
; Logical card identity, independent of the PCB hardware revision.
-DPCB_ID=0x01
monitor_speed = 115200
lib_deps =
janelia-arduino/TMC2209@^10.1.1
stan-reifel/FlexyStepper@^1.0.0
sparkfun/SparkFun ADS122C04 ADC Arduino Library@^1.0.4
links2004/WebSockets@^2.4.1
[env:waveshare_esp32_s3_nano_v2]
extends = env:waveshare_esp32_s3_nano
build_flags =
-DBOARD_HAS_PSRAM
-DARDUINO_USB_CDC_ON_BOOT=1
-DARDUINO_USB_MODE=1
-DPCB_VERSION=2
-DPCB_ID=0x02
@@ -0,0 +1,110 @@
#include "adc_manager.h"
#include "config.h"
#include <Wire.h>
volatile int32_t rawval[4] = {0, 0, 0, 0};
AdcManager& AdcManager::instance() {
static AdcManager inst;
return inst;
}
void AdcManager::init() {
pinMode(PIN_VOC, OUTPUT);
digitalWrite(PIN_VOC, LOW);
Wire.begin(SDA_PIN, SCL_PIN);
if (!_sensor.begin(0x40, Wire)) {
Serial.println("[ADC] Advertencia: No se pudo comunicar con ADS122C04 en 0x40");
}
_sensor.setInputMultiplexer(ADS122C04_MUX_AIN2_AVSS);
_sensor.setGain(ADS122C04_GAIN_1);
_sensor.setOperatingMode(ADS122C04_OP_MODE_NORMAL);
_sensor.setConversionMode(ADS122C04_CONVERSION_MODE_SINGLE_SHOT);
_sensor.setVoltageReference(ADS122C04_VREF_INTERNAL);
_sensor.setDataCounter(ADS122C04_DCNT_DISABLE);
_sensor.setDataIntegrityCheck(ADS122C04_CRC_DISABLED);
_sensor.setBurnOutCurrent(ADS122C04_BURN_OUT_CURRENT_OFF);
_sensor.setIDACcurrent(ADS122C04_IDAC_CURRENT_OFF);
_sensor.setIDAC1mux(ADS122C04_IDAC1_DISABLED);
_sensor.setIDAC2mux(ADS122C04_IDAC2_DISABLED);
Serial.println("[ADC] ADS122C04 inicializado correctamente.");
}
bool AdcManager::readAll() {
for (uint8_t ch = 0; ch < 4; ch++) {
if (ch == 0) { // Isc (cortocircuito activado)
_sensor.setInputMultiplexer(ADS122C04_MUX_AIN1_AVSS);
digitalWrite(PIN_VOC, HIGH);
} else if (ch == 1) { // AIN0 (Isc directa)
_sensor.setInputMultiplexer(ADS122C04_MUX_AIN0_AVSS);
} else if (ch == 2) { // Piranómetro
_sensor.setInputMultiplexer(ADS122C04_MUX_AIN2_AVSS);
digitalWrite(PIN_VOC, LOW);
} else if (ch == 3) { // Voc (circuito abierto con divisor)
_sensor.setInputMultiplexer(ADS122C04_MUX_AIN3_AVSS);
}
_sensor.start();
unsigned long start_time = millis();
bool drdy = false;
while (!drdy && (millis() < (start_time + ADS122C04_CONVERSION_TIMEOUT))) {
delay(1);
drdy = _sensor.checkDataReady();
}
if (!drdy) {
Serial.println(F("[ADC] checkDataReady timed out"));
return false;
}
// Conversión única de complemento a 2 (24 bits) -> entero con signo.
// Así el resto del sistema (log, telemetría, control) trabaja siempre
// con valores negativos correctos en vez de ~2^24.
int32_t val = ads122c04_to_signed(_sensor.readADC());
_rawval[ch] = val;
rawval[ch] = val; // Sincroniza variable global legacy
float base = ((float)val / 8388607.0f) * 2048.0f; // mV
Serial.print(F(",V"));
Serial.print(ch);
Serial.print(":");
if (ch == 2) {
Serial.print(base / 2.0f); // W/m2 directo
} else if (ch == 3) {
Serial.print(base * 2.0f); // Voc x2
} else {
Serial.print(base);
}
delay(1);
}
return true;
}
float AdcManager::getVoltage(uint8_t channel) const {
if (channel >= 4) return 0.0f;
float base = ((float)_rawval[channel] / 8388607.0f) * 2048.0f;
if (channel == 2) return base / 2.0f;
if (channel == 3) return base * 2.0f;
return base;
}
int32_t AdcManager::getIrradianceWm2() const {
// _rawval[2] ya viene con signo desde readAll() (ads122c04_to_signed).
float v2_base = ((float)_rawval[2] / 8388607.0f) * 2048.0f;
return (int32_t)lroundf(v2_base / 2.0f);
}
// Funciones globales legacy
void externalADC_init() {
AdcManager::instance().init();
}
void leetodo() {
AdcManager::instance().readAll();
}
int32_t current(int32_t data) {
return data;
}
@@ -0,0 +1,40 @@
#pragma once
#include <Arduino.h>
#include <SparkFun_ADS122C04_ADC_Arduino_Library.h>
// El ADS122C04 entrega 24 bits en complemento a 2. Interpretarlos como
// uint32_t hacía que los valores negativos (de noche, p. ej. ~0/-42) se
// leyeran como ~2^24 (≈2048 W/m2 en el piranómetro). Esta conversión
// extiende el bit 23 para obtener el entero con signo correcto.
static inline int32_t ads122c04_to_signed(uint32_t raw24) {
return (raw24 & 0x00800000u) ? (int32_t)(raw24 | 0xFF000000u)
: (int32_t)(raw24 & 0x00FFFFFFu);
}
class AdcManager {
public:
static AdcManager& instance();
void init();
bool readAll();
// Valor crudo CON SIGNO (ya convertido de complemento a 2).
int32_t getRaw(uint8_t channel) const {
return (channel < 4) ? _rawval[channel] : 0;
}
float getVoltage(uint8_t channel) const;
int32_t getIrradianceWm2() const;
int32_t getCurrentCounts() const { return _rawval[1]; }
private:
AdcManager() = default;
SFE_ADS122C04 _sensor;
volatile int32_t _rawval[4] = {0, 0, 0, 0};
};
extern volatile int32_t rawval[4];
void externalADC_init();
void leetodo();
int32_t current(int32_t data);
@@ -0,0 +1,383 @@
#include "cloud_client.h"
#include "config.h"
#include "cloud_config.h"
#include "motor_driver.h"
#include "solar_tracker.h"
#include "tuning_engine.h"
#include "log_manager.h"
#include "web_server_manager.h"
// OTA / firmware update
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <Update.h>
#include <cstdlib>
static bool parseBoundedPositiveInteger(const String& text, int32_t maximum, int32_t& result) {
if (!text.length()) return false;
int32_t value = 0;
for (size_t i = 0; i < text.length(); ++i) {
if (text[i] < '0' || text[i] > '9') return false;
int digit = text[i] - '0';
if (value > (maximum - digit) / 10) return false;
value = value * 10 + digit;
}
if (value < 1 || value > maximum) return false;
result = value;
return true;
}
CloudClient& CloudClient::instance() {
static CloudClient inst;
return inst;
}
void CloudClient::init(const char* host, uint16_t port, const char* path) {
if (!host || strlen(host) == 0 || !path || strlen(path) == 0) {
Serial.println("[CLOUD] Host no configurado, sincronizacion desactivada.");
_enabled = false;
return;
}
_enabled = true;
String devicePath(path);
devicePath += strchr(path, '?') ? "&device_id=" : "?device_id=";
devicePath += CLOUD_DEVICE_ID;
Serial.printf("[CLOUD] PCB_ID=0x%llx, ID=%s\n", static_cast<unsigned long long>(PCB_ID), CLOUD_DEVICE_ID);
Serial.printf("[CLOUD] Configurando conexion saliente a wss://%s:%u%s\n", host, port, devicePath.c_str());
_wsClient.beginSslWithCA(host, port, devicePath.c_str(), CLOUD_ROOT_CA_PEM);
// The library adds CRLF; an extra terminator would put User-Agent outside HTTP.
_wsClient.setExtraHeaders("Authorization: Bearer " CLOUD_DEVICE_TOKEN);
_wsClient.onEvent([this](WStype_t type, uint8_t* payload, size_t length) {
switch (type) {
case WStype_CONNECTED:
_connected = true;
Serial.println("[CLOUD] ¡Conectado al servidor Docker Hub con exito!");
sendVersion();
TuningEngine::instance().reportAxisConfig();
// Enviar posicion actual
sendPosition(MotorDriver::instance().getPosX(), MotorDriver::instance().getPosY());
break;
case WStype_DISCONNECTED:
_connected = false;
Serial.println("[CLOUD] Desconectado del servidor Docker Hub. Reintentando...");
break;
case WStype_TEXT: {
String cmd = String((char*)payload);
Serial.println("[CLOUD] Comando recibido: " + cmd);
processCommand(cmd);
break;
}
default:
break;
}
});
_wsClient.setReconnectInterval(5000); // Reintento automatico cada 5s
}
void CloudClient::loop() {
if (_enabled) {
_wsClient.loop();
runOtaPending();
}
}
void CloudClient::sendLog(const String& entry) {
if (_enabled && _connected) {
String msg = "LOG:" + entry;
_wsClient.sendTXT(msg.c_str());
}
}
void CloudClient::sendPosition(int32_t x, int32_t y) {
if (_enabled && _connected) {
String msg = "POS:" + String(-x) + ":" + String(-y);
_wsClient.sendTXT(msg.c_str());
}
}
void CloudClient::processCommand(const String& cmd) {
if (cmd.startsWith("CMD:")) {
int status = cmd.substring(4).toInt();
agregarLog("[CLOUD] Ejecutando comando status=" + String(status));
switch (status) {
case 2:
SolarTracker::instance().trackSunByTime();
break;
case 3:
tracker_status = 3;
agregarLog("Iniciando Fine-Tune (nomegusta)");
break;
case 4:
MotorDriver::instance().setXY();
MotorDriver::instance().sendPositionUpdate();
break;
case 5: {
int32_t cx = LIMITX / 2;
int32_t cy = LIMITY / 2;
MotorDriver::instance().moveTo(cx, cy);
agregarLog("Moviendo a centro (X:" + String(-cx) + ", Y:" + String(-cy) + ")");
break;
}
case 10:
MotorDriver::instance().printTMCStatus();
break;
case 11:
MotorDriver::instance().setXY();
MotorDriver::instance().moveTo(LIMITX, LIMITY);
break;
default:
break;
}
} else if (cmd.startsWith("SET_COORDS:")) {
int firstColon = cmd.indexOf(':');
int secondColon = cmd.indexOf(':', firstColon + 1);
if (secondColon > 0) {
int32_t targetX = -cmd.substring(firstColon + 1, secondColon).toInt();
int32_t targetY = -cmd.substring(secondColon + 1).toInt();
agregarLog("[CLOUD] Set Coords X:" + String(-targetX) + ", Y:" + String(-targetY));
MotorDriver::instance().moveTo(targetX, targetY);
}
} else if (cmd.startsWith("SET_PARAM:")) {
// Formato: SET_PARAM:param=val
String paramStr = cmd.substring(10);
int eqIndex = paramStr.indexOf('=');
if (eqIndex > 0) {
String param = paramStr.substring(0, eqIndex);
String val = paramStr.substring(eqIndex + 1);
if (param == "axisXFactor" || param == "axisYFactor") {
int32_t parsed;
if (!parseBoundedPositiveInteger(val, 8, parsed)) {
agregarLog("[CLOUD] Axis factor invalid (1..8; total budget <=64)"); return;
}
}
if (param == "errMax") {
int32_t v = val.toInt();
TuningEngine::instance().setErrMax(v);
errmax = v;
agregarLog("[CLOUD] ErrMax=" + String(v));
} else if (param == "delay") {
int32_t v = val.toInt();
TuningEngine::instance().setDelay(v);
delayyy = v;
agregarLog("[CLOUD] Delay=" + String(v));
} else if (param == "az") {
double v = val.toDouble();
SolarTracker::instance().setAzDeviation(v);
Az_deviation = v;
agregarLog("[CLOUD] Az_deviation=" + String(v));
} else if (param == "tilt") {
double v = val.toDouble();
SolarTracker::instance().setTiltAngle(v);
tilt_angle = v;
agregarLog("[CLOUD] TiltAngle=" + String(v));
} else if (param == "lpfBeta") {
float v = val.toFloat();
TuningEngine::instance().setLPFBeta(v);
LPF_Beta = v;
agregarLog("[CLOUD] LPF_Beta=" + String(v));
} else if (param == "minErrMax") {
int32_t v = val.toInt();
TuningEngine::instance().setMinErrMax(v);
min_errmax = v;
agregarLog("[CLOUD] MinErrMax=" + String(v));
} else if (param == "abruptThreshold") {
int32_t v = val.toInt();
TuningEngine::instance().setAbruptThreshold(v);
abrupt_threshold = v;
agregarLog("[CLOUD] AbruptThreshold=" + String(v));
} else if (param == "smallThreshold") {
int32_t v = val.toInt();
TuningEngine::instance().setSmallThreshold(v);
small_threshold = v;
agregarLog("[CLOUD] SmallThreshold=" + String(v));
} else if (param == "resetThreshold") {
int32_t v = val.toInt();
TuningEngine::instance().setResetThreshold(v);
reset_threshold = v;
agregarLog("[CLOUD] ResetThreshold=" + String(v));
} else if (param == "peakProximity") {
float v = val.toFloat();
TuningEngine::instance().setPeakProximity(v);
peak_proximity = v;
agregarLog("[CLOUD] PeakProximity=" + String(v));
} else if (param == "cicleMax") {
int32_t v;
if (!parseBoundedPositiveInteger(val, 64, v) || !TuningEngine::instance().setCicleMax(v)) {
agregarLog("[CLOUD] CicleMax invalid for current axis factors"); return;
}
cicleMAX = v;
agregarLog("[CLOUD] CicleMax=" + String(v));
} else if (param == "axisXFactor") {
int32_t v;
if (!parseBoundedPositiveInteger(val, 8, v)) { agregarLog("[CLOUD] AxisXFactor rejected"); return; }
if (!TuningEngine::instance().setAxisXFactor(v)) { agregarLog("[CLOUD] AxisXFactor rejected"); return; }
} else if (param == "axisYFactor") {
int32_t v;
if (!parseBoundedPositiveInteger(val, 8, v)) { agregarLog("[CLOUD] AxisYFactor rejected"); return; }
if (!TuningEngine::instance().setAxisYFactor(v)) { agregarLog("[CLOUD] AxisYFactor rejected"); return; }
} else if (param == "minIrradiance") {
float v = val.toFloat();
TuningEngine::instance().setMinIrradiance(v);
agregarLog("[CLOUD] MinIrradiance=" + String(v));
}
}
} else if (cmd.startsWith("OTA:")) {
String url = cmd.substring(4);
agregarLog("[CLOUD] OTA solicitado: " + url);
if (_otaPending) {
sendLog("OTA ya en curso, ignorando");
} else {
requestOta(url);
}
}
}
void forwardLogToCloud(const String& entry) {
CloudClient::instance().sendLog(entry);
}
void forwardPositionToCloud(int32_t x, int32_t y) {
CloudClient::instance().sendPosition(x, y);
}
// ----------------------------------------------------------------------------
// OTA / firmware update: download the image over HTTPS, flash the OTA slot,
// verify and reboot into it. Runs from loop(); on success the device reboots.
// ----------------------------------------------------------------------------
void CloudClient::sendVersion() {
if (_enabled && _connected) {
String msg = String("VER:") + FIRMWARE_VERSION;
_wsClient.sendTXT(msg.c_str());
}
}
void CloudClient::requestOta(const String& url) {
_otaUrl = url;
_otaPending = true;
Serial.printf("[OTA] Actualizacion solicitada desde: %s\n", url.c_str());
}
void CloudClient::runOtaPending() {
if (!_otaPending) return;
_otaPending = false;
sendLog("OTA: comenzando, descargando " + _otaUrl);
if (!performOta(_otaUrl)) {
sendLog("OTA: termino con fallos; la firmware actual sigue intacta");
}
// On success performOta() reboots, so the line above only runs on failure.
}
bool CloudClient::performOta(const String& url) {
// 1. Verificar/Reconectar WiFi
if (WiFi.status() != WL_CONNECTED) {
sendLog("OTA: WiFi caido, reconectando...");
WiFi.reconnect();
unsigned long t0 = millis();
while (WiFi.status() != WL_CONNECTED && (millis() - t0) < 15000) {
delay(200);
}
if (WiFi.status() != WL_CONNECTED) {
sendLog("OTA: FALLO - sin conexion WiFi para actualizar");
return false;
}
}
// 2. Configurar cliente HTTPS
WiFiClientSecure client;
client.setCACert(CLOUD_ROOT_CA_PEM);
client.setHandshakeTimeout(15);
HTTPClient http;
if (!http.begin(client, url)) {
sendLog("OTA: FALLO - no se pudo iniciar la conexion HTTP");
return false;
}
int code = http.GET();
if (code != 200) {
sendLog("OTA: FALLO - el servidor HTTP respondio " + String(code));
http.end();
return false;
}
long contentLength = http.getSize();
if (contentLength <= 0) {
sendLog("OTA: FALLO - la respuesta no incluye tamano (Content-Length)");
http.end();
return false;
}
size_t size = (size_t)contentLength;
sendLog("OTA: descargando " + String(size) + " bytes...");
// 3. Inicializar partición OTA
if (!Update.begin(size)) {
sendLog("OTA: FALLO en Update.begin (err " + String(Update.getError()) + ")");
http.end();
return false;
}
// 4. Obtener la referencia del stream (Corregido)
WiFiClient& stream = http.getStream(); // <- Uso de referencia (&) en lugar de puntero (*)
size_t written = 0;
unsigned long lastReport = millis();
const size_t BUF = 1024;
uint8_t buf[BUF];
while (written < size) {
_wsClient.loop(); // Mantener activa la sesión WSS
if (millis() - lastReport > 3000) {
lastReport = millis();
sendLog("OTA: descargando " + String(written) + "/" + String(size) + " bytes...");
}
size_t avail = stream.available(); // Uso de . en lugar de ->
if (avail > 0) {
if (avail > BUF) avail = BUF;
int n = stream.readBytes(buf, avail); // Uso de . en lugar de ->
if (n <= 0) break;
size_t w = Update.write(buf, n);
if (w == 0) {
http.end();
Update.abort();
sendLog("OTA: FALLO - no se pudo escribir en el slot OTA (err " + String(Update.getError()) + ")");
return false;
}
written += w;
} else {
delay(5);
}
if (!_wsClient.isConnected() && (millis() - lastReport) > 15000) {
http.end();
Update.abort();
sendLog("OTA: FALLO - conexion WS perdida durante la descarga");
return false;
}
}
http.end();
if (written != size) {
sendLog("OTA: FALLO - escritura incompleta " + String(written) + "/" + String(size));
Update.abort();
return false;
}
// 5. Finalizar actualización
if (Update.end(true)) {
sendLog("OTA: exito, reiniciando con la nueva firmware...");
delay(300);
ESP.restart();
return true;
} else {
sendLog("OTA: FALLO en Update.end (err " + String(Update.getError()) + ")");
return false;
}
}
@@ -0,0 +1,35 @@
#pragma once
#include <Arduino.h>
#include <WebSocketsClient.h>
class CloudClient {
public:
static CloudClient& instance();
void init(const char* host, uint16_t port, const char* path);
void loop();
bool isConnected() const { return _connected; }
void sendLog(const String& entry);
void sendPosition(int32_t x, int32_t y);
void processCommand(const String& cmd);
// ---- OTA / firmware update --------------------------------------------
void sendVersion(); // report "VER:<FIRMWARE_VERSION>" to the hub
void requestOta(const String& url); // queue an update (non-blocking, from a WS cmd)
void runOtaPending(); // call from loop(): performs a queued OTA
private:
CloudClient() = default;
bool performOta(const String& url); // download over HTTPS + flash + reboot (blocking)
WebSocketsClient _wsClient;
bool _connected = false;
bool _enabled = false;
bool _otaPending = false;
String _otaUrl;
};
void forwardLogToCloud(const String& entry);
void forwardPositionToCloud(int32_t x, int32_t y);
@@ -0,0 +1,118 @@
#include "log_manager.h"
#include "esp_heap_caps.h"
#include <time.h>
LogManager& LogManager::instance() {
static LogManager inst;
return inst;
}
void LogManager::init(size_t capacity, WebSocketsServer* wsServer) {
_ws = wsServer;
if (!_mutex) {
_mutex = xSemaphoreCreateMutex();
if (!_mutex) {
Serial.println("[LOG] FATAL: no se pudo crear el mutex del buffer de log. Reiniciando.");
esp_restart();
}
}
_logBuffer = (char*)ps_malloc(capacity);
if (!_logBuffer) {
Serial.println("[LOG] Error: No se pudo asignar PSRAM para logs. Usando Heap interno reducido...");
// Fallback a heap si PSRAM no estuviese lista
_logBuffer = (char*)malloc(64 * 1024);
if (_logBuffer) {
_logCapacity = 64 * 1024;
_logSize = 0;
_logBuffer[0] = '\0';
Serial.printf("[LOG] Log en SRAM asignado (%u bytes)\n", (unsigned)_logCapacity);
} else {
_logCapacity = 0;
_logSize = 0;
Serial.println("[LOG] Error fatal asignando memoria de log.");
}
} else {
_logCapacity = capacity;
_logSize = 0;
_logBuffer[0] = '\0';
Serial.printf("[LOG] Log en PSRAM inicializado correctamente (%u bytes)\n", (unsigned)_logCapacity);
}
}
String LogManager::getTimeStamp() {
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) {
return "[sin hora]";
}
char buffer[25];
strftime(buffer, sizeof(buffer), "[%Y-%m-%d %H:%M:%S]", &timeinfo);
return String(buffer);
}
String LogManager::snapshotTail(size_t maxLen) {
ScopedMutexGuard g(_mutex);
if (!_logBuffer || _logSize == 0) return String();
size_t start = (_logSize > maxLen) ? (_logSize - maxLen) : 0;
return String(_logBuffer + start);
}
void LogManager::add(const String& message) {
String entry = getTimeStamp() + " " + message + "\n";
Serial.print(entry);
{
ScopedMutexGuard g(_mutex);
if (_logBuffer && _logCapacity > 0) {
size_t len = entry.length();
// +1: reservar el NUL final tras el entry.
if (_logSize + len + 1 >= _logCapacity) {
size_t keep = _logCapacity / 2;
memmove(_logBuffer, _logBuffer + (_logSize - keep), keep);
_logSize = keep;
}
size_t space = _logCapacity - _logSize - 1;
if (len > space) len = space; // recortar entry, nunca desbordar
memcpy(_logBuffer + _logSize, entry.c_str(), len);
_logSize += len;
_logBuffer[_logSize] = '\0';
}
}
if (_ws) {
String wsMsg = "LOG:" + entry;
_ws->broadcastTXT(wsMsg.c_str());
}
// Retransmitir al servidor Docker Hub remoto
extern void forwardLogToCloud(const String& entry);
forwardLogToCloud(entry);
}
void LogManager::clear() {
{
ScopedMutexGuard g(_mutex);
if (_logBuffer) {
_logSize = 0;
_logBuffer[0] = '\0';
}
}
if (_ws) {
_ws->broadcastTXT("LOG:[LOG LIMPIADO]\n");
}
Serial.println("[LOG LIMPIADO]");
}
// Envoltorios globales
void agregarLog(const String& texto) {
LogManager::instance().add(texto);
}
void limpiarLog() {
LogManager::instance().clear();
}
String getTimeStamp() {
return LogManager::instance().getTimeStamp();
}
@@ -0,0 +1,40 @@
#pragma once
#include <Arduino.h>
#include <WebSocketsServer.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include "config.h"
class LogManager {
public:
static LogManager& instance();
void init(size_t capacity, WebSocketsServer* wsServer);
void add(const String& message);
void clear();
String getTimeStamp();
// Guard RAII: serializa el acceso al buffer durante el alcance del guard.
ScopedMutexGuard guard() { return ScopedMutexGuard(_mutex); }
// Snapshot consistente de los ultimos maxLen bytes, tomado bajo el mutex.
// Evita copiar el log completo (2MB en PSRAM) al heap interno.
String snapshotTail(size_t maxLen);
private:
LogManager() = default;
char* _logBuffer = nullptr;
size_t _logSize = 0;
size_t _logCapacity = 0;
WebSocketsServer* _ws = nullptr;
// Serializa memcpy/memmove sobre _logBuffer y los cambios de _logSize.
// Sin esto, dos tareas escribiendo a la vez corrompen la PSRAM.
SemaphoreHandle_t _mutex = nullptr;
};
// Funciones globales auxiliares para compatibilidad sencilla
void agregarLog(const String& texto);
void limpiarLog();
String getTimeStamp();
+179
View File
@@ -0,0 +1,179 @@
#include <Arduino.h>
#include <WiFi.h>
#include <time.h>
#include "config.h"
#include "log_manager.h"
#include "motor_driver.h"
#include "adc_manager.h"
#include "tuning_engine.h"
#include "solar_tracker.h"
#include "web_server_manager.h"
#include "cloud_client.h"
// Local WPA2-Enterprise credentials; see wifi_credentials.example.h.
#include "wifi_credentials.h"
// --- Servidor Docker Hub Remoto (Cambia a la IP de tu PC o dominio/VPS) ---
const char* CLOUD_SERVER_HOST = "micro.ferrasdh.uk";
void setup() {
Serial.begin(115200);
delay(1000); // Pequeña pausa para inicialización de USB CDC nativo
Serial.println("\n==========================================");
Serial.println(" ESP32-S3 Microtracking System Starting ");
Serial.println("==========================================");
// Conexión WiFi (WPA2-Enterprise / PEAP)
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WPA2_AUTH_PEAP, WIFI_EAP_IDENTITY, WIFI_EAP_USERNAME, WIFI_EAP_PASSWORD);
Serial.print("Conectando a WiFi");
uint8_t wifi_attempts = 0;
while (WiFi.status() != WL_CONNECTED && wifi_attempts < 40) {
delay(500);
Serial.print(".");
wifi_attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println("\nWiFi conectado exitosamente.");
Serial.print("IP local asignada: ");
Serial.println(WiFi.localIP());
} else {
Serial.println("\nNo se pudo conectar a WiFi en el inicio (continuando de forma autonoma).");
}
// Sincronización NTP
configTime(GMT_OFFSET_SEC, DAYLIGHT_OFFSET_SEC, NTP_SERVER);
struct tm timeinfo;
if (!getLocalTime(&timeinfo, 5000)) {
Serial.println("Advertencia: No se pudo sincronizar NTP.");
} else {
Serial.println(&timeinfo, "Hora NTP sincronizada: %Y-%m-%d %H:%M:%S");
}
// Inicialización del sistema de Log con PSRAM
LogManager::instance().init(PSRAM_LOG_CAPACITY, &WebServerManager::instance().getWebSocket());
if (WiFi.status() == WL_CONNECTED) {
agregarLog("WiFi conectado. IP: " + WiFi.localIP().toString());
}
agregarLog("Hora sincronizada NTP");
// Inicialización de Motores y TMC2209
MotorDriver::instance().init();
// Inicialización del ADC externo (ADS122C04)
AdcManager::instance().init();
// Inicialización del Servidor Web HTTP y WebSockets
WebServerManager::instance().init();
// Inicialización de conexión saliente hacia Docker Hub Remoto
if (CLOUD_SYNC_ENABLED) {
CloudClient::instance().init(CLOUD_SERVER_HOST, CLOUD_SERVER_PORT, CLOUD_SERVER_PATH);
}
// Diagnóstico de memoria del sistema
Serial.printf("[MEM] Free Heap: %u bytes\n", ESP.getFreeHeap());
Serial.printf("[MEM] Total PSRAM: %u bytes | Free PSRAM: %u bytes\n",
ESP.getPsramSize(), ESP.getFreePsram());
char memMsg[90];
snprintf(memMsg, sizeof(memMsg), "Memoria: Heap=%u B, PSRAM Free=%u B",
ESP.getFreeHeap(), ESP.getFreePsram());
agregarLog(memMsg);
}
void loop() {
// Persistencia condicional de coordenadas X/Y en NVS
MotorDriver::instance().maybePersistXY();
// Manejo de peticiones Web y WebSockets locales
WebServerManager::instance().loop();
// Atender cliente de conexión en la nube
if (CLOUD_SYNC_ENABLED) {
CloudClient::instance().loop();
}
static uint32_t lastSampleSec = 0;
static int32_t lastPosX = 0;
static int32_t lastPosY = 0;
static uint32_t lastDriftCheckMs = 0;
uint32_t nowSec = millis() / 1000;
if (nowSec >= lastSampleSec + TuningEngine::instance().getDelay()) {
AdcManager::instance().readAll();
lastSampleSec = nowSec;
int32_t curX = MotorDriver::instance().getPosX();
int32_t curY = MotorDriver::instance().getPosY();
int32_t counts = AdcManager::instance().getCurrentCounts();
int32_t irrad = AdcManager::instance().getIrradianceWm2();
int32_t rawV = AdcManager::instance().getRaw(3);
int32_t rawP = AdcManager::instance().getRaw(2);
char buff[90];
snprintf(buff, sizeof(buff), "X:%d,Y:%d,C:%d,V:%d,P:%d,S:%u,er:%d",
-curX, -curY, counts, rawV, rawP, lastSampleSec, TuningEngine::instance().getErrMax());
agregarLog(buff);
// Registro de muestra para el algoritmo de Auto-Tune
TuningEngine::instance().pushSample(counts, irrad, millis());
// F2: Auto-arranque del fine-tune
// Si el tracker aún no está activo (status != 3) y hay suficiente luz,
// iniciar automáticamente sin depender del comando cloud.
if (tracker_status != 3 && irrad > TuningEngine::instance().getMinIrradiance()) {
tracker_status = 3;
char b[120];
snprintf(b, sizeof(b),
"F2: Auto-arranque fine-tune (irr=%.0f W/m2 > min=%.0f)",
(float)irrad, TuningEngine::instance().getMinIrradiance());
agregarLog(b);
}
// Bucle activo de fine-tune si el estado es 3
int32_t varerr = -counts;
if (tracker_status == 3) {
TuningEngine::instance().nomegusta(varerr, TuningEngine::instance().getErrMax(), (float)irrad);
}
// Telemetría: acumular valores y reportar promedios cada 2s
TuningEngine::instance().updateTelemetry(counts, rawV, rawP, 0);
TuningEngine::instance().logTelemetry();
// Reconexión automática de WiFi si se pierde el enlace
if (WiFi.status() != WL_CONNECTED) {
WiFi.reconnect();
}
}
// F5: Re-alineación astronómica periódica (cada 5 min)
// Corrige deriva del fine-tune comparando con la posición solar calculada.
// Solo actúa si NO hay suficiente luz (irradiancia insuficiente): con luz
// suficiente el fine-tune (F2/nomegusta) ya mantiene la alineación en tiempo
// real, por lo que F5 NO debe interrumpirlo. Su frontera coincide con F2
// (que arranca el fine-tune cuando irrad > getMinIrradiance()).
{
uint32_t nowMs = millis();
if ((nowMs - lastDriftCheckMs) >= F5_DRIFT_PERIOD_MS) {
lastDriftCheckMs = nowMs;
int32_t irradNow = AdcManager::instance().getIrradianceWm2();
if (irradNow < TuningEngine::instance().getMinIrradiance()) {
SolarTracker::instance().checkDrift(F5_DRIFT_THRESHOLD);
}
}
}
// Actualización de posición si hubo cambios
int32_t curPosX = MotorDriver::instance().getPosX();
int32_t curPosY = MotorDriver::instance().getPosY();
if (curPosX != lastPosX || curPosY != lastPosY) {
MotorDriver::instance().sendPositionUpdate();
lastPosX = curPosX;
lastPosY = curPosY;
}
// Evaluación periódica de auto-apply de Auto-Tune
TuningEngine::instance().checkAutoApply();
}
@@ -0,0 +1,339 @@
#include "motor_driver.h"
#include "log_manager.h"
#include "tuning_engine.h"
#include <HardwareSerial.h>
// Global references for backward compatibility
bool nvsEnabled = true;
FlexyStepper stepperX;
FlexyStepper stepperY;
TMC2209 stepper_driver_0;
TMC2209 stepper_driver_1;
static HardwareSerial& tmcSerial = Serial0;
MotorDriver& MotorDriver::instance() {
static MotorDriver inst;
return inst;
}
// --- Mutex de movimiento (creado en init(), critico si falla la creacion) ---
void MotorDriver::init() {
// Crear el mutex antes de cualquier operación de motor (setCurrentPosition
// al final de esta función ya lo utiliza).
if (!_motionMutex) {
_motionMutex = xSemaphoreCreateMutex();
if (!_motionMutex) {
Serial.println("[MOTOR] FATAL: no se pudo crear el mutex de movimiento. Reiniciando.");
esp_restart();
}
}
// --- NVS Coordinates Restore ---
_prefs.begin("coords", false);
_lastSavedX = _prefs.getInt("x", 0);
_lastSavedY = _prefs.getInt("y", 0);
_posX = _lastSavedX;
_posY = _lastSavedY;
// Restaurar flag de autosave NVS (persistido en namespace "params")
_nvsEnabled = _prefs.getBool("nvsEn", true);
// --- NVS Tuning Parameters Restore (namespace "params") ---
TuningEngine::instance().loadFromNVS();
#if VERBO
Serial.printf("[NVS] Restaurado X=%ld Y=%ld\n", (long)_posX, (long)_posY);
#endif
tmcSerial.begin(TMC_SERIAL_BAUD_RATE);
pinMode(PIN_MOTOR_EN, OUTPUT);
digitalWrite(PIN_MOTOR_EN, LOW); // Enable drivers
stepper_driver_0.setup(tmcSerial, TMC_SERIAL_BAUD_RATE, TMC2209::SERIAL_ADDRESS_1);
stepper_driver_0.setReplyDelay(TMC_REPLY_DELAY);
stepper_driver_1.setup(tmcSerial, TMC_SERIAL_BAUD_RATE, TMC2209::SERIAL_ADDRESS_3);
stepper_driver_1.setReplyDelay(TMC_REPLY_DELAY);
if (!stepper_driver_0.isCommunicating()) {
Serial.println("[TMC] Stepper driver 0 no responde a UART, reintentando...");
agregarLog("Stepper driver 1 no configurado");
stepper_driver_0.setup(tmcSerial, TMC_SERIAL_BAUD_RATE, TMC2209::SERIAL_ADDRESS_1);
stepper_driver_0.setReplyDelay(TMC_REPLY_DELAY);
delay(100);
}
Serial.println("[TMC] COM READY");
agregarLog("COM READY");
// Configuración Driver 0 (X)
stepper_driver_0.moveAtVelocity(0);
stepper_driver_0.setRunCurrent(RUN_CURRENT_PERCENT0);
stepper_driver_0.setMicrostepsPerStepPowerOfTwo(3);
stepper_driver_0.setStallGuardThreshold(STALL_GUARD_THRESHOLD0);
stepper_driver_0.enableAutomaticCurrentScaling();
stepper_driver_0.enableCoolStep(0, 2);
stepper_driver_0.enable();
stepper_driver_0.setHoldCurrent(2);
// Configuración Driver 1 (Y)
stepper_driver_1.moveAtVelocity(0);
stepper_driver_1.setRunCurrent(RUN_CURRENT_PERCENT1);
stepper_driver_1.setMicrostepsPerStepPowerOfTwo(3);
stepper_driver_1.setStallGuardThreshold(STALL_GUARD_THRESHOLD1);
stepper_driver_1.enableAutomaticCurrentScaling();
stepper_driver_1.enableCoolStep(0, 2);
stepper_driver_1.enable();
stepper_driver_1.setHoldCurrent(2);
// FlexyStepper setup
stepperX.connectToPins(MOTOR_X_STEP_PIN, MOTOR_X_DIRECTION_PIN);
stepperY.connectToPins(MOTOR_Y_STEP_PIN, MOTOR_Y_DIRECTION_PIN);
stepper_driver_0.disableInverseMotorDirection();
stepper_driver_1.enableInverseMotorDirection();
// CRÍTICO: alinear el contador lógico con la posición física real DESPUÉS de
// toda la inicialización de hardware (enable/microsteps pueden mover los motores).
// Esto evita que el primer moveToPositionInSteps() tras un reinicio viaje al origen.
setCurrentPosition(_posX, _posY);
}
void MotorDriver::setXY() {
ScopedMutexGuard g(_motionMutex);
stepper_driver_0.setMicrostepsPerStepPowerOfTwo(3);
long time = millis();
uint16_t contador = 0;
uint32_t s = stepper_driver_0.getStallGuardResult();
uint32_t resultado = 500;
stepper_driver_0.moveAtVelocity(VELOCITY0);
stepper_driver_0.disableInverseMotorDirection();
delay(100);
while (millis() - time < 45500 && resultado > 180) {
s += stepper_driver_0.getStallGuardResult();
contador++;
if (contador == 272) {
resultado = s / 272;
s = 0;
contador = 0;
#if VERBO
Serial.print("stall_guard_result = ");
Serial.println(resultado);
#endif
}
}
stepper_driver_0.moveAtVelocity(0);
stepper_driver_0.setStandstillMode(stepper_driver_0.NORMAL);
stepper_driver_1.moveAtVelocity(VELOCITY1);
stepper_driver_1.enableInverseMotorDirection();
delay(100);
time = millis();
contador = 0;
s = stepper_driver_1.getStallGuardResult();
resultado = 500;
while (millis() - time < 45000 && resultado > 185) {
s += stepper_driver_1.getStallGuardResult();
contador++;
if (contador == 272) {
resultado = s / 272;
s = 0;
contador = 0;
#if VERBO
Serial.print("stall_guard_motorY = ");
Serial.println(resultado);
#endif
}
}
#if VERBO
Serial.print("stall_guard_result-- = ");
Serial.println(resultado);
Serial.println("MOTOR Y SET");
#endif
stepper_driver_1.moveAtVelocity(0);
stepper_driver_1.setStandstillMode(stepper_driver_1.NORMAL);
stepperX.setCurrentPositionInSteps(0);
stepperY.setCurrentPositionInSteps(0);
stepper_driver_0.setMicrostepsPerStepPowerOfTwo(3);
stepper_driver_1.setMicrostepsPerStepPowerOfTwo(3);
stepperX.setSpeedInStepsPerSecond(MOTOR_MAX_SPEED);
stepperX.setAccelerationInStepsPerSecondPerSecond(MOTOR_ACCELERATION);
stepperY.setSpeedInStepsPerSecond(MOTOR_MAX_SPEED);
stepperY.setAccelerationInStepsPerSecondPerSecond(MOTOR_ACCELERATION);
_posX = 0;
_posY = 0;
commitXY(); // El origen (0,0) debe quedar persistido tras el homing
sendPositionUpdate();
}
void MotorDriver::moveTo(int32_t targetX, int32_t targetY) {
ScopedMutexGuard g(_motionMutex);
if (targetX >= 0) targetX = 0;
if (targetX <= LIMITX) targetX = LIMITX;
if (targetY >= 0) targetY = 0;
if (targetY <= LIMITY) targetY = LIMITY;
_posX = targetX;
_posY = targetY;
stepperX.moveToPositionInSteps(_posX);
stepperY.moveToPositionInSteps(_posY);
waitMotionComplete();
sendPositionUpdate();
}
void MotorDriver::setCurrentPosition(int32_t x, int32_t y) {
ScopedMutexGuard g(_motionMutex);
_posX = x;
_posY = y;
stepperX.setCurrentPositionInSteps(_posX);
stepperY.setCurrentPositionInSteps(_posY);
stepper_driver_0.setMicrostepsPerStepPowerOfTwo(3);
stepper_driver_1.setMicrostepsPerStepPowerOfTwo(3);
stepperX.setSpeedInStepsPerSecond(4200);
stepperX.setAccelerationInStepsPerSecondPerSecond(4000);
stepperY.setSpeedInStepsPerSecond(4200);
stepperY.setAccelerationInStepsPerSecondPerSecond(4000);
sendPositionUpdate();
}
void MotorDriver::waitMotionComplete() {
bool moving = true;
while (moving) {
stepperX.processMovement();
stepperY.processMovement();
moving = !stepperX.motionComplete() || !stepperY.motionComplete();
delay(1);
}
}
void MotorDriver::maybePersistXY() {
unsigned long now = millis();
if (now - _lastNvsCheckMs < NVS_CHECK_PERIOD_MS) return;
_lastNvsCheckMs = now;
if (!_nvsEnabled) return;
int32_t x = _posX;
int32_t y = _posY;
if (abs(x - _lastSavedX) >= NVS_THRESHOLD) {
_prefs.putInt("x", x);
_lastSavedX = x;
#if VERBO
Serial.printf("[NVS] Guardado X=%ld\n", (long)x);
#endif
}
if (abs(y - _lastSavedY) >= NVS_THRESHOLD) {
_prefs.putInt("y", y);
_lastSavedY = y;
#if VERBO
Serial.printf("[NVS] Guardado Y=%ld\n", (long)y);
#endif
}
}
// Guardado inmediato de la posición actual sin esperar el umbral de cambio.
// Se usa tras homing, setXY o cualquier re-sincronización para que el arranque
// siguiente restaure exactamente donde se quedó.
void MotorDriver::commitXY() {
int32_t x = _posX;
int32_t y = _posY;
_prefs.putInt("x", x);
_prefs.putInt("y", y);
_lastSavedX = x;
_lastSavedY = y;
#if VERBO
Serial.printf("[NVS] commit X=%ld Y=%ld\n", (long)x, (long)y);
#endif
}
void MotorDriver::setNvsEnabled(bool enabled) {
_nvsEnabled = enabled;
nvsEnabled = enabled;
_prefs.putBool("nvsEn", enabled);
if (enabled) {
// Persistir estado actual inmediatamente al reactivar el autosave
commitXY();
} else {
// Comportamiento anterior: borrar registro de coordenadas al desactivar
_prefs.remove("x");
_prefs.remove("y");
_lastSavedX = _posX;
_lastSavedY = _posY;
}
agregarLog(String("Autosave NVS: ") + (enabled ? "ACTIVADO" : "DESACTIVADO"));
}
// --- Acceso NVS para parámetros de configuración (namespace "params") ---
void MotorDriver::nvsPutInt(const char* key, int32_t value) {
_prefs.putInt(key, value);
}
int32_t MotorDriver::nvsGetInt(const char* key, int32_t defaultValue) {
return _prefs.getInt(key, defaultValue);
}
void MotorDriver::nvsPutFloat(const char* key, float value) {
_prefs.putFloat(key, value);
}
float MotorDriver::nvsGetFloat(const char* key, float defaultValue) {
return _prefs.getFloat(key, defaultValue);
}
void MotorDriver::nvsPutBool(const char* key, bool value) {
_prefs.putBool(key, value);
}
bool MotorDriver::nvsGetBool(const char* key, bool defaultValue) {
return _prefs.getBool(key, defaultValue);
}
void MotorDriver::sendPositionUpdate() {
String posMsg = "POS:" + String(-_posX) + ":" + String(-_posY);
// Transmitir via log manager y cloud client
extern void broadcastPosition(const String& msg);
broadcastPosition(posMsg);
extern void forwardPositionToCloud(int32_t x, int32_t y);
forwardPositionToCloud(_posX, _posY);
}
void MotorDriver::printTMCStatus() {
TMC2209::Settings s0 = stepper_driver_0.getSettings();
char b0[150];
snprintf(b0, sizeof(b0), "Driver0: comm=%d, setup=%d, en=%d, uSteps=%d, irun=%d",
s0.is_communicating, s0.is_setup, s0.software_enabled, s0.microsteps_per_step, s0.irun_percent);
agregarLog(b0);
TMC2209::Settings s1 = stepper_driver_1.getSettings();
char b1[150];
snprintf(b1, sizeof(b1), "Driver1: comm=%d, setup=%d, en=%d, uSteps=%d, irun=%d",
s1.is_communicating, s1.is_setup, s1.software_enabled, s1.microsteps_per_step, s1.irun_percent);
agregarLog(b1);
}
// Global functions
void setXY() {
MotorDriver::instance().setXY();
}
void sendPositionUpdate() {
MotorDriver::instance().sendPositionUpdate();
}
void maybePersistXY() {
MotorDriver::instance().maybePersistXY();
}
@@ -0,0 +1,88 @@
#pragma once
#include <Arduino.h>
#include <TMC2209.h>
#include <FlexyStepper.h>
#include <Preferences.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include "config.h"
// ---------------------------------------------------------------------------
// Objetos de hardware: INSTANCIA ÚNICA (definidos en motor_driver.cpp).
// Antes existían por duplicado (miembros privados de MotorDriver + estos
// globales); solo los globales estaban conectados a pines/UART, por lo que
// los miembros eran copias muertas que podían inducir a error. Ahora hay un
// solo objeto por driver y los accesores devuelven estos globales.
// ---------------------------------------------------------------------------
extern FlexyStepper stepperX;
extern FlexyStepper stepperY;
extern TMC2209 stepper_driver_0;
extern TMC2209 stepper_driver_1;
// Variables globales para compatibilidad directa
extern bool nvsEnabled;
class MotorDriver {
public:
static MotorDriver& instance();
void init();
void setXY(); // Procedimiento de homing StallGuard
void moveTo(int32_t targetX, int32_t targetY);
void setCurrentPosition(int32_t x, int32_t y);
void moveSteps(int32_t deltaX, int32_t deltaY);
void waitMotionComplete();
// --- Mutex de movimiento -------------------------------------------------
// Serializa el acceso a los steppers entre el bucle principal (fine-tune)
// y los comandos atendidos por web/cloud. Toda maniobra que mueva motores
// debe ejecutarse dentro de un guard() (RAII: libera al salir del alcance).
ScopedMutexGuard guard() { return ScopedMutexGuard(_motionMutex); }
void maybePersistXY();
void commitXY(); // Guardado inmediato de X/Y sin esperar umbral
void setNvsEnabled(bool enabled); // Persiste flag y fuerza guardado/inmediate commit
bool isNvsEnabled() const { return _nvsEnabled; }
// Acceso NVS para parámetros de configuración (namespace "params")
void nvsPutInt(const char* key, int32_t value);
int32_t nvsGetInt(const char* key, int32_t defaultValue);
void nvsPutFloat(const char* key, float value);
float nvsGetFloat(const char* key, float defaultValue);
void nvsPutBool(const char* key, bool value);
bool nvsGetBool(const char* key, bool defaultValue);
int32_t getPosX() const { return _posX; }
int32_t getPosY() const { return _posY; }
void setPosX(int32_t x) { _posX = x; }
void setPosY(int32_t y) { _posY = y; }
void sendPositionUpdate();
void printTMCStatus();
// Accesores a los objetos únicos de hardware
FlexyStepper& getStepperX() { return stepperX; }
FlexyStepper& getStepperY() { return stepperY; }
TMC2209& getDriver0() { return stepper_driver_0; }
TMC2209& getDriver1() { return stepper_driver_1; }
private:
MotorDriver() = default;
Preferences _prefs;
volatile int32_t _posX = 0;
volatile int32_t _posY = 0;
volatile int32_t _lastSavedX = 0;
volatile int32_t _lastSavedY = 0;
unsigned long _lastNvsCheckMs = 0;
bool _nvsEnabled = true;
// Protege los objetos stepperX/stepperY y el estado de posición lógica.
SemaphoreHandle_t _motionMutex = nullptr;
};
void setXY();
void sendPositionUpdate();
void maybePersistXY();
@@ -0,0 +1,232 @@
#include "solar_tracker.h"
#include "config.h"
#include "log_manager.h"
#include "motor_driver.h"
#include "adc_manager.h"
#include "tuning_engine.h"
#include <math.h>
#include <time.h>
volatile double Az_deviation = 44.23;
volatile double tilt_angle = 23.229;
extern volatile uint16_t tracker_status; // forward ref
static void calculateMotorTargets(double az_corr, double alt_corr, double az_deviation,
int32_t& targetX, int32_t& targetY) {
// Hub and motor commands use X for azimuth and Y for elevation.
double az_steps = (az_corr - 180.0) / 80.0 * 53000.0 + 53000.0;
double alt_steps = (90.0 - alt_corr) / 80.0 * 53000.0 + 53000.0
+ (az_deviation / 90.0) * az_steps;
targetX = constrain(-(int32_t)round(az_steps), LIMITX, 0);
targetY = constrain(-(int32_t)round(alt_steps), LIMITY, 0);
}
SolarTracker& SolarTracker::instance() {
static SolarTracker inst;
return inst;
}
void SolarTracker::trackSunByTime() {
tracker_status = 3;
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) {
agregarLog("Error al obtener hora NTP");
tracker_status = 0;
return;
}
char time_buff[120];
snprintf(time_buff, sizeof(time_buff),
"Timeinfo: year=%d, mon=%d, day=%d, hour=%d, min=%d, sec=%d",
timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday,
timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec);
agregarLog(time_buff);
int year = timeinfo.tm_year + 1900;
int month = timeinfo.tm_mon + 1;
int day = timeinfo.tm_mday;
int hour = timeinfo.tm_hour;
int minute = timeinfo.tm_min;
int second = timeinfo.tm_sec;
int a = (14 - month) / 12;
int y = year + 4800 - a;
int m = month + 12 * a - 3;
double jd0 = day + floor((153 * m + 2) / 5.0) + 365.0 * y + floor(y / 4.0) - floor(y / 100.0) + floor(y / 400.0) - 32045.5;
// local -> UTC (Aguascalientes UTC-6)
double ut_hour = hour + minute / 60.0 + second / 3600.0 + 6.0;
double jdn = jd0 + ut_hour / 24.0;
char ut_buff[60];
snprintf(ut_buff, sizeof(ut_buff), "UT_hour: %.6f", ut_hour);
agregarLog(ut_buff);
double t = (jdn - 2451545.0) / 36525.0;
double mean_anomaly = 357.5291 + 35999.0503 * t - 0.0001537 * t * t;
mean_anomaly = fmod(mean_anomaly, 360.0) * M_PI / 180.0;
double C = 1.914602 * sin(mean_anomaly) + 0.019993 * sin(2.0 * mean_anomaly) + 0.000289 * sin(3.0 * mean_anomaly);
double L = 280.46646 + 36000.76983 * t + 0.0003032 * t * t;
L = fmod(L, 360.0) * M_PI / 180.0;
double ecliptic_long = L + C * M_PI / 180.0;
double epsilon = (23.4392911 - 0.0130042 * t - 0.000000163 * t * t + 0.0000005036 * t * t * t) * M_PI / 180.0;
double declination = asin(sin(epsilon) * sin(ecliptic_long));
double ra = atan2(cos(epsilon) * sin(ecliptic_long), cos(ecliptic_long));
if (ra < 0) ra += 2 * M_PI;
double D = jdn - 2451545.0;
double gmst_hours = 6.697374558 + 0.06570982441908 * D + ut_hour * 1.00273790935;
gmst_hours = fmod(gmst_hours, 24.0);
if (gmst_hours < 0) gmst_hours += 24.0;
double gmst = gmst_hours * 15.0 * M_PI / 180.0;
double lon_rad = SITE_LONGITUDE * M_PI / 180.0;
double lst = gmst + lon_rad;
lst = fmod(lst, 2.0 * M_PI);
if (lst < 0) lst += 2.0 * M_PI;
double ha = lst - ra;
if (ha < -M_PI) ha += 2 * M_PI;
if (ha > M_PI) ha -= 2 * M_PI;
double phi = SITE_LATITUDE * M_PI / 180.0;
double sin_alt = sin(phi) * sin(declination) + cos(phi) * cos(declination) * cos(ha);
sin_alt = constrain(sin_alt, -1.0, 1.0);
double altitude_rad = asin(sin_alt);
double altitude = altitude_rad * 180.0 / M_PI;
double cos_alt = cos(altitude_rad);
if (fabs(cos_alt) < 1e-12) cos_alt = (cos_alt >= 0) ? 1e-12 : -1e-12;
double sin_az = -cos(declination) * sin(ha) / cos_alt;
double cos_az = (sin(declination) - sin(phi) * sin_alt) / (cos(phi) * cos_alt);
double azimuth = atan2(sin_az, cos_az) * 180.0 / M_PI;
if (azimuth < 0) azimuth += 360.0;
double az_corr = azimuth - _azDeviation;
double alt_corr = altitude + _tiltAngle;
char debug_buff[200];
snprintf(debug_buff, sizeof(debug_buff),
"Debug: jdn=%.3f, t=%.6f, ra=%.3f, ha=%.3f, decl=%.3f, sin_alt=%.3f, gmst=%.3f, lst=%.3f, alt=%.3f",
jdn, t, ra * 180.0 / M_PI, ha * 180.0 / M_PI, declination * 180.0 / M_PI,
sin_alt, gmst * 180.0 / M_PI, lst * 180.0 / M_PI, altitude);
agregarLog(debug_buff);
int32_t target_posX;
int32_t target_posY;
calculateMotorTargets(az_corr, alt_corr, _azDeviation, target_posX, target_posY);
MotorDriver::instance().moveTo(target_posX, target_posY);
char buff[120];
snprintf(buff, sizeof(buff), "SunPos: Az=%.1f, Alt=%.1f, Az_corr=%.1f, Alt_corr=%.1f, posX=%d, posY=%d",
azimuth, altitude, az_corr, alt_corr, -target_posX, -target_posY);
agregarLog(buff);
// Fine-tune tras movimiento astronómico
int32_t currentValue = current(AdcManager::instance().getRaw(1));
TuningEngine::instance().nomegusta(-currentValue, TuningEngine::instance().getErrMax(), (float)AdcManager::instance().getIrradianceWm2());
MotorDriver::instance().sendPositionUpdate();
}
// F5: Re-alineación astronómica periódica
// Calcula la posición solar ideal y compara con la posición actual.
// Si la deriva supera maxDriftSteps, mueve directamente al ideal.
// No cambia tracker_status ni llama a nomegusta (solo corrección de deriva).
bool SolarTracker::checkDrift(int32_t maxDriftSteps) {
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) {
return false;
}
int year = timeinfo.tm_year + 1900;
int month = timeinfo.tm_mon + 1;
int day = timeinfo.tm_mday;
int hour = timeinfo.tm_hour;
int minute = timeinfo.tm_min;
int second = timeinfo.tm_sec;
int a = (14 - month) / 12;
int y = year + 4800 - a;
int m = month + 12 * a - 3;
double jd0 = day + floor((153 * m + 2) / 5.0) + 365.0 * y + floor(y / 4.0) - floor(y / 100.0) + floor(y / 400.0) - 32045.5;
double ut_hour = hour + minute / 60.0 + second / 3600.0 + 6.0;
double jdn = jd0 + ut_hour / 24.0;
double t = (jdn - 2451545.0) / 36525.0;
double mean_anomaly = 357.5291 + 35999.0503 * t - 0.0001537 * t * t;
mean_anomaly = fmod(mean_anomaly, 360.0) * M_PI / 180.0;
double C = 1.914602 * sin(mean_anomaly) + 0.019993 * sin(2.0 * mean_anomaly) + 0.000289 * sin(3.0 * mean_anomaly);
double L = 280.46646 + 36000.76983 * t + 0.0003032 * t * t;
L = fmod(L, 360.0) * M_PI / 180.0;
double ecliptic_long = L + C * M_PI / 180.0;
double epsilon = (23.4392911 - 0.0130042 * t - 0.000000163 * t * t + 0.0000005036 * t * t * t) * M_PI / 180.0;
double declination = asin(sin(epsilon) * sin(ecliptic_long));
double ra = atan2(cos(epsilon) * sin(ecliptic_long), cos(ecliptic_long));
if (ra < 0) ra += 2 * M_PI;
double D = jdn - 2451545.0;
double gmst_hours = 6.697374558 + 0.06570982441908 * D + ut_hour * 1.00273790935;
gmst_hours = fmod(gmst_hours, 24.0);
if (gmst_hours < 0) gmst_hours += 24.0;
double gmst = gmst_hours * 15.0 * M_PI / 180.0;
double lon_rad = SITE_LONGITUDE * M_PI / 180.0;
double lst = gmst + lon_rad;
lst = fmod(lst, 2.0 * M_PI);
if (lst < 0) lst += 2.0 * M_PI;
double ha = lst - ra;
if (ha < -M_PI) ha += 2 * M_PI;
if (ha > M_PI) ha -= 2 * M_PI;
double phi = SITE_LATITUDE * M_PI / 180.0;
double sin_alt = sin(phi) * sin(declination) + cos(phi) * cos(declination) * cos(ha);
sin_alt = constrain(sin_alt, -1.0, 1.0);
double altitude_rad = asin(sin_alt);
double altitude = altitude_rad * 180.0 / M_PI;
double cos_alt = cos(altitude_rad);
if (fabs(cos_alt) < 1e-12) cos_alt = (cos_alt >= 0) ? 1e-12 : -1e-12;
double sin_az = -cos(declination) * sin(ha) / cos_alt;
double cos_az = (sin(declination) - sin(phi) * sin_alt) / (cos(phi) * cos_alt);
double azimuth = atan2(sin_az, cos_az) * 180.0 / M_PI;
if (azimuth < 0) azimuth += 360.0;
double az_corr = azimuth - _azDeviation;
double alt_corr = altitude + _tiltAngle;
int32_t target_posX;
int32_t target_posY;
calculateMotorTargets(az_corr, alt_corr, _azDeviation, target_posX, target_posY);
int32_t curX = MotorDriver::instance().getPosX();
int32_t curY = MotorDriver::instance().getPosY();
int32_t driftX = abs(target_posX - curX);
int32_t driftY = abs(target_posY - curY);
int32_t drift = (driftX > driftY) ? driftX : driftY;
if (drift > maxDriftSteps) {
char b[200];
snprintf(b, sizeof(b),
"F5: Deriva detectada %d pasos (dX=%d, dY=%d), re-alineando a (%d, %d)",
drift, driftX, driftY, target_posX, target_posY);
agregarLog(b);
MotorDriver::instance().moveTo(target_posX, target_posY);
MotorDriver::instance().sendPositionUpdate();
return true;
}
return false;
}
void trackSunByTime() {
SolarTracker::instance().trackSunByTime();
}
@@ -0,0 +1,31 @@
#pragma once
#include <Arduino.h>
class SolarTracker {
public:
static SolarTracker& instance();
void trackSunByTime();
// F5: Calcula posición astronómica sin mover motores ni cambiar tracker_status.
// Devuelve true si la posición actual se desvió más de maxDriftSteps del ideal.
bool checkDrift(int32_t maxDriftSteps);
// Parámetros de calibración angular
double getAzDeviation() const { return _azDeviation; }
void setAzDeviation(double az) { _azDeviation = az; }
double getTiltAngle() const { return _tiltAngle; }
void setTiltAngle(double tilt) { _tiltAngle = tilt; }
private:
SolarTracker() = default;
volatile double _azDeviation = 44.23;
volatile double _tiltAngle = 23.229;
};
extern volatile double Az_deviation;
extern volatile double tilt_angle;
void trackSunByTime();
@@ -0,0 +1,592 @@
#include "tuning_engine.h"
#include "config.h"
#include "log_manager.h"
#include "motor_driver.h"
#include <algorithm>
#include <cmath>
// Variables Globales Optimizadas
// F3: errmax reducido 1000->500, cicleMAX 2->5 para menos oscilación cerca del pico
volatile float LPF_Beta = 0.70f; // LPF activo: filtra deriva térmica (0=bypass, >=1=bypass)
volatile int32_t errmax = 500; // Paso máximo de búsqueda (F3: 1000->500)
volatile int32_t delayyy = 2; // Periodo de muestreo en segundos
volatile int32_t min_errmax = 100; // Resolución mínima cerca del pico (F3: 193->100)
volatile int32_t abrupt_threshold = 15000; // Cambio moderado (borde de nube) -> reducir paso
volatile int32_t small_threshold = 4500; // Zona muerta para ruido
volatile int32_t reset_threshold = 25000; // Cambio grande (nube) -> re-baseline Cmax
volatile float peak_proximity = 0.30f; // Tolerancia al pico (30%): absorbe deriva térmica
volatile int32_t cicleMAX = 5; // 5 ciclos por eje (F3: 2->5, menos oscilación)
volatile bool at_auto_apply = false; // Mantener deshabilitado en pruebas fijas
TuningEngine& TuningEngine::instance() {
static TuningEngine inst;
return inst;
}
// --- Persistencia NVS de parámetros (namespace "params" abierto por MotorDriver) ---
void TuningEngine::persistParam(const char* key, int32_t value) {
MotorDriver::instance().nvsPutInt(key, value);
}
void TuningEngine::persistFloatParam(const char* key, float value) {
MotorDriver::instance().nvsPutFloat(key, value);
}
void TuningEngine::persistBoolParam(const char* key, bool value) {
MotorDriver::instance().nvsPutBool(key, value);
}
// Restaurar parámetros guardados en NVS y sincronizar las variables globales legacy.
// Se llama desde MotorDriver::init() tras abrir el namespace "coords".
void TuningEngine::loadFromNVS() {
_lpfBeta = MotorDriver::instance().nvsGetFloat("lpfbeta", _lpfBeta);
_errmax = MotorDriver::instance().nvsGetInt("errmax", _errmax);
_delaySeconds = MotorDriver::instance().nvsGetInt("delayyy", _delaySeconds);
_minErrmax = MotorDriver::instance().nvsGetInt("minerrmax", _minErrmax);
_abruptThreshold = MotorDriver::instance().nvsGetInt("abrupty", _abruptThreshold);
_smallThreshold = MotorDriver::instance().nvsGetInt("smaillt", _smallThreshold);
_resetThreshold = MotorDriver::instance().nvsGetInt("resett", _resetThreshold);
_peakProximity = MotorDriver::instance().nvsGetFloat("peakpx", _peakProximity);
const int32_t savedBase = MotorDriver::instance().nvsGetInt("ciclemx", 5);
const int32_t savedX = MotorDriver::instance().nvsGetInt("axisxf", 3);
const int32_t savedY = MotorDriver::instance().nvsGetInt("axisyf", 1);
_cicleMax = savedBase;
_axisXFactor = savedX;
_axisYFactor = savedY;
bool corrected = false;
if (_cicleMax < 1 || _cicleMax > 64) {
_cicleMax = 5;
corrected = true;
}
if (!isAxisConfigValid()) {
_axisXFactor = 1;
_axisYFactor = 1;
corrected = true;
}
_axisXCount = _axisYCount = _cicles = 0;
_xActive = true;
_minIrradiance = MotorDriver::instance().nvsGetFloat("minirr", _minIrradiance);
_autoApply = MotorDriver::instance().nvsGetBool("autoaply", _autoApply);
// Sincronizar globales legacy para que UI/logs muestren los valores restaurados
LPF_Beta = _lpfBeta;
errmax = _errmax;
delayyy = _delaySeconds;
min_errmax = _minErrmax;
abrupt_threshold = _abruptThreshold;
small_threshold = _smallThreshold;
reset_threshold = _resetThreshold;
peak_proximity = _peakProximity;
cicleMAX = _cicleMax;
at_auto_apply = _autoApply;
char msg[170];
snprintf(msg, sizeof(msg),
"NVS params: lpfbeta=%.2f errmax=%d delay=%d minerr=%d abrt=%d smll=%d rst=%d peak=%.2f ccl=%d minirr=%.0f auto=%d",
_lpfBeta, _errmax, _delaySeconds, _minErrmax, _abruptThreshold,
_smallThreshold, _resetThreshold, _peakProximity, _cicleMax, _minIrradiance, (int)_autoApply);
agregarLog(msg);
if (corrected) {
agregarLog("Invalid NVS axis schedule sanitized to a valid base and/or 1:1 factors");
persistParam("ciclemx", _cicleMax);
persistParam("axisxf", _axisXFactor);
persistParam("axisyf", _axisYFactor);
}
reportAxisConfig();
}
bool TuningEngine::isAxisConfigValid() const {
return _cicleMax >= 1 && _cicleMax <= 64 &&
_axisXFactor >= 1 && _axisXFactor <= 8 &&
_axisYFactor >= 1 && _axisYFactor <= 8 &&
_cicleMax <= 64 / _axisXFactor && _cicleMax <= 64 / _axisYFactor;
}
void TuningEngine::reportAxisConfig() const {
if (!isAxisConfigValid()) return;
char report[48];
snprintf(report, sizeof(report), "AXISCFG:%d:%d:%d", _axisXFactor, _axisYFactor, _cicleMax);
agregarLog(report);
}
bool TuningEngine::setCicleMax(int32_t value) {
if (value < 1 || value > 64 || _axisXFactor < 1 || _axisXFactor > 8 ||
_axisYFactor < 1 || _axisYFactor > 8 || value > 64 / _axisXFactor || value > 64 / _axisYFactor) return false;
_cicleMax = value;
_axisXCount = _axisYCount = _cicles = 0;
_xActive = true;
persistParam("ciclemx", value);
reportAxisConfig();
return true;
}
bool TuningEngine::setAxisXFactor(int32_t value) {
if (value < 1 || value > 8 || _cicleMax < 1 || _cicleMax > 64 || value > 64 / _cicleMax || _axisYFactor < 1 || _cicleMax > 64 / _axisYFactor) return false;
_axisXFactor = value; _axisXCount = _axisYCount = _cicles = 0; _xActive = true;
persistParam("axisxf", value); reportAxisConfig(); return true;
}
bool TuningEngine::setAxisYFactor(int32_t value) {
if (value < 1 || value > 8 || _cicleMax < 1 || _cicleMax > 64 || value > 64 / _cicleMax || _axisXFactor < 1 || _cicleMax > 64 / _axisXFactor) return false;
_axisYFactor = value; _axisXCount = _axisYCount = _cicles = 0; _xActive = true;
persistParam("axisyf", value); reportAxisConfig(); return true;
}
static inline int at_index(int k) {
return (k + AT_CAP) % AT_CAP;
}
template<typename T>
static float quantile_copy(const T* arr, int n, float q) {
static T tmp[AT_CAP];
if (n <= 0) return 0.0f;
for (int i = 0; i < n; i++) tmp[i] = arr[i];
for (int i = 1; i < n; i++) { // insertion sort
T key = tmp[i];
int j = i - 1;
while (j >= 0 && tmp[j] > key) {
tmp[j + 1] = tmp[j];
j--;
}
tmp[j + 1] = key;
}
float idx = (n - 1) * q;
int i0 = (int)idx;
int i1 = std::min(i0 + 1, n - 1);
float frac = idx - i0;
return tmp[i0] * (1.0f - frac) + tmp[i1] * frac;
}
void TuningEngine::pushSample(int32_t Cval, int32_t Ival, uint32_t now_ms) {
_atb.head = (_atb.head + 1) % AT_CAP;
_atb.C[_atb.head] = Cval;
_atb.I[_atb.head] = Ival;
_atb.t[_atb.head] = now_ms;
if (_atb.count < AT_CAP) _atb.count++;
}
void TuningEngine::recommendThresholds(bool applyNow) {
if (_atb.count < 20) {
agregarLog("AT: pocos datos (" + String(_atb.count) + "), espera más muestras");
return;
}
const int n = _atb.count;
static int32_t abs_dC[AT_CAP];
static int32_t dI[AT_CAP];
int m = 0;
int64_t sumI = 0, maxC = 0;
for (int k = 1; k < n; ++k) {
int i1 = at_index(_atb.head - (n - 1 - k));
int i0 = at_index(_atb.head - (n - 1 - (k - 1)));
int32_t dC = _atb.C[i1] - _atb.C[i0];
int32_t dIv = _atb.I[i1] - _atb.I[i0];
abs_dC[m] = abs(dC);
dI[m] = dIv;
m++;
}
for (int k = 0; k < n; ++k) {
int idx = at_index(_atb.head - (n - 1 - k));
sumI += _atb.I[idx];
if (llabs((long long)_atb.C[idx]) > maxC) maxC = llabs((long long)_atb.C[idx]);
}
float meanI = (float)sumI / (float)n;
// Zona estable por irradiancia
const float thrI = std::max(25.0f, 0.03f * meanI);
static int32_t abs_dC_stable[AT_CAP];
int ms = 0;
for (int i = 0; i < m; ++i) {
if (fabs((float)dI[i]) <= thrI) abs_dC_stable[ms++] = abs_dC[i];
}
if (ms < 5) {
for (int i = 0; i < m && i < 10; ++i) abs_dC_stable[i] = abs_dC[i];
ms = std::min(m, 10);
}
int32_t q_small = (int32_t)lroundf(quantile_copy(abs_dC_stable, ms, 0.70f));
int32_t q_abrupt = (int32_t)lroundf(quantile_copy(abs_dC, m, 0.90f));
int32_t q_reset = (int32_t)lroundf(quantile_copy(abs_dC, m, 0.99f));
int32_t rec_small = std::max<int32_t>(5, q_small);
int32_t rec_abrupt = std::max<int32_t>(rec_small * 3, q_abrupt);
int32_t rec_reset = std::max<int32_t>(rec_abrupt * 2, q_reset);
float rec_peak = 0.05f;
if (maxC > 0) {
rec_peak = 2.0f * ((float)rec_small / (float)maxC);
if (rec_peak < 0.01f) rec_peak = 0.01f;
if (rec_peak > 0.10f) rec_peak = 0.10f;
}
char msg[180];
snprintf(msg, sizeof(msg),
"AT: meanI=%.1f, q70(dC_estable)=%d, q90(dC)=%d, q99(dC)=%d, max|C|=%lld",
meanI, q_small, q_abrupt, q_reset, (long long)maxC);
agregarLog(msg);
snprintf(msg, sizeof(msg),
"AT: rec small=%d, abrupt=%d, reset=%d, peak_prox=%.3f",
rec_small, rec_abrupt, rec_reset, rec_peak);
agregarLog(msg);
if (applyNow) {
_smallThreshold = rec_small;
_abruptThreshold = rec_abrupt;
_resetThreshold = rec_reset;
_peakProximity = rec_peak;
small_threshold = rec_small;
abrupt_threshold = rec_abrupt;
reset_threshold = rec_reset;
peak_proximity = rec_peak;
snprintf(msg, sizeof(msg),
"AT: APLICADOS -> small=%d, abrupt=%d, reset=%d, peak=%.3f",
_smallThreshold, _abruptThreshold, _resetThreshold, _peakProximity);
agregarLog(msg);
}
}
void TuningEngine::checkAutoApply() {
if (_autoApply && (millis() - _lastApplyMs >= AT_PERIOD_MS)) {
recommendThresholds(true);
_lastApplyMs = millis();
}
}
void TuningEngine::nomegusta(int32_t value, int32_t e, float irradianceWm2) {
// --- F4: Filtro de picos transitorios ---
// Si el cambio brusco en C supera K*sqrt(varianza móvil), se descarta
// la muestra para no contaminar el LPF con ruido (picos de EMI, nubes, etc.)
float posSignal = (float)(-value);
{
int32_t dC_spike = (int32_t)(posSignal) - _spikeLastC;
if (_spikeInit) {
float varUpdate = 0.95f * _spikeVar + 0.05f * ((float)dC_spike * (float)dC_spike - _spikeVar);
if (varUpdate < 0.0f) varUpdate = 0.0f;
_spikeVar = varUpdate;
float spikeLimit = SPIKE_K * sqrtf(_spikeVar);
if (fabsf((float)dC_spike) > spikeLimit && spikeLimit > 50.0f) {
// Pico detectado: no actualizar LPF, solo actualizar varianza
_spikeLastC = (int32_t)posSignal;
// Keep the signed reference of the last accepted sample.
return;
}
} else {
_spikeInit = true;
_spikeVar = 0.0f;
}
_spikeLastC = (int32_t)posSignal;
}
// --- LPF: filtra deriva térmica lenta (guard: beta<=0 o >=1 = bypass) ---
if (_lpfBeta <= 0.0f || _lpfBeta >= 1.0f) {
_lpfValue = posSignal;
} else {
_lpfValue = _lpfBeta * _lpfValue + (1.0f - _lpfBeta) * posSignal;
}
int32_t lpfPos = (int32_t)_lpfValue; // C positivo filtrado (cuenta del ADC)
// ================================================================
// STANDBY POR BAJA LUZ (noche / nubes densas / sin sol)
// Si la irradiancia real esta por debajo del umbral, NO movemos
// motores: evita el "rumeo" nocturno y que se pierda el pico.
// Al reactivarse la luz se reinicia la referencia termica (CmaxN)
// para que el algoritmo vuelva a aprender el pico del dia.
// ================================================================
bool lightOk = (irradianceWm2 >= _minIrradiance);
if (!lightOk) {
if (!_lowLight) {
_lowLight = true;
_thermalInit = false; // re-aprender pico al amanecer
_cmaxNorm = 0;
_cicles = _axisXCount = _axisYCount = 0;
_xActive = true;
_dirX = 1;
_dirY = 1;
char b[140];
snprintf(b, sizeof(b),
"Standby: sin luz (irr=%.0f < %.0f W/m2), motores congelados",
irradianceWm2, _minIrradiance);
agregarLog(b);
}
_adcOld = -lpfPos;
return; // no mover motores
}
if (_lowLight) {
_lowLight = false;
char b[120];
snprintf(b, sizeof(b),
"Standby OFF: luz suficiente (irr=%.0f W/m2), reanudando fine-tune",
irradianceWm2);
agregarLog(b);
}
// --- Normalización térmica usando el piranómetro como referencia ---
// La celda CPV se calienta y su corriente cae aunque la irradiancia real no cambie.
// Dividimos C entre una baseline LENTA de irradiancia: si P está plano pero C cae,
// la razón normalizada NO cae -> el algoritmo no cree que "perdió el pico".
const float IRRAD_BETA = 0.98f; // baseline muy lenta (~minutos)
if (!_thermalInit) {
_irradBaseline = (irradianceWm2 > 0.0f) ? irradianceWm2 : 1.0f;
_cmaxNorm = 0; // se fija abajo con la primera razon normalizada
_thermalInit = true;
} else if (lightOk) {
_irradBaseline = IRRAD_BETA * _irradBaseline + (1.0f - IRRAD_BETA) * irradianceWm2;
}
if (_irradBaseline < 1.0f) _irradBaseline = 1.0f; // anti-división por cero
// Razón señal/irradiancia real (invariante térmica), en escala para precisión
float normRatio = ((float)lpfPos / _irradBaseline) * 1000.0f;
// --- F1: Reset periódico de CmaxN ---
// El máximo histórico crece sin tope (18M+ a las 17:00) y la métrica de
// proximity se queda pegada en ~0.88, impidiendo la rampa de pasos finos.
// Reset conditions:
// a) Cada CMAX_RESET_PERIOD_MS (10 min)
// b) Si la irradiancia cae por debajo del 70% del baseline (nube/paso de nube)
uint32_t nowMs = millis();
bool doCmaxReset = false;
if (_cmaxLastResetMs > 0 && (nowMs - _cmaxLastResetMs) >= CMAX_RESET_PERIOD_MS) {
doCmaxReset = true;
}
if (_irradBaseline > 10.0f && irradianceWm2 < (_irradBaseline * CMAX_IRRAD_DROP)) {
doCmaxReset = true;
}
if (doCmaxReset && _cmaxNorm > 0) {
int32_t oldCmax = _cmaxNorm;
_cmaxNorm = (int32_t)normRatio; // Re-baselinear con el valor actual
_cmaxLastResetMs = nowMs;
char b[160];
snprintf(b, sizeof(b),
"F1: CmaxN reset %d -> %d (irr=%.0f, baseline=%.0f)",
oldCmax, _cmaxNorm, irradianceWm2, _irradBaseline);
agregarLog(b);
}
if (_cmaxLastResetMs == 0) _cmaxLastResetMs = nowMs;
const bool newPeak = normRatio > (float)_cmaxNorm;
if (newPeak) _cmaxNorm = (int32_t)normRatio;
// Both values use negative current; negating only one would add magnitudes.
int32_t current_change = abs(value - _adcOld);
static int32_t dynamic_errmax = _errmax;
static bool near_peak = false;
static uint8_t escape_counter = 0;
// Distancia relativa al pico (sobre magnitud normalizada): 0 = en el pico
float proximity = 0.0f;
if (_cmaxNorm > 0) proximity = (float)(_cmaxNorm - (int32_t)normRatio) / (float)_cmaxNorm;
if (proximity < 0.0f) proximity = 0.0f;
// Existing NVS/remote settings may contain zero; never divide by that window.
const float peakWindow = std::isfinite(_peakProximity) && _peakProximity > 0.0f
? _peakProximity : 0.01f;
// Orden: max > near > RESET > ABRUPT > SMALL > default
// Reset antes que abrupt: cambios grandes de nube re-baselinean Cmax
if (newPeak) {
// Nuevo máximo -> cerca del pico, paso mínimo
near_peak = true;
dynamic_errmax = _minErrmax;
e = _minErrmax;
escape_counter = 0;
} else if (proximity <= peakWindow) {
// CERCA DEL PICO: rampa suave errmax(borde) -> min_errmax(pico)
near_peak = true;
float w = proximity / peakWindow; // 0 en el pico, 1 en el borde
if (w < 0.0f) w = 0.0f; else if (w > 1.0f) w = 1.0f;
int32_t ramped = _minErrmax + (int32_t)lroundf(w * (float)(_errmax - _minErrmax));
dynamic_errmax = ramped;
e = ramped; // sin guard que aplaste la rampa
escape_counter = 0;
} else if (current_change > _resetThreshold) {
near_peak = false;
dynamic_errmax = _errmax;
e = _errmax;
escape_counter = 0;
} else if (current_change > _abruptThreshold && _cicles > 0) {
near_peak = false;
dynamic_errmax = (dynamic_errmax * 60) / 100;
if (dynamic_errmax < _minErrmax) dynamic_errmax = _minErrmax;
e = dynamic_errmax;
escape_counter = 0;
} else if (current_change < _smallThreshold && _cicles > 0) {
near_peak = false;
escape_counter++;
// Let the existing escape below run after ten deadband samples.
if (escape_counter < 10) {
char log_buff[150];
snprintf(log_buff, sizeof(log_buff),
"FineTune: dC=%d, dynE=%d, e=%d, near=%d, CmaxN=%d, norm=%d, prox=%.2f",
current_change, dynamic_errmax, e, near_peak, _cmaxNorm, (int32_t)normRatio, proximity);
agregarLog(log_buff);
MotorDriver::instance().sendPositionUpdate();
_adcOld = -lpfPos;
return;
}
} else {
// Outside the near-peak ramp, keep its boundary value for reacquisition.
near_peak = false;
dynamic_errmax = _errmax;
e = dynamic_errmax;
escape_counter++;
}
if (escape_counter >= 10) {
near_peak = false;
dynamic_errmax = _errmax;
e = dynamic_errmax;
escape_counter = 0;
}
char log_buff[150];
snprintf(log_buff, sizeof(log_buff),
"FineTune: dC=%d, dynE=%d, e=%d, near=%d, CmaxN=%d, norm=%d, prox=%.2f",
current_change, dynamic_errmax, e, near_peak, _cmaxNorm, (int32_t)normRatio, proximity);
agregarLog(log_buff);
// --- Sección crítica de movimiento -------------------------------------
// Serializa el fine-tune (bucle principal) contra los comandos web/cloud
// que mueven los mismos steppers (Set Coords, homing, centro, demo).
// Guard RAII: libera el mutex al salir del alcance.
{
ScopedMutexGuard g(MotorDriver::instance().guard());
int32_t currentX = MotorDriver::instance().getPosX();
int32_t currentY = MotorDriver::instance().getPosY();
if (value < _adcOld || _cicles == 0) {
if (_cicles == 0 && value > _adcOld) {
if (_xActive) _dirY *= -1;
else _dirX *= -1;
}
if (_xActive) {
currentX += _dirX * e;
if (currentX >= 0) currentX = 0;
if (currentX <= LIMITX) currentX = LIMITX;
stepperX.moveToPositionInSteps(currentX);
MotorDriver::instance().setPosX(currentX);
} else {
currentY += _dirY * e;
if (currentY >= 0) currentY = 0;
if (currentY <= LIMITY) currentY = LIMITY;
stepperY.moveToPositionInSteps(currentY);
MotorDriver::instance().setPosY(currentY);
}
} else {
if (_xActive) {
_dirX *= -1;
currentX += _dirX * e;
if (currentX >= 0) currentX = 0;
if (currentX <= LIMITX) currentX = LIMITX;
stepperX.moveToPositionInSteps(currentX);
MotorDriver::instance().setPosX(currentX);
} else {
_dirY *= -1;
currentY += _dirY * e;
if (currentY >= 0) currentY = 0;
if (currentY <= LIMITY) currentY = LIMITY;
stepperY.moveToPositionInSteps(currentY);
MotorDriver::instance().setPosY(currentY);
}
}
MotorDriver::instance().waitMotionComplete();
// Actualizar estado interno con LPF (se adapta a deriva térmica)
_adcOld = -lpfPos;
_totalSteps += abs(e);
_cicles++;
if (_xActive) _axisXCount++; else _axisYCount++;
const int32_t xBudget = getAxisXBudget();
const int32_t yBudget = getAxisYBudget();
if (_xActive && (_axisXCount >= xBudget || currentX == 0 || currentX == LIMITX)) {
_xActive = false;
_cicles = 0;
} else if (!_xActive && (_axisYCount >= yBudget || currentY == 0 || currentY == LIMITY)) {
_axisXCount = _axisYCount = _cicles = 0;
_xActive = true;
dynamic_errmax = _errmax;
// No re-baselineamos _cmaxNorm aqui: se mantiene el maximo historico.
// La normalizacion termica ya protege contra la caida de C por temperatura.
near_peak = false;
escape_counter = 0;
}
} // el guard libera el mutex antes de difundir la posicion
MotorDriver::instance().sendPositionUpdate();
}
// --- Telemetría: acumular y reportar promedios periódicos ---
void TuningEngine::updateTelemetry(int32_t C, int32_t V, int32_t P, int32_t steps) {
_telSumC += C;
_telSumV += V;
_telSumP += P;
_telCount++;
_totalSteps += steps;
}
void TuningEngine::logTelemetry() {
uint32_t now = millis();
if (_lastTelMs == 0) {
_lastTelMs = now;
return;
}
if ((now - _lastTelMs) < TEL_PERIOD_MS) return;
if (_telCount > 0) {
int32_t avgC = (int32_t)(_telSumC / _telCount);
int32_t avgV = (int32_t)(_telSumV / _telCount);
int32_t avgP = (int32_t)(_telSumP / _telCount);
int32_t stepsDelta = _totalSteps - _stepsLastReport;
// F6: Telemetría mejorada con proximity y CmaxN para diagnóstico
float telProx = 0.0f;
if (_cmaxNorm > 0 && _irradBaseline > 0.0f) {
float telNorm = ((float)avgC / _irradBaseline) * 1000.0f;
telProx = (float)(_cmaxNorm - (int32_t)telNorm) / (float)_cmaxNorm;
if (telProx < 0.0f) telProx = 0.0f;
}
char buff[220];
snprintf(buff, sizeof(buff),
"TEL: t=%lus, steps=%d(+%d), avgC=%d, avgV=%d, avgP=%d, LPF=%d, prox=%.2f, CmaxN=%d, X=%d, Y=%d",
(unsigned long)(now / 1000),
_totalSteps, stepsDelta,
avgC, avgV, avgP,
(int32_t)_lpfValue,
telProx,
_cmaxNorm,
MotorDriver::instance().getPosX(),
MotorDriver::instance().getPosY());
agregarLog(buff);
_stepsLastReport = _totalSteps;
_telSumC = 0;
_telSumV = 0;
_telSumP = 0;
_telCount = 0;
_lastTelMs = now;
}
}
// Global functions
void nomegusta(int32_t varerr, int32_t error) {
TuningEngine::instance().nomegusta(varerr, error);
}
void nomegusta(int32_t varerr, int32_t error, float irradianceWm2) {
TuningEngine::instance().nomegusta(varerr, error, irradianceWm2);
}
void at_push(int32_t Cval, int32_t Ival, uint32_t now_ms) {
TuningEngine::instance().pushSample(Cval, Ival, now_ms);
}
void at_recommend_thresholds(bool applyNow) {
TuningEngine::instance().recommendThresholds(applyNow);
}
@@ -0,0 +1,169 @@
#pragma once
#include <Arduino.h>
#ifndef AT_CAP
#define AT_CAP 180 // ~3 min a 1 Hz
#endif
struct AutoTuneBuf {
int32_t C[AT_CAP]; // Isc (cuentas ADC)
int32_t I[AT_CAP]; // Irradiancia W/m2
uint32_t t[AT_CAP]; // millis
int head = -1;
int count = 0;
};
class TuningEngine {
public:
static TuningEngine& instance();
void nomegusta(int32_t value, int32_t e, float irradianceWm2 = 0.0f);
void pushSample(int32_t Cval, int32_t Ival, uint32_t now_ms);
void recommendThresholds(bool applyNow);
void checkAutoApply();
// Telemetría: acumular y reportar promedios periódicos
void updateTelemetry(int32_t C, int32_t V, int32_t P, int32_t steps);
void logTelemetry();
int32_t getTotalSteps() const { return _totalSteps; }
void loadFromNVS();
// Getters y Setters de parámetros
float getLPFBeta() const { return _lpfBeta; }
void setLPFBeta(float beta) { _lpfBeta = beta; persistFloatParam("lpfbeta", beta); }
float getLPFValue() const { return _lpfValue; }
int32_t getErrMax() const { return _errmax; }
void setErrMax(int32_t err) { _errmax = err; persistParam("errmax", err); }
int32_t getDelay() const { return _delaySeconds; }
void setDelay(int32_t d) { _delaySeconds = d; persistParam("delayyy", d); }
int32_t getMinErrMax() const { return _minErrmax; }
void setMinErrMax(int32_t minErr) { _minErrmax = minErr; persistParam("minerrmax", minErr); }
int32_t getAbruptThreshold() const { return _abruptThreshold; }
void setAbruptThreshold(int32_t abrupt) { _abruptThreshold = abrupt; persistParam("abrupty", abrupt); }
int32_t getSmallThreshold() const { return _smallThreshold; }
void setSmallThreshold(int32_t smallVal) { _smallThreshold = smallVal; persistParam("smaillt", smallVal); }
int32_t getResetThreshold() const { return _resetThreshold; }
void setResetThreshold(int32_t resetVal) { _resetThreshold = resetVal; persistParam("resett", resetVal); }
float getPeakProximity() const { return _peakProximity; }
void setPeakProximity(float prox) { _peakProximity = prox; persistFloatParam("peakpx", prox); }
int32_t getCicleMax() const { return _cicleMax; }
bool setCicleMax(int32_t c);
int32_t getAxisXFactor() const { return _axisXFactor; }
int32_t getAxisYFactor() const { return _axisYFactor; }
bool setAxisXFactor(int32_t value);
bool setAxisYFactor(int32_t value);
int32_t getAxisXBudget() const { return _cicleMax * _axisXFactor; }
int32_t getAxisYBudget() const { return _cicleMax * _axisYFactor; }
bool isAxisConfigValid() const;
void reportAxisConfig() const;
// Umbral de luz mínima (W/m2): por debajo el tracker entra en standby y NO mueve motores
float getMinIrradiance() const { return _minIrradiance; }
void setMinIrradiance(float wm2) { _minIrradiance = wm2; persistFloatParam("minirr", wm2); }
bool isLowLight() const { return _lowLight; }
bool getAutoApply() const { return _autoApply; }
void setAutoApply(bool apply) { _autoApply = apply; persistBoolParam("autoaply", apply); }
// --- Acceso de solo lectura para sincronizar globales legacy ---
float peekLPFBeta() const { return _lpfBeta; }
int32_t peekErrMax() const { return _errmax; }
int32_t peekDelay() const { return _delaySeconds; }
int32_t peekMinErrMax() const { return _minErrmax; }
int32_t peekAbruptThreshold()const { return _abruptThreshold; }
int32_t peekSmallThreshold() const { return _smallThreshold; }
int32_t peekResetThreshold() const { return _resetThreshold; }
float peekPeakProximity() const { return _peakProximity; }
int32_t peekCicleMax() const { return _cicleMax; }
float peekMinIrradiance() const { return _minIrradiance; }
private:
TuningEngine() = default;
// Persistencia de parámetros en NVS (namespace "params" abierto por MotorDriver)
void persistParam(const char* key, int32_t value);
void persistFloatParam(const char* key, float value);
void persistBoolParam(const char* key, bool value);
AutoTuneBuf _atb;
float _lpfBeta = 0.7f;
float _lpfValue = 0.0f; // Señal Isc filtrada (positiva)
int32_t _errmax = 500; // F3: 1000->500, pasos más finos
int32_t _delaySeconds = 2;
int32_t _minErrmax = 100; // F3: 193->100, resolución mínima más fina
int32_t _abruptThreshold = 15000;
int32_t _smallThreshold = 4500;
int32_t _resetThreshold = 25000;
float _peakProximity = 0.30f;
int32_t _cicleMax = 5; // F3: 2->5, menos oscilación
int32_t _axisXFactor = 3;
int32_t _axisYFactor = 1;
// --- Umbral de luz minima (standby nocturno / sin sol) ---
float _minIrradiance = 10.0f; // W/m2: por debajo se congelan los motores
bool _lowLight = false; // estado actual de standby
int32_t _cicles = 0;
int32_t _axisXCount = 0;
int32_t _axisYCount = 0;
int32_t _adcOld = 1000;
int8_t _dirX = 1;
int8_t _dirY = 1;
bool _xActive = true;
// --- Normalización térmica con piranómetro ---
float _irradBaseline = 0.0f; // P filtrado lento (irradiancia "esperada")
int32_t _cmaxNorm = 0; // Máximo histórico de C normalizado por irradiancia
bool _thermalInit = false; // baseline/pico aún no inicializados
uint32_t _cmaxLastResetMs = 0; // Timestamp del último reset de CmaxN
const uint32_t CMAX_RESET_PERIOD_MS = 600000UL; // 10 min: reset periódico
const float CMAX_IRRAD_DROP = 0.70f; // Si irradiancia cae <70% del baseline -> reset
// --- Filtro de picos transitorios (F4) ---
float _spikeVar = 0.0f; // Varianza móvil de dC
int32_t _spikeLastC = 0; // Última muestra de C para calcular dC
bool _spikeInit = false; // Primer sample aún no procesado
const float SPIKE_K = 3.0f; // Factor: |dC| > K*sqrt(var) -> descartar
bool _autoApply = false;
unsigned long _lastApplyMs = 0;
const unsigned long AT_PERIOD_MS = 60000UL;
// --- Telemetría ---
int32_t _totalSteps = 0;
int32_t _stepsLastReport = 0;
int64_t _telSumC = 0;
int64_t _telSumV = 0;
int64_t _telSumP = 0;
int32_t _telCount = 0;
uint32_t _lastTelMs = 0;
const uint32_t TEL_PERIOD_MS = 2000UL;
};
// Variables globales legacy
extern volatile float LPF_Beta;
extern volatile int32_t errmax;
extern volatile int32_t delayyy;
extern volatile int32_t min_errmax;
extern volatile int32_t abrupt_threshold;
extern volatile int32_t small_threshold;
extern volatile int32_t reset_threshold;
extern volatile float peak_proximity;
extern volatile int32_t cicleMAX;
extern volatile bool at_auto_apply;
void nomegusta(int32_t varerr, int32_t error);
void nomegusta(int32_t varerr, int32_t error, float irradianceWm2);
void at_push(int32_t Cval, int32_t Ival, uint32_t now_ms);
void at_recommend_thresholds(bool applyNow);
@@ -0,0 +1,415 @@
#include "web_server_manager.h"
#include "web_pages.h"
#include "log_manager.h"
#include "motor_driver.h"
#include "tuning_engine.h"
#include "solar_tracker.h"
#include "config.h"
#include <Update.h>
volatile uint16_t tracker_status = 0;
WebServerManager::WebServerManager() : _server(80), _webSocket(81) {}
WebServerManager& WebServerManager::instance() {
static WebServerManager inst;
return inst;
}
void WebServerManager::init() {
registerRoutes();
_server.begin();
_webSocket.begin();
_webSocket.onEvent([this](uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
if (type == WStype_CONNECTED) {
// Mandar los ultimos LOG_SEND_CHUNK bytes del log al nuevo cliente.
// snapshotTail toma el mutex, copia solo el tail y lo libera;
// el envio se hace fuera del lock para no retenerlo durante el sendTXT.
String tail = LogManager::instance().snapshotTail(LOG_SEND_CHUNK);
if (tail.length() > 0) {
_webSocket.sendTXT(num, "LOG:" + tail);
}
// Mandar posición actual
int32_t x = MotorDriver::instance().getPosX();
int32_t y = MotorDriver::instance().getPosY();
String posMsg = "POS:" + String(-x) + ":" + String(-y);
_webSocket.sendTXT(num, posMsg);
}
});
agregarLog("Servidor Web (puerto 80) y WebSocket (puerto 81) iniciados.");
}
void WebServerManager::loop() {
_server.handleClient();
_webSocket.loop();
}
void WebServerManager::broadcast(const String& msg) {
_webSocket.broadcastTXT(msg.c_str());
}
void broadcastPosition(const String& msg) {
WebServerManager::instance().broadcast(msg);
}
void WebServerManager::registerRoutes() {
_server.on("/", HTTP_GET, [this]() { handleRoot(); });
_server.on("/download", HTTP_GET, [this]() { handleDownload(); });
_server.on("/clear", HTTP_GET, [this]() { handleClearLog(); });
_server.on("/setCoords", HTTP_GET, [this]() { handleSetCoords(); });
_server.on("/setLPFBeta", HTTP_GET, [this]() { handleSetLPFBeta(); });
_server.on("/setErrMax", HTTP_GET, [this]() { handleSetErrMax(); });
_server.on("/setDelay", HTTP_GET, [this]() { handleSetDelay(); });
_server.on("/setMinErrMax", HTTP_GET, [this]() { handleSetMinErrMax(); });
_server.on("/setAbruptThreshold", HTTP_GET, [this]() { handleSetAbruptThreshold(); });
_server.on("/setSmallThreshold", HTTP_GET, [this]() { handleSetSmallThreshold(); });
_server.on("/setResetThreshold", HTTP_GET, [this]() { handleSetResetThreshold(); });
_server.on("/setPeakProximity", HTTP_GET, [this]() { handleSetPeakProximity(); });
_server.on("/setCicleMax", HTTP_GET, [this]() { handleSetCicleMax(); });
_server.on("/setAxisXFactor", HTTP_GET, [this]() { handleSetAxisXFactor(); });
_server.on("/setAxisYFactor", HTTP_GET, [this]() { handleSetAxisYFactor(); });
_server.on("/setMinIrradiance", HTTP_GET, [this]() { handleSetMinIrradiance(); });
_server.on("/setAzDeviation", HTTP_GET, [this]() { handleSetAzDeviation(); });
_server.on("/setTiltAngle", HTTP_GET, [this]() { handleSetTiltAngle(); });
_server.on("/command", HTTP_GET, [this]() { handleCommand(); });
_server.on(
"/update", HTTP_POST,
[this]() {
_server.sendHeader("Connection", "close");
_server.send(200, "text/plain", Update.hasError() ? "Fallo OTA" : "OTA OK, reiniciando...");
delay(500);
ESP.restart();
},
[this]() { handleUpdateUpload(); });
_server.on("/recomputeAutoTune", HTTP_GET, [this]() { handleRecomputeAutoTune(); });
_server.on("/applyAutoTune", HTTP_GET, [this]() { handleApplyAutoTune(); });
_server.on("/setAutoApply", HTTP_GET, [this]() { handleSetAutoApply(); });
_server.on("/toggleNVS", HTTP_GET, [this]() { handleToggleNVS(); });
_server.on("/getNVS", HTTP_GET, [this]() { handleGetNVS(); });
}
void WebServerManager::handleRoot() {
_server.sendHeader("Content-Type", "text/html; charset=UTF-8");
_server.send_P(200, "text/html", INDEX_HTML);
}
void WebServerManager::handleDownload() {
// Snapshot del tail bajo el mutex; el envio ocurre fuera del lock.
String tail = LogManager::instance().snapshotTail(LOG_SEND_CHUNK);
if (tail.length() > 0) {
_server.send(200, "text/plain", tail);
} else {
_server.send(200, "text/plain", "[sin log]");
}
}
void WebServerManager::handleClearLog() {
LogManager::instance().clear();
_server.sendHeader("Location", "/");
_server.send(303);
}
void WebServerManager::handleUpdateUpload() {
HTTPUpload& upload = _server.upload();
if (upload.status == UPLOAD_FILE_START) {
Serial.printf("[OTA] Inicio de actualizacion: %s\n", upload.filename.c_str());
if (!Update.begin()) {
Update.printError(Serial);
}
} else if (upload.status == UPLOAD_FILE_WRITE) {
if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) {
Update.printError(Serial);
}
} else if (upload.status == UPLOAD_FILE_END) {
if (Update.end(true)) {
Serial.printf("[OTA] Actualizacion completa: %u bytes\n", upload.totalSize);
} else {
Update.printError(Serial);
}
}
}
void WebServerManager::handleSetCoords() {
if (_server.hasArg("x") && _server.hasArg("y")) {
int32_t targetX = -_server.arg("x").toInt();
int32_t targetY = -_server.arg("y").toInt();
agregarLog("Recibi X:" + String(-targetX) + ", Y:" + String(-targetY));
MotorDriver::instance().moveTo(targetX, targetY);
_server.send(200, "text/plain", "Coordenadas seteadas");
} else {
_server.send(400, "text/plain", "Faltan parámetros x o y");
}
}
void WebServerManager::handleSetLPFBeta() {
if (_server.hasArg("beta")) {
float val = _server.arg("beta").toFloat();
TuningEngine::instance().setLPFBeta(val);
LPF_Beta = val;
agregarLog("LPF_Beta=" + String(val));
_server.send(200, "text/plain", "LPF_Beta OK");
} else {
_server.send(400, "text/plain", "Falta beta");
}
}
void WebServerManager::handleSetErrMax() {
if (_server.hasArg("err")) {
int32_t val = _server.arg("err").toInt();
TuningEngine::instance().setErrMax(val);
errmax = val;
agregarLog("ErrMax=" + String(val));
_server.send(200, "text/plain", "ErrMax OK");
} else {
_server.send(400, "text/plain", "Falta err");
}
}
void WebServerManager::handleSetDelay() {
if (_server.hasArg("delay")) {
int32_t val = _server.arg("delay").toInt();
TuningEngine::instance().setDelay(val);
delayyy = val;
agregarLog("Delay=" + String(val));
_server.send(200, "text/plain", "Delay OK");
} else {
_server.send(400, "text/plain", "Falta delay");
}
}
void WebServerManager::handleSetMinErrMax() {
if (_server.hasArg("minErr")) {
int32_t val = _server.arg("minErr").toInt();
TuningEngine::instance().setMinErrMax(val);
min_errmax = val;
agregarLog("MinErrMax=" + String(val));
_server.send(200, "text/plain", "MinErrMax OK");
} else {
_server.send(400, "text/plain", "Falta minErr");
}
}
void WebServerManager::handleSetAbruptThreshold() {
if (_server.hasArg("abrupt")) {
int32_t val = _server.arg("abrupt").toInt();
TuningEngine::instance().setAbruptThreshold(val);
abrupt_threshold = val;
agregarLog("AbruptThreshold=" + String(val));
_server.send(200, "text/plain", "AbruptThreshold OK");
} else {
_server.send(400, "text/plain", "Falta abrupt");
}
}
void WebServerManager::handleSetSmallThreshold() {
if (_server.hasArg("small")) {
int32_t val = _server.arg("small").toInt();
TuningEngine::instance().setSmallThreshold(val);
small_threshold = val;
agregarLog("SmallThreshold=" + String(val));
_server.send(200, "text/plain", "SmallThreshold OK");
} else {
_server.send(400, "text/plain", "Falta small");
}
}
void WebServerManager::handleSetResetThreshold() {
if (_server.hasArg("reset")) {
int32_t val = _server.arg("reset").toInt();
TuningEngine::instance().setResetThreshold(val);
reset_threshold = val;
agregarLog("ResetThreshold=" + String(val));
_server.send(200, "text/plain", "ResetThreshold OK");
} else {
_server.send(400, "text/plain", "Falta reset");
}
}
void WebServerManager::handleSetPeakProximity() {
if (_server.hasArg("peak")) {
float val = _server.arg("peak").toFloat();
TuningEngine::instance().setPeakProximity(val);
peak_proximity = val;
agregarLog("PeakProximity=" + String(val));
_server.send(200, "text/plain", "PeakProximity OK");
} else {
_server.send(400, "text/plain", "Falta peak");
}
}
void WebServerManager::handleSetCicleMax() {
if (_server.hasArg("cicle")) {
String text = _server.arg("cicle");
int32_t val = 0;
bool valid = text.length() > 0;
for (size_t i=0; i<text.length(); ++i) {
if (text[i]<'0' || text[i]>'9') { valid = false; break; }
int digit = text[i]-'0';
if (val > (64-digit)/10) { valid = false; break; }
val = val*10 + digit;
}
if (!valid || val < 1 || !TuningEngine::instance().setCicleMax(val)) {
_server.send(400,"text/plain","Invalid CicleMax for current axis factors"); return;
}
cicleMAX = val;
agregarLog("CicleMax=" + String(val));
_server.send(200, "text/plain", "CicleMax OK");
} else {
_server.send(400, "text/plain", "Falta cicle");
}
}
static bool parseAxisFactor(const String& text, int32_t& value) {
if (!text.length()) return false;
for (size_t i=0; i<text.length(); ++i) if (text[i]<'0' || text[i]>'9') return false;
int32_t v = 0;
for (size_t i=0; i<text.length(); ++i) {
int digit=text[i]-'0';
if (v > (8-digit)/10) return false;
v=v*10+digit;
}
if (v < 1 || v > 8) return false;
value = v; return true;
}
void WebServerManager::handleSetAxisXFactor() {
int32_t value;
if (!_server.hasArg("factor") || !parseAxisFactor(_server.arg("factor"), value) || !TuningEngine::instance().setAxisXFactor(value)) { _server.send(400,"text/plain","Invalid axis factor"); return; }
_server.send(200,"text/plain","Axis X factor saved");
}
void WebServerManager::handleSetAxisYFactor() {
int32_t value;
if (!_server.hasArg("factor") || !parseAxisFactor(_server.arg("factor"), value) || !TuningEngine::instance().setAxisYFactor(value)) { _server.send(400,"text/plain","Invalid axis factor"); return; }
_server.send(200,"text/plain","Axis Y factor saved");
}
void WebServerManager::handleSetMinIrradiance() {
if (_server.hasArg("minirr")) {
float val = _server.arg("minirr").toFloat();
TuningEngine::instance().setMinIrradiance(val);
agregarLog("MinIrradiance=" + String(val));
_server.send(200, "text/plain", "MinIrradiance OK");
} else {
_server.send(400, "text/plain", "Falta minirr");
}
}
void WebServerManager::handleSetAzDeviation() {
if (_server.hasArg("az")) {
double val = _server.arg("az").toDouble();
SolarTracker::instance().setAzDeviation(val);
Az_deviation = val;
agregarLog("Az_deviation=" + String(val));
_server.send(200, "text/plain", "Az_deviation OK");
} else {
_server.send(400, "text/plain", "Falta az");
}
}
void WebServerManager::handleSetTiltAngle() {
if (_server.hasArg("tilt")) {
double val = _server.arg("tilt").toDouble();
SolarTracker::instance().setTiltAngle(val);
tilt_angle = val;
agregarLog("tilt_angle=" + String(val));
_server.send(200, "text/plain", "tilt_angle OK");
} else {
_server.send(400, "text/plain", "Falta tilt");
}
}
void WebServerManager::handleCommand() {
if (!_server.hasArg("status")) {
_server.send(400, "text/plain", "Falta status");
return;
}
tracker_status = _server.arg("status").toInt();
agregarLog("Ejecutando comando: " + String(tracker_status));
switch (tracker_status) {
case 2:
SolarTracker::instance().trackSunByTime();
break;
case 3:
tracker_status = 3;
agregarLog("Iniciando Fine-Tune (nomegusta)");
break;
case 4:
MotorDriver::instance().setXY();
MotorDriver::instance().sendPositionUpdate();
break;
case 5: {
int32_t cx = LIMITX / 2;
int32_t cy = LIMITY / 2;
MotorDriver::instance().moveTo(cx, cy);
agregarLog("Moviendo a centro (X:" + String(-cx) + ", Y:" + String(-cy) + ")");
break;
}
case 6:
if (_server.hasArg("x") && _server.hasArg("y")) {
int32_t nx = -_server.arg("x").toInt();
int32_t ny = -_server.arg("y").toInt();
MotorDriver::instance().setCurrentPosition(nx, ny);
agregarLog("Posición actual seteada: X:" + String(-nx) + ", Y:" + String(-ny));
} else {
agregarLog("status=6 sin x/y -> Ignorado (usa botón 'Calibrar Pos Actual')");
}
break;
case 10:
MotorDriver::instance().printTMCStatus();
break;
case 11:
MotorDriver::instance().setXY();
agregarLog("Modo demo activo, seteando y moviendo al final");
MotorDriver::instance().moveTo(LIMITX, LIMITY);
break;
default:
_server.send(400, "text/plain", "Comando no válido");
return;
}
_server.send(200, "text/plain", "Comando ejecutado");
}
void WebServerManager::handleRecomputeAutoTune() {
TuningEngine::instance().recommendThresholds(false);
_server.send(200, "text/plain", "Recomputed (ver log)");
}
void WebServerManager::handleApplyAutoTune() {
TuningEngine::instance().recommendThresholds(true);
_server.send(200, "text/plain", "Applied (ver log)");
}
void WebServerManager::handleSetAutoApply() {
if (_server.hasArg("on")) {
bool on = (_server.arg("on") == "1");
TuningEngine::instance().setAutoApply(on);
at_auto_apply = on;
agregarLog(String("AT auto-apply: ") + (on ? "ON" : "OFF"));
_server.send(200, "text/plain", "OK");
} else {
_server.send(400, "text/plain", "Falta 'on'");
}
}
void WebServerManager::handleToggleNVS() {
// setNvsEnabled persiste el flag y hace commit/borrado de coordenadas según el estado
MotorDriver::instance().setNvsEnabled(!MotorDriver::instance().isNvsEnabled());
String json = String("{\"nvsEnabled\":") + (MotorDriver::instance().isNvsEnabled() ? "true" : "false") + "}";
_server.sendHeader("Access-Control-Allow-Origin", "*");
_server.send(200, "application/json", json);
}
void WebServerManager::handleGetNVS() {
bool enabled = MotorDriver::instance().isNvsEnabled();
TuningEngine& engine = TuningEngine::instance();
String json = String("{\"nvsEnabled\":") + (enabled ? "true" : "false") +
",\"cicleMax\":" + String(engine.getCicleMax()) +
",\"axisXFactor\":" + String(engine.getAxisXFactor()) +
",\"axisYFactor\":" + String(engine.getAxisYFactor()) + "}";
_server.sendHeader("Access-Control-Allow-Origin", "*");
_server.send(200, "application/json", json);
}
@@ -0,0 +1,54 @@
#pragma once
#include <Arduino.h>
#include <WebServer.h>
#include <WebSocketsServer.h>
class WebServerManager {
public:
static WebServerManager& instance();
void init();
void loop();
WebServer& getServer() { return _server; }
WebSocketsServer& getWebSocket() { return _webSocket; }
void broadcast(const String& msg);
private:
WebServerManager();
WebServer _server;
WebSocketsServer _webSocket;
void registerRoutes();
void handleRoot();
void handleDownload();
void handleClearLog();
void handleUpdateUpload();
void handleSetCoords();
void handleSetLPFBeta();
void handleSetErrMax();
void handleSetDelay();
void handleSetMinErrMax();
void handleSetAbruptThreshold();
void handleSetSmallThreshold();
void handleSetResetThreshold();
void handleSetPeakProximity();
void handleSetCicleMax();
void handleSetAxisXFactor();
void handleSetAxisYFactor();
void handleSetMinIrradiance();
void handleSetAzDeviation();
void handleSetTiltAngle();
void handleCommand();
void handleRecomputeAutoTune();
void handleApplyAutoTune();
void handleSetAutoApply();
void handleToggleNVS();
void handleGetNVS();
};
extern volatile uint16_t tracker_status;
void broadcastPosition(const String& msg);