Files
follow-me-stairs/firmware/follow-me-calibration.h
T
Jackson AndresandClaude Opus 5.5 87a0abca57
Installer / Build (push) Failing after 2s
Installer / Publish (push) Skipped
Follow-me stair light for the Apollo MTR-1 and WLED
ESPHome package for the Apollo MTR-1: tracks people on the stairs with the
LD2450 radar and streams a lit window to a WLED controller over DDP. The
sensor serves a setup wizard that measures the stairs with one walk and
stores the setup on the device. installer/ holds the browser installer,
published to GitHub Pages on every version tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:08:40 -04:00

631 lines
23 KiB
C++

#pragma once
// The stairs calibration made by the setup wizard (wizard/wizard.html), stored
// on the device, and the geometry derived from it.
//
// Storage: one flash preference holding the calibration and the wizard's own
// state (an opaque JSON string the wizard reads back to resume). It survives
// reboots and firmware updates.
//
// HTTP, on the device's web server:
// GET /followme the setup wizard (follow-me-wizard.h, built by
// wizard/tools/embed.mjs), gzipped, fonts
// inlined; works with no internet access.
//
// GET /followme/calibration {"set":false} or {"set":true, ...calibration}
// POST /followme/calibration body: calibration JSON, sent as text/plain so
// a page on another origin needs no preflight.
// 200 {"ok":true} once validated; 400 with
// {"ok":false,"error":"..."} otherwise.
//
// GET /followme/targets {"t": ms, "targets": [[x, y] | null, ...]}: the
// radar's three targets now, raw (mm, x as
// reported), unthrottled. The wizard polls it
// for its live view.
// POST /followme/preview body {"wled": host, "led": n} as text/plain.
// Streams LED n (1-based, raw strip index, as
// follow-me addresses it) lit and the rest
// dark, to that WLED for
// PREVIEW_MS after the last request. led 0 ends
// it. Works with or without a calibration; the
// setup wizard uses it to match LEDs to spots.
//
// Calibration JSON (lengths in mm, in the LD2450's own frame, x as reported):
// cl 2 to 4 points [x, y]: the walking path from the lower floor to the
// upper floor (2 = straight, 3 = L, 4 = U).
// bottom, top distance along cl of the front of the bottom step
// and the top edge of the top step.
// landIn, landOut distance along cl of the landing edges (L, U only).
// steps treads per flight, [n] or [lower, upper].
// width stair width.
// wled WLED controller address (IPv4 or host name). The LED count and
// whether the strip has a white channel are read from WLED itself.
// anchors 2 to 8 pairs [d, led]: LED number (1-based) whose centre sits at
// distance d along cl. LEDs between anchors are interpolated, LEDs
// beyond the outer anchors extrapolated.
// wizard wizard state, stored and returned verbatim.
//
// Threading: the HTTP handler runs on the web server task. It validates into
// a pending copy; the main loop applies it (apply_pending()), saves it, and
// rebuilds the derived geometry. Geometry is read only from the main loop.
#include <atomic>
#include <cctype>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <memory>
#include <mutex>
#include <string>
#include <utility>
#include "follow-me.h"
#include "follow-me-wizard.h"
#include "esphome/components/json/json_util.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/web_server_base/web_server_base.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/preferences.h"
namespace follow_me {
static const char *const CAL_TAG = "follow_me.cal";
static const uint32_t CAL_VERSION = 1;
static const int CAL_MAX_POINTS = 4;
static const int CAL_MAX_ANCHORS = 8;
// Longest strip follow-me drives. LED positions are kept for every LED, so
// this sets the RAM used (4 bytes per LED) and the frame buffer (4 per LED).
static const int MAX_STREAM_LEDS = 1500;
static const int CAL_MAX_LED_NUMBER = MAX_STREAM_LEDS;
static const size_t CAL_WIZARD_MAX = 1536;
static const size_t CAL_BODY_MAX = 4096;
struct Calibration {
uint32_t version;
uint8_t points;
float x[CAL_MAX_POINTS], y[CAL_MAX_POINTS];
float bottom, top, land_in, land_out;
uint8_t steps[2];
float width;
char wled[HOST_MAX];
uint8_t anchors;
float anchor_d[CAL_MAX_ANCHORS], anchor_led[CAL_MAX_ANCHORS];
char wizard[CAL_WIZARD_MAX];
};
// Geometry used by the follow-me loop. Positions are inches along the stairs
// from the top edge of the top step, increasing down the stairs ("wall
// inches"; effects are laid out in the same units).
static const int MAX_GAPS = 8;
static const float GAP_FACTOR = 3.0f;
struct Geometry {
bool ready = false;
int points = 0;
float x[CAL_MAX_POINTS] = {}, y[CAL_MAX_POINTS] = {}; // path, inches
float cum[CAL_MAX_POINTS] = {}; // inches along the path at each point
float top_along = 0; // inches along the path of the top edge
float stairs_bottom = 0; // wall inches of the front of the bottom step
float inches_per_step = 0;
float half_width = 0;
float edge_wall[MAX_STREAM_LEDS + 1] = {}; // wall inches of LED edge k (edge 0 = outer edge of LED 1)
// Drawing coordinate: wall inches with the strip's gaps squeezed out. A gap
// is a span between two pinned LEDs with far fewer LEDs than its length
// needs (a jumper across a landing, a corner, a doorway): each LED there
// covers more than GAP_FACTOR times the strip's usual spacing. In drawing
// coordinates each such LED covers the usual spacing, so a window crossing
// the gap lights both sides of it.
int gaps = 0;
float gap_lo[MAX_GAPS] = {}, gap_hi[MAX_GAPS] = {}, gap_cut[MAX_GAPS] = {}; // wall span and inches removed
float edge_draw[MAX_STREAM_LEDS + 1] = {}; // drawing coordinate of LED edge k
};
inline Calibration active_cal{};
inline Calibration pending_cal{};
inline bool has_active = false;
inline std::atomic<bool> has_pending{false};
inline std::mutex cal_mutex;
inline Geometry geo;
inline ESPPreferenceObject cal_pref;
inline float path_length(const Calibration &c) {
float total = 0;
for (int i = 1; i < c.points; i++) total += hypotf(c.x[i] - c.x[i - 1], c.y[i] - c.y[i - 1]);
return total;
}
// Empty string if the calibration can drive the light, otherwise why not.
inline std::string validate(const Calibration &c) {
if (c.points < 2 || c.points > CAL_MAX_POINTS) return "cl needs 2 to 4 points";
for (int i = 1; i < c.points; i++)
if (hypotf(c.x[i] - c.x[i - 1], c.y[i] - c.y[i - 1]) < 100) return "cl has points closer than 100 mm";
const float total = path_length(c);
if (!(c.bottom >= 0 && c.top > c.bottom + 300 && c.top <= total + 1)) return "bottom and top must lie on cl, top past bottom";
const int flights = c.points == 2 ? 1 : 2;
if (flights == 2 && !(c.land_in > c.bottom && c.land_out >= c.land_in && c.top > c.land_out))
return "landIn and landOut must lie between bottom and top";
for (int f = 0; f < flights; f++)
if (c.steps[f] < 1 || c.steps[f] > 40) return "steps must be 1 to 40 per flight";
if (!(c.width >= 300 && c.width <= 2500)) return "width must be 300 to 2500 mm";
if (!c.wled[0]) return "wled address is required";
if (c.anchors < 2 || c.anchors > CAL_MAX_ANCHORS) return "anchors needs 2 to 8 pairs";
for (int i = 0; i < c.anchors; i++) {
if (!(c.anchor_led[i] >= 1 && c.anchor_led[i] <= CAL_MAX_LED_NUMBER)) return "anchor LED is past the longest strip follow-me drives";
for (int j = 0; j < i; j++)
if (fabsf(c.anchor_led[i] - c.anchor_led[j]) < 0.5f) return "two anchors share an LED";
}
return "";
}
// Drawing coordinate of a wall position: the gaps' removed inches before it
// are subtracted; inside a gap the removal is spread evenly.
inline float draw_pos(float wall) {
float out = wall;
for (int i = 0; i < geo.gaps; i++) {
const float lo = geo.gap_lo[i], hi = geo.gap_hi[i], cut = geo.gap_cut[i];
if (wall >= hi) out -= cut;
else if (wall > lo) out -= cut * (wall - lo) / (hi - lo);
}
return out;
}
// Rebuilds geo from a validated calibration.
inline void derive(const Calibration &c) {
Geometry &g = geo;
g.points = c.points;
for (int i = 0; i < c.points; i++) {
g.x[i] = c.x[i] / 25.4f;
g.y[i] = c.y[i] / 25.4f;
g.cum[i] = i == 0 ? 0 : g.cum[i - 1] + hypotf(g.x[i] - g.x[i - 1], g.y[i] - g.y[i - 1]);
}
g.top_along = c.top / 25.4f;
g.stairs_bottom = (c.top - c.bottom) / 25.4f;
const float treads = c.points == 2 ? c.top - c.bottom : (c.land_in - c.bottom) + (c.top - c.land_out);
const int steps = c.steps[0] + (c.points == 2 ? 0 : c.steps[1]);
g.inches_per_step = treads / steps / 25.4f;
g.half_width = c.width / 2 / 25.4f;
// Anchors as (LED edge, wall inches), sorted by edge. LED n's centre is
// edge n - 0.5.
float ae[CAL_MAX_ANCHORS], aw[CAL_MAX_ANCHORS];
const int n = c.anchors;
for (int i = 0; i < n; i++) {
ae[i] = c.anchor_led[i] - 0.5f;
aw[i] = (c.top - c.anchor_d[i]) / 25.4f;
}
for (int i = 1; i < n; i++)
for (int j = i; j > 0 && ae[j] < ae[j - 1]; j--) {
std::swap(ae[j], ae[j - 1]);
std::swap(aw[j], aw[j - 1]);
}
// Interpolated between anchors, extrapolated past the outer ones.
for (int k = 0; k <= MAX_STREAM_LEDS; k++) {
int j = 0;
while (j < n - 2 && k > ae[j + 1]) j++;
g.edge_wall[k] = aw[j] + (k - ae[j]) * (aw[j + 1] - aw[j]) / (ae[j + 1] - ae[j]);
}
// Gaps: per-LED spacing within the pinned range, against its median.
{
int lo = MAX_STREAM_LEDS, hi = 0;
for (int i = 0; i < n; i++) {
lo = std::min(lo, (int) floorf(ae[i]));
hi = std::max(hi, (int) ceilf(ae[i]));
}
lo = std::max(lo, 0);
hi = std::min(hi, MAX_STREAM_LEDS);
static float pitch[MAX_STREAM_LEDS];
int m = 0;
for (int k = lo; k < hi; k++) pitch[m++] = fabsf(g.edge_wall[k + 1] - g.edge_wall[k]);
g.gaps = 0;
if (m > 0) {
static float sorted[MAX_STREAM_LEDS];
memcpy(sorted, pitch, m * sizeof(float));
std::nth_element(sorted, sorted + m / 2, sorted + m);
const float usual = sorted[m / 2];
for (int k = lo; k < hi && usual > 0.0f; k++) {
const float a = fminf(g.edge_wall[k], g.edge_wall[k + 1]), b = fmaxf(g.edge_wall[k], g.edge_wall[k + 1]);
if (b - a <= GAP_FACTOR * usual) continue;
if (g.gaps > 0 && fabsf(g.gap_hi[g.gaps - 1] - a) < 0.01f) { // continues the previous gap
g.gap_hi[g.gaps - 1] = b;
g.gap_cut[g.gaps - 1] += b - a - usual;
} else if (g.gaps > 0 && fabsf(g.gap_lo[g.gaps - 1] - b) < 0.01f) {
g.gap_lo[g.gaps - 1] = a;
g.gap_cut[g.gaps - 1] += b - a - usual;
} else if (g.gaps < MAX_GAPS) {
g.gap_lo[g.gaps] = a;
g.gap_hi[g.gaps] = b;
g.gap_cut[g.gaps] = b - a - usual;
g.gaps++;
}
}
}
}
g.ready = true;
for (int k = 0; k <= MAX_STREAM_LEDS; k++) g.edge_draw[k] = draw_pos(g.edge_wall[k]);
for (int i = 0; i < g.gaps; i++)
ESP_LOGI(CAL_TAG, "Strip gap at wall %.1f-%.1f in, drawn %.1f in shorter", g.gap_lo[i], g.gap_hi[i], g.gap_cut[i]);
set_wled_host(c.wled);
ESP_LOGI(CAL_TAG, "Calibration applied: %d-point path, %.1f in per step, stairs %.1f in long, WLED %s", c.points,
g.inches_per_step, g.stairs_bottom, c.wled);
}
// Nearest point on the path to (x, y) in radar inches. Returns the squared
// distance; *wall is the wall-inch position there, *at_end whether the point
// lies beyond either end of the path.
inline float project(float x, float y, float *wall, bool *at_end) {
const Geometry &g = geo;
float best = 1e9f;
for (int k = 0; k < g.points - 1; k++) {
const float sx = g.x[k + 1] - g.x[k], sy = g.y[k + 1] - g.y[k];
float f = ((x - g.x[k]) * sx + (y - g.y[k]) * sy) / (sx * sx + sy * sy);
bool end = false;
if (f < 0.0f) {
f = 0.0f;
end = k == 0;
}
if (f > 1.0f) {
f = 1.0f;
end = k == g.points - 2;
}
const float ex = x - (g.x[k] + f * sx), ey = y - (g.y[k] + f * sy);
const float d2 = ex * ex + ey * ey;
if (d2 < best) {
best = d2;
*wall = g.top_along - (g.cum[k] + f * (g.cum[k + 1] - g.cum[k]));
*at_end = end;
}
}
return best;
}
// Main loop: load the stored calibration once, then pick up new ones.
inline void apply_pending() {
if (!has_pending.load()) return;
{
std::lock_guard<std::mutex> lock(cal_mutex);
active_cal = pending_cal;
has_active = true;
has_pending = false;
}
if (cal_pref.save(&active_cal) && global_preferences->sync())
ESP_LOGI(CAL_TAG, "Calibration saved");
else
ESP_LOGW(CAL_TAG, "Calibration could not be saved to flash");
derive(active_cal);
}
inline void load_calibration() {
cal_pref = global_preferences->make_preference<Calibration>(fnv1_hash("follow_me_calibration"));
static Calibration c;
if (!cal_pref.load(&c) || c.version != CAL_VERSION) {
ESP_LOGI(CAL_TAG, "No calibration stored; run the setup wizard");
return;
}
c.wizard[CAL_WIZARD_MAX - 1] = '\0';
c.wled[HOST_MAX - 1] = '\0';
const std::string err = validate(c);
if (!err.empty()) {
ESP_LOGW(CAL_TAG, "Stored calibration rejected: %s", err.c_str());
return;
}
{
std::lock_guard<std::mutex> lock(cal_mutex);
active_cal = c;
has_active = true;
}
derive(c);
}
// ---------- JSON ----------
inline std::string to_json(const Calibration &c) {
std::string out = "{\"set\":true,\"cl\":[";
char buf[64];
for (int i = 0; i < c.points; i++) {
snprintf(buf, sizeof(buf), "%s[%.1f,%.1f]", i ? "," : "", c.x[i], c.y[i]);
out += buf;
}
snprintf(buf, sizeof(buf), "],\"bottom\":%.1f,\"top\":%.1f", c.bottom, c.top);
out += buf;
if (c.points > 2) {
snprintf(buf, sizeof(buf), ",\"landIn\":%.1f,\"landOut\":%.1f", c.land_in, c.land_out);
out += buf;
}
if (c.points > 2)
snprintf(buf, sizeof(buf), ",\"steps\":[%u,%u]", c.steps[0], c.steps[1]);
else
snprintf(buf, sizeof(buf), ",\"steps\":[%u]", c.steps[0]);
out += buf;
snprintf(buf, sizeof(buf), ",\"width\":%.1f,\"wled\":\"", c.width);
out += buf;
for (const char *p = c.wled; *p; p++)
if (*p != '"' && *p != '\\') out += *p; // validated to be a plain host; kept JSON-safe regardless
out += "\",\"anchors\":[";
for (int i = 0; i < c.anchors; i++) {
snprintf(buf, sizeof(buf), "%s[%.1f,%.2f]", i ? "," : "", c.anchor_d[i], c.anchor_led[i]);
out += buf;
}
out += "],\"wizard\":";
out += c.wizard[0] ? c.wizard : "null";
out += "}";
return out;
}
// Parses a POST body into c. Empty string on success, otherwise the error.
inline std::string from_json(const std::string &body, Calibration &c) {
c = Calibration{};
c.version = CAL_VERSION;
std::string err = "body is not a JSON object";
esphome::json::parse_json(body, [&](JsonObject o) -> bool {
JsonArray cl = o["cl"].as<JsonArray>(), steps = o["steps"].as<JsonArray>(), an = o["anchors"].as<JsonArray>();
if (cl.isNull() || steps.isNull() || an.isNull()) {
err = "cl, steps and anchors are required";
return false;
}
if (cl.size() < 2 || cl.size() > CAL_MAX_POINTS) {
err = "cl needs 2 to 4 points";
return false;
}
c.points = cl.size();
for (int i = 0; i < c.points; i++) {
c.x[i] = cl[i][0] | NAN;
c.y[i] = cl[i][1] | NAN;
}
c.bottom = o["bottom"] | NAN;
c.top = o["top"] | NAN;
c.land_in = o["landIn"] | NAN;
c.land_out = o["landOut"] | NAN;
if (steps.size() != (c.points == 2 ? 1u : 2u)) {
err = "steps needs one count per flight";
return false;
}
for (size_t i = 0; i < steps.size(); i++) c.steps[i] = steps[i] | 0;
c.width = o["width"] | NAN;
const char *wled = o["wled"] | "";
if (strlen(wled) >= HOST_MAX) {
err = "wled address is too long";
return false;
}
for (const char *p = wled; *p; p++)
if (!(isalnum((unsigned char) *p) || *p == '.' || *p == '-' || *p == '_')) {
err = "wled must be an IPv4 address or host name";
return false;
}
strcpy(c.wled, wled);
if (an.size() < 2 || an.size() > CAL_MAX_ANCHORS) {
err = "anchors needs 2 to 8 pairs";
return false;
}
c.anchors = an.size();
for (int i = 0; i < c.anchors; i++) {
c.anchor_d[i] = an[i][0] | NAN;
c.anchor_led[i] = an[i][1] | NAN;
}
JsonVariant wiz = o["wizard"];
if (!wiz.isNull()) {
std::string s;
serializeJson(wiz, s);
if (s.size() >= CAL_WIZARD_MAX) {
err = "wizard state is too large";
return false;
}
memcpy(c.wizard, s.c_str(), s.size() + 1);
}
for (int i = 0; i < c.points; i++)
if (std::isnan(c.x[i]) || std::isnan(c.y[i])) {
err = "cl points must be [x, y] numbers";
return false;
}
err = validate(c);
return err.empty();
});
return err;
}
// The radar's target X/Y sensors, found by the follow-me loop.
inline esphome::sensor::Sensor *radar_x[MAX_TRACKS] = {}, *radar_y[MAX_TRACKS] = {};
inline std::string targets_json() {
std::string out = "{\"t\":" + std::to_string(millis()) + ",\"targets\":[";
char buf[48];
for (int t = 0; t < MAX_TRACKS; t++) {
if (t) out += ",";
const float x = radar_x[t] ? radar_x[t]->get_raw_state() : NAN, y = radar_y[t] ? radar_y[t]->get_raw_state() : NAN;
if (std::isnan(x) || std::isnan(y) || (x == 0 && y == 0)) {
out += "null";
} else {
snprintf(buf, sizeof(buf), "[%.0f,%.0f]", x, y);
out += buf;
}
}
return out + "]}";
}
// ---------- LED preview ----------
static const uint32_t PREVIEW_MS = 10000;
inline char preview_host[HOST_MAX] = "";
inline int preview_led = 0; // guarded by cal_mutex
inline uint32_t preview_until = 0; // millis(); guarded by cal_mutex
// Main loop: the LED to preview now (0 = none). Points the WLED watcher at
// the preview's WLED while previewing and back at the calibration's after.
inline int preview_now(uint32_t now) {
static bool was = false;
int led = 0;
char host[HOST_MAX];
{
std::lock_guard<std::mutex> lock(cal_mutex);
if (preview_led > 0 && (int32_t) (preview_until - now) > 0) {
led = preview_led;
memcpy(host, preview_host, HOST_MAX);
} else {
preview_led = 0;
}
}
if (led > 0) {
set_wled_host(host);
was = true;
} else if (was) {
was = false;
set_wled_host(has_active ? active_cal.wled : "");
}
return led;
}
// Fills data (bpl bytes per LED) with the preview of LED led (1-based) for a
// strip of leds LEDs: that one LED bright amber, the rest dark. Returns the
// data length.
inline int preview_frame(uint8_t *data, int leds, int bpl, int led) {
const int len = leds * bpl;
memset(data, 0, len);
static const uint8_t LIT[3] = {255, 110, 0};
if (led >= 1 && led <= leds)
for (int ch = 0; ch < 3; ch++) data[(led - 1) * bpl + ch] = LIT[ch];
return len;
}
inline std::string parse_preview(const std::string &body) {
std::string err = "body is not a JSON object";
esphome::json::parse_json(body, [&](JsonObject o) -> bool {
const char *wled = o["wled"] | "";
const int led = o["led"] | -1;
if (led < 0 || led > CAL_MAX_LED_NUMBER) {
err = "led is past the longest strip follow-me drives";
return false;
}
if (led > 0) {
if (!wled[0] || strlen(wled) >= HOST_MAX) {
err = "wled address is required";
return false;
}
for (const char *p = wled; *p; p++)
if (!(isalnum((unsigned char) *p) || *p == '.' || *p == '-' || *p == '_')) {
err = "wled must be an IPv4 address or host name";
return false;
}
}
std::lock_guard<std::mutex> lock(cal_mutex);
preview_led = led;
if (led > 0) {
strcpy(preview_host, wled);
preview_until = millis() + PREVIEW_MS;
}
err = "";
return true;
});
return err;
}
// ---------- HTTP ----------
class CalibrationHandler : public AsyncWebHandler {
public:
bool canHandle(AsyncWebServerRequest *request) const override {
char buf[AsyncWebServerRequest::URL_BUF_SIZE];
const StringRef url = request->url_to(buf);
const auto m = request->method();
return ((url == "/followme" || url == "/followme/") && m == HTTP_GET) ||
(url == "/followme/calibration" && (m == HTTP_GET || m == HTTP_POST)) ||
(url == "/followme/preview" && m == HTTP_POST) || (url == "/followme/targets" && m == HTTP_GET);
}
void handleBody(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) override {
if (index == 0) {
this->body_.clear();
this->too_big_ = total > CAL_BODY_MAX;
if (!this->too_big_) this->body_.reserve(total);
}
if (!this->too_big_) this->body_.append(reinterpret_cast<const char *>(data), len);
}
void handleRequest(AsyncWebServerRequest *request) override {
char ubuf[AsyncWebServerRequest::URL_BUF_SIZE];
const StringRef url = request->url_to(ubuf);
if (url == "/followme" || url == "/followme/") {
AsyncWebServerResponse *response =
request->beginResponse(200, "text/html; charset=utf-8", WIZARD_HTML_GZ, WIZARD_HTML_GZ_LEN);
response->addHeader("Content-Encoding", "gzip");
response->addHeader("Cache-Control", "no-cache");
request->send(response);
return;
}
if (url == "/followme/targets") {
const std::string out = targets_json();
request->send(200, "application/json", out.c_str());
return;
}
if (request->method() == HTTP_GET) {
std::string out;
{
std::lock_guard<std::mutex> lock(cal_mutex);
if (has_pending.load())
out = to_json(pending_cal);
else if (has_active)
out = to_json(active_cal);
else
out = "{\"set\":false}";
}
request->send(200, "application/json", out.c_str());
return;
}
if (url == "/followme/preview") {
const std::string err = this->too_big_ ? std::string("body is too large") : parse_preview(this->body_);
this->body_.clear();
if (!err.empty()) {
const std::string out = "{\"ok\":false,\"error\":\"" + err + "\"}";
request->send(400, "application/json", out.c_str());
} else {
request->send(200, "application/json", "{\"ok\":true}");
}
return;
}
// Heap, not stack: the web server task has little stack to spare.
auto c = std::make_unique<Calibration>();
std::string err;
if (this->too_big_)
err = "body is too large";
else
err = from_json(this->body_, *c);
this->body_.clear();
this->body_.shrink_to_fit();
if (!err.empty()) {
ESP_LOGW(CAL_TAG, "Calibration refused: %s", err.c_str());
const std::string out = "{\"ok\":false,\"error\":\"" + err + "\"}";
request->send(400, "application/json", out.c_str());
return;
}
{
std::lock_guard<std::mutex> lock(cal_mutex);
pending_cal = *c;
has_pending = true;
}
request->send(200, "application/json", "{\"ok\":true}");
}
bool isRequestHandlerTrivial() const override { return false; }
protected:
std::string body_;
bool too_big_ = false;
};
inline bool web_ready = false; // the device config has a web server; the wizard needs it
inline bool radar_missing = false; // set by the follow-me loop when the radar target sensors are not found
// Called once at boot, after the web server is set up.
inline void setup_calibration() {
start_watch();
load_calibration();
if (web_server_base::global_web_server_base == nullptr) {
ESP_LOGE(CAL_TAG, "No web server in this device config; the setup wizard cannot reach the device");
return;
}
web_server_base::global_web_server_base->add_handler(new CalibrationHandler()); // NOLINT
web_ready = true;
}
} // namespace follow_me