Initial import of Microtracking project
This commit is contained in:
commit
60720c388c
2265 files changed
+1464696
No files matched your search
@@ -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
|
||||
@@ -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"
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user