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

+93
View File
@@ -0,0 +1,93 @@
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+62
View File
@@ -0,0 +1,62 @@
// Drives the wizard in headless Chrome over the DevTools protocol and saves
// screenshots, for checking changes without a phone or a person on the stairs.
// node wizard/tools/drive.mjs <url> <width> <height> <steps.json>
// steps.json is a list run in order; each entry may have:
// eval JavaScript run in the page (its value is printed)
// wait milliseconds to wait afterwards
// shot PNG path to save; with h, captures that many pixels tall
// Use ?sim=1 in the url for the simulated walker. Real waits are needed:
// Chrome's --virtual-time-budget hangs on the page's open event stream.
// Needs Node 22+ (built-in WebSocket) and Google Chrome.
import {spawn} from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const CHROME = process.env.CHROME || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const [url, w, h, stepsFile] = process.argv.slice(2);
if (!stepsFile) { console.error('usage: drive.mjs <url> <width> <height> <steps.json>'); process.exit(2); }
const steps = JSON.parse(fs.readFileSync(stepsFile, 'utf8'));
const port = 9300 + Math.floor(Math.random() * 600);
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'wizard-drive-'));
const chrome = spawn(CHROME, ['--headless=new', `--remote-debugging-port=${port}`, `--user-data-dir=${profile}`, 'about:blank'], {stdio: 'ignore'});
const sleep = ms => new Promise(r => setTimeout(r, ms));
let tabs;
for (let i = 0; i < 50 && !tabs; i++) { try { tabs = await (await fetch(`http://127.0.0.1:${port}/json`)).json(); } catch { await sleep(200); } }
const ws = new WebSocket(tabs.find(t => t.type === 'page').webSocketDebuggerUrl);
await new Promise(r => { ws.onopen = r; });
let id = 0;
const pending = {}, errors = [];
ws.onmessage = e => {
const m = JSON.parse(e.data);
if (m.method === 'Runtime.exceptionThrown') errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
if (pending[m.id]) { pending[m.id](m); delete pending[m.id]; }
};
const send = (method, params = {}) => new Promise(r => { pending[++id] = r; ws.send(JSON.stringify({id, method, params})); });
await send('Runtime.enable');
await send('Emulation.setDeviceMetricsOverride', {width: +w, height: +h, deviceScaleFactor: 2, mobile: +w < 600});
await send('Page.enable');
await send('Page.navigate', {url});
await sleep(2500);
for (const s of steps) {
if (s.eval) {
const r = await send('Runtime.evaluate', {expression: s.eval, returnByValue: true, awaitPromise: true});
if (r.result?.exceptionDetails) console.log('eval error:', r.result.exceptionDetails.exception?.description);
else if (r.result?.result?.value !== undefined) console.log('eval:', JSON.stringify(r.result.result.value));
}
if (s.wait) await sleep(s.wait);
if (s.shot) {
const r = await send('Page.captureScreenshot', {format: 'png', clip: {x: 0, y: 0, width: +w, height: +(s.h || h), scale: 1}, captureBeyondViewport: !!s.h});
fs.writeFileSync(s.shot, Buffer.from(r.result.data, 'base64'));
console.log('shot', s.shot);
}
}
console.log(errors.length ? errors.map(e => 'page error: ' + e).join('\n') : 'no page errors');
chrome.kill();
await sleep(300);
fs.rmSync(profile, {recursive: true, force: true, maxRetries: 3});
process.exit(0);
+79
View File
@@ -0,0 +1,79 @@
// Builds firmware/follow-me-wizard.h: the setup wizard as one gzipped page
// for the sensor to serve at /followme.
//
// node wizard/tools/embed.mjs
//
// Inlines every url(fonts/<file>) in wizard/wizard.html as a data: URI,
// drops <link> tags to Google Fonts (unreachable offline), marks the page as
// served by the device (<meta name="followme-on-device">), gzips it at level
// 9 and writes it as a C++ byte array. Exits non-zero if a referenced font
// file is missing or a Google Fonts reference remains.
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gzipSync, constants } from 'node:zlib';
const WIZARD_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
const ROOT = join(WIZARD_DIR, '..');
const SRC = join(WIZARD_DIR, 'wizard.html');
const OUT = join(ROOT, 'firmware', 'follow-me-wizard.h');
const MIME = { woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf' };
function fail(msg) {
console.error(`embed: ${msg}`);
process.exit(1);
}
let html = readFileSync(SRC, 'utf8');
// Google Fonts <link> tags (stylesheet and preconnect).
html = html.replace(/[ \t]*<link\b[^>]*fonts\.(?:googleapis|gstatic)\.com[^>]*>[ \t]*\r?\n?/gi, '');
if (/fonts\.(?:googleapis|gstatic)\.com/i.test(html)) fail('wizard.html still references Google Fonts outside a <link> tag');
// url(fonts/<file>), quoted or not.
const fonts = new Set();
html = html.replace(/url\(\s*(['"]?)(?:\.\/)?fonts\/([^'")\s]+)\1\s*\)/g, (_, _q, file) => {
const path = join(WIZARD_DIR, 'fonts', file);
if (!existsSync(path)) fail(`font file missing: ${relative(ROOT, path)}`);
const ext = file.split('.').pop().toLowerCase();
if (!MIME[ext]) fail(`unknown font type: ${file}`);
fonts.add(file);
return `url(data:${MIME[ext]};base64,${readFileSync(path).toString('base64')})`;
});
const META = '<meta name="followme-on-device" content="1">';
const charset = /<meta charset="utf-8">/i;
if (!charset.test(html)) fail('wizard.html has no <meta charset="utf-8">');
html = html.replace(charset, (m) => `${m}\n${META}`);
const raw = Buffer.from(html, 'utf8');
const gz = gzipSync(raw, { level: constants.Z_BEST_COMPRESSION });
const lines = [];
for (let i = 0; i < gz.length; i += 16)
lines.push(' ' + [...gz.subarray(i, i + 16)].map((b) => '0x' + b.toString(16).padStart(2, '0')).join(', ') + ',');
const header = `#pragma once
// Generated by wizard/tools/embed.mjs; do not edit. Regenerate after changing
// wizard/wizard.html or wizard/fonts: node wizard/tools/embed.mjs
//
// The setup wizard page with its fonts inlined, gzipped (${raw.length} bytes raw).
#include <cstddef>
#include <cstdint>
namespace follow_me {
static const uint8_t WIZARD_HTML_GZ[] = {
${lines.join('\n')}
};
static const size_t WIZARD_HTML_GZ_LEN = ${gz.length};
} // namespace follow_me
`;
writeFileSync(OUT, header);
console.log(`fonts inlined: ${fonts.size ? [...fonts].join(', ') : 'none'}`);
console.log(`raw ${raw.length} bytes, gzipped ${gz.length} bytes -> ${relative(ROOT, OUT)}`);
+24
View File
@@ -0,0 +1,24 @@
// Serves the wizard folder on the LAN so a phone can open it.
// node wizard/tools/serve.mjs [port]
// Then open http://<this machine>:<port>/ on the phone and enter the sensor's
// address in the bar that opens (or add ?host=<address>).
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const port = +(process.argv[2] || 8080);
const types = {'.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json',
'.css': 'text/css', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8'};
http.createServer((req, res) => {
const p = decodeURIComponent(new URL(req.url, 'http://x').pathname);
const f = path.join(root, p === '/' ? 'wizard.html' : p);
if (!f.startsWith(root + path.sep)) { res.writeHead(403).end(); return; }
fs.readFile(f, (err, data) => {
if (err) { res.writeHead(404).end('not found'); return; }
res.writeHead(200, {'Content-Type': types[path.extname(f)] || 'application/octet-stream', 'Cache-Control': 'no-store'}).end(data);
console.log(new Date().toLocaleTimeString(), req.socket.remoteAddress, p);
});
}).listen(port, '0.0.0.0', () => console.log(`serving ${root} on port ${port}`));
+97
View File
@@ -0,0 +1,97 @@
[
{
"eval": "localStorage.clear(); location.reload(); 1",
"wait": 2500
},
{
"eval": "document.querySelector('[data-shape=\"L\"]').click(); 1",
"wait": 500
},
{
"eval": "document.getElementById('next').click(); 1",
"wait": 400
},
{
"eval": "document.querySelector('[data-inc=\"s0\"]').click(); document.querySelector('[data-inc=\"s1\"]').click(); document.querySelector('[data-inc=\"w\"]').click(); 1",
"wait": 400
},
{
"eval": "document.getElementById('next').click(); 1",
"wait": 400
},
{
"eval": "document.querySelector('[data-side]').click(); document.querySelector('[data-start=\"bottom\"]').click(); 1",
"wait": 400
},
{
"eval": "document.getElementById('next').click(); 1",
"wait": 400
},
{
"eval": "document.getElementById('walkTap').click(); 1",
"wait": 3000
},
{
"eval": "document.getElementById('walkTap').click(); 1",
"wait": 9000
},
{
"eval": "document.getElementById('walkTap').click(); 1",
"wait": 9000
},
{
"eval": "document.getElementById('walkTap').click(); 1",
"wait": 9000
},
{
"eval": "document.getElementById('walkTap').click(); 1",
"wait": 9000
},
{
"eval": "document.getElementById('walkTap').click(); 1",
"wait": 1500,
"shot": "l-walk.png"
},
{
"eval": "document.getElementById('next').click(); 1",
"wait": 600
},
{
"eval": "const h = document.getElementById('wledHost'); h.value = 'wled-stairs.local'; h.dispatchEvent(new Event('change')); 1",
"wait": 5000
},
{
"eval": "const e = document.getElementById('ledCount'); e.value = 150; e.dispatchEvent(new Event('change')); 1",
"wait": 600
},
{
"eval": "document.getElementById('pin').click(); 1",
"wait": 300
},
{
"eval": "document.getElementById('pin').click(); 1",
"wait": 300
},
{
"eval": "document.getElementById('pin').click(); 1",
"wait": 300
},
{
"eval": "document.getElementById('pin').click(); 1",
"wait": 300
},
{
"eval": "document.getElementById('next').textContent + ' | disabled=' + document.getElementById('next').disabled",
"wait": 200,
"shot": "l-match.png"
},
{
"eval": "document.getElementById('next').click(); 1",
"wait": 1500
},
{
"eval": "document.getElementById('toast').textContent + ' | screen=' + document.getElementById('title').textContent",
"wait": 3000,
"shot": "l-test.png"
}
]
+81
View File
@@ -0,0 +1,81 @@
[
{
"eval": "localStorage.clear(); location.reload(); 1",
"wait": 2500
},
{
"eval": "document.querySelector('[data-shape=\"straight\"]').click(); 1",
"wait": 500
},
{
"eval": "document.getElementById('next').click(); 1",
"wait": 400
},
{
"eval": "document.querySelector('[data-inc=\"s0\"]').click(); document.querySelector('[data-inc=\"w\"]').click(); 1",
"wait": 400
},
{
"eval": "document.getElementById('next').click(); 1",
"wait": 400
},
{
"eval": "document.querySelector('[data-side]').click(); document.querySelector('[data-start=\"bottom\"]').click(); 1",
"wait": 400
},
{
"eval": "document.getElementById('next').click(); 1",
"wait": 400
},
{
"eval": "document.getElementById('walkTap').click(); 1",
"wait": 3000
},
{
"eval": "document.getElementById('walkTap').click(); 1",
"wait": 9000
},
{
"eval": "document.getElementById('walkTap').click(); 1",
"wait": 9000
},
{
"eval": "document.getElementById('walkTap').click(); 1",
"wait": 1500,
"shot": "straight-walk.png"
},
{
"eval": "document.getElementById('next').click(); 1",
"wait": 600
},
{
"eval": "const h = document.getElementById('wledHost'); h.value = 'wled-stairs.local'; h.dispatchEvent(new Event('change')); 1",
"wait": 5000
},
{
"eval": "const e = document.getElementById('ledCount'); e.value = 150; e.dispatchEvent(new Event('change')); 1",
"wait": 600
},
{
"eval": "document.getElementById('pin').click(); 1",
"wait": 300
},
{
"eval": "document.getElementById('pin').click(); 1",
"wait": 300
},
{
"eval": "document.getElementById('next').textContent + ' | disabled=' + document.getElementById('next').disabled",
"wait": 200,
"shot": "straight-match.png"
},
{
"eval": "document.getElementById('next').click(); 1",
"wait": 1500
},
{
"eval": "document.getElementById('toast').textContent + ' | screen=' + document.getElementById('title').textContent",
"wait": 3000,
"shot": "straight-test.png"
}
]
+97
View File
@@ -0,0 +1,97 @@
[
{
"eval": "localStorage.clear(); location.reload(); 1",
"wait": 2500
},
{
"eval": "document.querySelector('[data-shape=\"U\"]').click(); 1",
"wait": 500
},
{
"eval": "document.getElementById('next').click(); 1",
"wait": 400
},
{
"eval": "document.querySelector('[data-inc=\"s0\"]').click(); document.querySelector('[data-inc=\"s1\"]').click(); document.querySelector('[data-inc=\"w\"]').click(); 1",
"wait": 400
},
{
"eval": "document.getElementById('next').click(); 1",
"wait": 400
},
{
"eval": "document.querySelector('[data-side]').click(); document.querySelector('[data-start=\"bottom\"]').click(); 1",
"wait": 400
},
{
"eval": "document.getElementById('next').click(); 1",
"wait": 400
},
{
"eval": "document.getElementById('walkTap').click(); 1",
"wait": 3000
},
{
"eval": "document.getElementById('walkTap').click(); 1",
"wait": 9000
},
{
"eval": "document.getElementById('walkTap').click(); 1",
"wait": 9000
},
{
"eval": "document.getElementById('walkTap').click(); 1",
"wait": 9000
},
{
"eval": "document.getElementById('walkTap').click(); 1",
"wait": 9000
},
{
"eval": "document.getElementById('walkTap').click(); 1",
"wait": 1500,
"shot": "u-walk.png"
},
{
"eval": "document.getElementById('next').click(); 1",
"wait": 600
},
{
"eval": "const h = document.getElementById('wledHost'); h.value = 'wled-stairs.local'; h.dispatchEvent(new Event('change')); 1",
"wait": 5000
},
{
"eval": "const e = document.getElementById('ledCount'); e.value = 150; e.dispatchEvent(new Event('change')); 1",
"wait": 600
},
{
"eval": "document.getElementById('pin').click(); 1",
"wait": 300
},
{
"eval": "document.getElementById('pin').click(); 1",
"wait": 300
},
{
"eval": "document.getElementById('pin').click(); 1",
"wait": 300
},
{
"eval": "document.getElementById('pin').click(); 1",
"wait": 300
},
{
"eval": "document.getElementById('next').textContent + ' | disabled=' + document.getElementById('next').disabled",
"wait": 200,
"shot": "u-match.png"
},
{
"eval": "document.getElementById('next').click(); 1",
"wait": 1500
},
{
"eval": "document.getElementById('toast').textContent + ' | screen=' + document.getElementById('title').textContent",
"wait": 3000,
"shot": "u-test.png"
}
]
+1715
View File
File diff suppressed because it is too large. Load diff