Follow-me stair light for the Apollo MTR-1 and WLED
Installer / Build (push) Failing after 2s
Installer / Publish (push) Skipped

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>
This commit is contained in:
Jackson AndresandClaude Opus 5.5 committed 2026-10-14 20:08:40 -04:00
commit 87a0abca57
27 files changed
+11100

No files matched your search

+269
View File
@@ -0,0 +1,269 @@
#pragma once
// Shared state for the follow-me light (follow-me.yaml): the per-person
// windows, the WLED watcher and the DDP sender.
//
// A FreeRTOS task talks to the WLED controller over plain blocking lwIP
// sockets, so the main loop never waits on the network. ESPHome's
// http_request component is not used because it blocks the main loop for the
// whole request (and for the full timeout while WLED is unreachable).
//
// The WLED address comes from the stored calibration or the LED preview
// (set_wled_host()); it may be an IPv4 address or a host name. The task:
// - resolves the address and reads /json/info for the LED count and
// whether the strip has a white channel, when the address changes and
// every INFO_MS after that (so DHCP changes and strip edits are picked up);
// - polls /json/state every POLL_MS. wled_on is true while WLED reports
// itself on. When pause_when_on() is set, the follow-me loop stops
// streaming while WLED is on (someone is using the light), and on each
// off-to-on change the task posts {"live":false} so WLED leaves realtime
// mode at once. An unreachable WLED reads as off.
// wled_ip is 0 and wled_leds 0 until the controller has answered.
//
// Frames go to WLED over DDP (UDP port 4048): 3 bytes per LED on RGB strips,
// 4 on RGBW, split over as many packets as the strip needs. DDP addresses
// LEDs by their raw index on the controller, like WLED's other realtime
// protocols, independent of WLED's segments.
#include <algorithm>
#include <atomic>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <mutex>
#include "esphome/components/json/json_util.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "lwip/inet.h"
#include "lwip/netdb.h"
#include "lwip/sockets.h"
namespace follow_me {
inline std::atomic<bool> wled_on{false};
inline std::atomic<uint32_t> wled_ip{0}; // resolved IPv4, network byte order; 0 = unknown
inline std::atomic<int> wled_leds{0}; // LED count WLED reports; 0 = unknown
inline std::atomic<bool> wled_white{false}; // strip has a white channel (RGBW)
inline std::atomic<bool> pause_when_on{true}; // mirrors the "Follow-Me Pause While WLED On" switch
static const uint32_t POLL_MS = 500;
static const uint32_t INFO_MS = 30000;
static const uint32_t RELEASE_DELAY_MS = 60; // lets the loop stop streaming before the release
static const size_t HOST_MAX = 128;
inline char wled_host[HOST_MAX] = "";
inline uint32_t wled_host_gen = 0; // bumped on every change
inline std::mutex wled_host_mutex;
inline void set_wled_host(const char *host) {
std::lock_guard<std::mutex> lock(wled_host_mutex);
if (strncmp(wled_host, host, HOST_MAX) == 0) return;
strncpy(wled_host, host, HOST_MAX - 1);
wled_host[HOST_MAX - 1] = '\0';
wled_host_gen++;
}
// IPv4 address of host (dotted quad or name), network byte order; 0 on failure.
inline uint32_t resolve(const char *host) {
struct in_addr a;
if (inet_aton(host, &a)) return a.s_addr;
struct addrinfo hints = {};
hints.ai_family = AF_INET;
struct addrinfo *res = nullptr;
if (lwip_getaddrinfo(host, nullptr, &hints, &res) != 0 || res == nullptr) return 0;
const uint32_t ip = reinterpret_cast<struct sockaddr_in *>(res->ai_addr)->sin_addr.s_addr;
lwip_freeaddrinfo(res);
return ip;
}
// One HTTP/1.0 exchange with WLED's web server (port 80, plain HTTP; WLED
// serves nothing else). Reads at most len - 1 bytes of the response into buf
// (NUL-terminated) and returns a pointer to the body, or nullptr on failure.
inline const char *http_exchange(uint32_t ip, const char *request, char *buf, size_t len) {
const int s = lwip_socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (s < 0) return nullptr;
struct timeval tv = {1, 0};
lwip_setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
lwip_setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
struct sockaddr_in addr = {};
addr.sin_family = AF_INET;
addr.sin_port = htons(80);
addr.sin_addr.s_addr = ip;
int got = -1;
const int request_len = strlen(request);
if (lwip_connect(s, (struct sockaddr *) &addr, sizeof(addr)) == 0 &&
lwip_send(s, request, request_len, 0) == request_len) {
got = 0;
while (got < (int) len - 1) {
const int n = lwip_recv(s, buf + got, len - 1 - got, 0);
if (n <= 0) break;
got += n;
}
buf[got] = '\0';
}
lwip_close(s);
if (got <= 0) return nullptr;
const char *body = strstr(buf, "\r\n\r\n");
return body ? body + 4 : nullptr;
}
inline void watch_task(void *) {
static char buf[4096]; // /json/info is about 1.5 kB with its headers
char host[HOST_MAX] = "";
char get[HOST_MAX + 64];
uint32_t gen = 0, ip = 0, last_info = 0;
bool info_ok = false;
for (;;) {
{
std::lock_guard<std::mutex> lock(wled_host_mutex);
if (gen != wled_host_gen) {
gen = wled_host_gen;
memcpy(host, wled_host, HOST_MAX);
ip = 0;
info_ok = false;
wled_ip = 0;
wled_leds = 0;
}
}
if (!host[0]) {
wled_on = false;
vTaskDelay(pdMS_TO_TICKS(POLL_MS));
continue;
}
const uint32_t now = xTaskGetTickCount() * portTICK_PERIOD_MS;
if (!info_ok || now - last_info >= INFO_MS) {
last_info = now;
ip = resolve(host);
info_ok = false;
if (ip) {
snprintf(get, sizeof(get), "GET /json/info HTTP/1.0\r\nHost: %s\r\n\r\n", host);
if (const char *body = http_exchange(ip, get, buf, sizeof(buf))) {
esphome::json::parse_json(reinterpret_cast<const uint8_t *>(body), strlen(body), [&](JsonObject o) -> bool {
const int count = o["leds"]["count"] | 0;
const int lc = o["leds"]["lc"] | -1; // light capabilities: bit 0 RGB, bit 1 white
if (count <= 0 || lc < 0) return false;
wled_leds = count;
wled_white = (lc & 2) != 0;
info_ok = true;
return true;
});
}
}
wled_ip = info_ok ? ip : 0;
}
bool on = false;
if (info_ok) {
snprintf(get, sizeof(get), "GET /json/state HTTP/1.0\r\nHost: %s\r\n\r\n", host);
if (const char *body = http_exchange(ip, get, buf, sizeof(buf))) {
esphome::json::parse_json(reinterpret_cast<const uint8_t *>(body), strlen(body), [&](JsonObject o) -> bool {
on = o["on"] | false;
return true;
});
}
}
const bool was_on = wled_on.exchange(on);
if (on && !was_on && pause_when_on.load()) {
vTaskDelay(pdMS_TO_TICKS(RELEASE_DELAY_MS));
char release[HOST_MAX + 128];
snprintf(release, sizeof(release),
"POST /json/state HTTP/1.0\r\nHost: %s\r\nContent-Type: application/json\r\n"
"Content-Length: 14\r\n\r\n{\"live\":false}",
host);
http_exchange(ip, release, buf, sizeof(buf));
}
vTaskDelay(pdMS_TO_TICKS(POLL_MS));
}
}
// One lit window following one person. Positions are wall inches.
struct Track {
bool alive = false; // slot in use (lit, fading, or person lingering off the stairs)
bool tracking = false; // window has a filtered position
bool on_stairs = false; // the person has been on the stairs
bool entering = false; // entrance spring running
bool found = false; // matched to a radar target this frame
int exit_dir = 0; // left the stairs: 1 past the bottom, -1 past the top
float target = 0.0f; // filtered person position
float est_v = 0.0f; // estimated speed, inches/s
float last_z = 0.0f; // last radar position used
float centre = 0.0f; // window centre before speed limit and easing
float limited = 0.0f; // speed-limited window centre
float pos = 0.0f; // displayed window centre
float level = 0.0f; // fade envelope, 0 dark to 1 full
float exit_far = 0.0f; // farthest radar distance from the exit end since leaving
float ent_v = 0.0f; // entrance spring velocity, inches/s
uint32_t last_meas = 0;
uint32_t last_seen = 0;
uint32_t ent_start = 0;
// Matched radar target this frame.
float det_p = 0.0f, det_raw = 0.0f, det_x = 0.0f, det_y = 0.0f;
};
static const int MAX_TRACKS = 3; // the LD2450 tracks three targets
inline Track tracks[MAX_TRACKS];
// Centre of the brightest window, or NAN when all are dark.
inline float display_pos() {
float best = 0.0f, pos = NAN;
for (const Track &t : tracks) {
if (t.level > best) {
best = t.level;
pos = t.pos;
}
}
return pos;
}
// ---------- DDP ----------
static const uint16_t DDP_PORT = 4048;
static const int DDP_MAX_DATA = 1440; // bytes per packet; keeps each datagram inside one Ethernet frame
// Sends one frame (bpl bytes per LED, 3 or 4) to WLED over DDP, in as many
// packets as needed; the last carries the push flag. Main loop only.
inline void send_ddp(uint32_t ip, const uint8_t *data, int len, int bpl) {
static int sock = -1;
static uint8_t seq = 0;
if (sock < 0) {
sock = lwip_socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (sock < 0) return;
}
struct sockaddr_in dest = {};
dest.sin_family = AF_INET;
dest.sin_port = htons(DDP_PORT);
dest.sin_addr.s_addr = ip;
seq = seq % 15 + 1; // 1-15; 0 means "no sequence"
const int chunk = DDP_MAX_DATA / bpl * bpl;
static uint8_t pkt[10 + DDP_MAX_DATA];
for (int off = 0; off < len; off += chunk) {
const int n = std::min(chunk, len - off);
const bool last = off + n >= len;
pkt[0] = 0x40 | (last ? 0x01 : 0x00); // version 1, push on the last packet
pkt[1] = seq;
pkt[2] = bpl == 4 ? 0x1B : 0x0B; // RGBW or RGB, 8 bits per channel
pkt[3] = 1; // destination: default output device
pkt[4] = off >> 24;
pkt[5] = off >> 16;
pkt[6] = off >> 8;
pkt[7] = off;
pkt[8] = n >> 8;
pkt[9] = n;
memcpy(pkt + 10, data + off, n);
if (lwip_sendto(sock, pkt, 10 + n, 0, (struct sockaddr *) &dest, sizeof(dest)) < 0) {
lwip_close(sock);
sock = -1;
return;
}
}
}
// Starts the watcher once.
inline void start_watch() {
static bool started = false;
if (started) return;
started = true;
xTaskCreate(watch_task, "follow_me_wled", 6144, nullptr, 1, nullptr);
}
} // namespace follow_me