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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
24 files changed
+10874
No files matched your search
@@ -0,0 +1,3 @@
|
||||
.esphome/
|
||||
.DS_Store
|
||||
*.png
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Jackson Andres
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,139 @@
|
||||
<!--
|
||||
type: entry point (overview, installation, setup)
|
||||
audience: functional
|
||||
reader: Owner of an Apollo MTR-1 and a WLED strip, with or without Home Assistant. Can flash ESPHome and change WLED settings. Has this repository, their own sensor and their own WLED controller. Does not read the firmware source.
|
||||
owner: follow-me-stairs maintainer
|
||||
last_verified: 2026-10-14. Behaviour, settings and wizard screens checked against the firmware and wizard source. Installation steps checked against example/apollo-mtr-1.yaml, not run on a new device.
|
||||
sources: firmware/, wizard/wizard.html, example/apollo-mtr-1.yaml
|
||||
-->
|
||||
|
||||
# follow-me-stairs
|
||||
|
||||
A stair light that follows you. A radar sensor at the stairs tracks where each person is. An LED strip along the stairs lights the part next to that person, and the lit part moves with them, up or down.
|
||||
|
||||
The parts:
|
||||
|
||||
| Part | Job |
|
||||
|---|---|
|
||||
| Radar sensor (Apollo MTR-1) | Runs this firmware. It tracks people, decides which LEDs to light, and sends the LED colors to WLED. |
|
||||
| WLED controller | Drives the LED strip. It runs standard WLED, with no changes. |
|
||||
| Your phone or computer | Runs the setup wizard once, in a web browser. The sensor serves the wizard itself. |
|
||||
| Home Assistant | Optional. The sensor works without it. |
|
||||
|
||||
The sensor sends the LED colors to WLED directly over your network. Home Assistant is not needed for the light to work.
|
||||
|
||||
## What it does
|
||||
|
||||
- Lights a window of LEDs next to each person on the stairs, and moves it with them.
|
||||
- Follows up to three people at the same time, with one window each.
|
||||
- Lights the end of the stairs when a person comes near it, before they step on.
|
||||
- Shows a moving effect inside the window, for example Rainbow, Christmas or Fireplace.
|
||||
- Lights a dim night light at each end of the stairs, when you turn it on.
|
||||
- Steps aside while you use WLED yourself, and starts again when you turn WLED off.
|
||||
|
||||
All values for your stairs come from the setup wizard: the shape, the number of steps, where the strip runs and the WLED address. The wizard stores them on the sensor. The firmware has no values for any one house.
|
||||
|
||||
## What you need
|
||||
|
||||
- An Apollo MTR-1. Another ESP32 with an LD2450 radar can work, on ESPHome's esp-idf framework. See [Using a different device](#using-a-different-device).
|
||||
- ESPHome 2026.8.2 or newer. Firmware built with ESPHome 2026.3.3 crashes shortly after it starts.
|
||||
- An LED strip along the stairs, on a WLED controller. Follow-me is tested with WLED 16.
|
||||
- The sensor and the WLED controller on the same network.
|
||||
- Straight, L-shaped or U-shaped stairs, with at most one landing.
|
||||
|
||||
## Installing on an Apollo MTR-1
|
||||
|
||||
1. Copy `example/apollo-mtr-1.yaml` into your ESPHome configuration folder.
|
||||
2. Put this repository in the same folder, as a folder named `follow-me-stairs`.
|
||||
3. Add `wifi_ssid` and `wifi_password` to `secrets.yaml` in that folder.
|
||||
4. Install the configuration on the sensor with ESPHome.
|
||||
5. Open `http://<sensor address>/` in a browser.
|
||||
|
||||
The sensor's web page shows the Follow-Me controls. Follow-Me Setup shows "Not set up" until you complete the setup wizard.
|
||||
|
||||
### Using a different device
|
||||
|
||||
Add the follow-me package to the device's own configuration:
|
||||
|
||||
```yaml
|
||||
substitutions:
|
||||
follow_me_dir: <path to this repository>/firmware # relative to the device file
|
||||
|
||||
packages:
|
||||
follow_me: !include <path to this repository>/firmware/follow-me.yaml
|
||||
```
|
||||
|
||||
ESPHome finds the firmware's source files through `follow_me_dir`, from the folder of the device file.
|
||||
|
||||
Follow-me finds the radar's position readings by name. If your radar's target sensors are not named `Target-1 X`, `Target-1 Y` and so on, set these substitutions. In each name, `%d` stands for the target number, 1 to 3.
|
||||
|
||||
| Substitution | Default |
|
||||
|---|---|
|
||||
| `radar_x_name` | `Target-%d X` |
|
||||
| `radar_y_name` | `Target-%d Y` |
|
||||
|
||||
Follow-me adds a web server to the device. If the device configuration already has `web_server:`, ESPHome combines the two.
|
||||
|
||||
## Setting up
|
||||
|
||||
Open the setup wizard from the "Set up Follow-Me" button on the sensor's web page, or at `http://<sensor address>/followme`. Each screen keeps Next off until you answer it.
|
||||
|
||||
1. **Shape:** pick straight, L-shaped or U-shaped. Check which way the stairs turn, and flip it if the drawing is wrong.
|
||||
2. **Steps:** count the steps in each flight, and give the stair width. Do not count a landing or the upper floor.
|
||||
3. **Strip:** tap the wall that the strip is on. Then pick the end where LED 1 is.
|
||||
4. **Walk:** start on the lower floor and walk up once. Stop on each step that the screen marks, and tap. At the end, the screen grades the walk. "Good walk" means you can continue. "Walk it again" names the part of the stairs to walk again.
|
||||
5. **Match:** enter the WLED address. Tap Read to get the LED count and to check WLED's settings. For each mark on the drawing, move the lit LED until it is at that spot on your stairs. The bottom and top marks are required. Then tap "Save to sensor".
|
||||
6. **Test:** walk the stairs and change the sliders until the light is correct for you. The sliders change the sensor's settings directly.
|
||||
|
||||
When the setup is saved, Follow-Me Setup shows Ready and the light follows you.
|
||||
|
||||
To change the setup later, open the wizard again. It loads the setup saved on the sensor.
|
||||
|
||||
## Settings
|
||||
|
||||
All settings are on the sensor's web page. When the sensor is added to Home Assistant, they are also on its device page. Distances are in steps: one step is the average tread depth that the setup wizard measured.
|
||||
|
||||
For ranges, defaults and the full description of each setting, see [docs/settings.md](docs/settings.md).
|
||||
|
||||
| Control | What it does |
|
||||
|---|---|
|
||||
| Follow-Me Glow | The light that follows people. |
|
||||
| Follow-Me Night Light | Lights a patch at each end of the stairs. |
|
||||
| Follow-Me Window | The length of the lit window. |
|
||||
| Follow-Me Buffer | How close the person gets to either end of the window before the window moves. |
|
||||
| Follow-Me Offset | Moves the window ahead of the person, in the direction they walk. |
|
||||
| Follow-Me Approach | A person off the stairs within this distance of either end lights that end of the stairs. |
|
||||
| Follow-Me Smoothness | How closely the window follows the radar. |
|
||||
| Follow-Me Hold | How long the window stays lit after the radar loses a person on the stairs. |
|
||||
| Follow-Me Fade In | The fade-in time of a window that starts away from the ends of the stairs. |
|
||||
| Follow-Me Fade Out | The fade-out time after Follow-Me Hold ends. |
|
||||
| Follow-Me Night Light Size | The length of each night light patch. |
|
||||
| Follow-Me Multi-Person | Off: one window follows one person. On: up to three windows follow up to three people. |
|
||||
| Follow-Me Pause While WLED On | Lets WLED take over the strip while WLED itself is on. |
|
||||
| Follow-Me RGB White | For strips with no white LED only. Sets how the sensor mixes white from red, green and blue: Warm, Neutral or Cool. |
|
||||
| Follow-Me Setup | What follow-me needs before it can work. |
|
||||
| Follow-Me Paused | On while WLED itself is on. |
|
||||
| Follow-Me Position | Where the followed person is, in steps down from the top edge of the top step. |
|
||||
|
||||
## WLED
|
||||
|
||||
WLED needs some settings changed before it accepts the light from the sensor. The setup wizard checks them on the Match screen, and "Fix WLED settings" changes them. For the list, see [wled/README.md](wled/README.md).
|
||||
|
||||
## Known limits
|
||||
|
||||
- Only strips with a white LED (RGBW) are tested on hardware.
|
||||
- Stairs with more than one landing, winders and spiral stairs are not currently supported.
|
||||
- A WLED address given as a `.local` name works only if the sensor can resolve it. An IP address with a DHCP reservation always works.
|
||||
- The sensor's web page and the setup wizard have no password. Anyone on your network can open them.
|
||||
|
||||
## More documents
|
||||
|
||||
| Document | Contents |
|
||||
|---|---|
|
||||
| [docs/settings.md](docs/settings.md) | Every setting: what it does, its range and its default. What each Follow-Me Setup status means. |
|
||||
| [wled/README.md](wled/README.md) | The WLED settings that follow-me needs. |
|
||||
| [docs/developing.md](docs/developing.md) | How the repository is organized, and how to change and test the wizard and the firmware. |
|
||||
|
||||
## License
|
||||
|
||||
MIT, see `LICENSE`. IBM Plex fonts: SIL Open Font License 1.1, see `wizard/fonts/OFL.txt`.
|
||||
@@ -0,0 +1,94 @@
|
||||
<!--
|
||||
type: reference
|
||||
audience: technical
|
||||
reader: Someone changing the firmware or the setup wizard. Has this repository, Node 22 or newer, Google Chrome and ESPHome.
|
||||
owner: follow-me-stairs maintainer
|
||||
last_verified: 2026-10-14. Commands run from the repository root. File contents checked against the source.
|
||||
sources: firmware/, wizard/, example/
|
||||
-->
|
||||
|
||||
# Developing follow-me-stairs
|
||||
|
||||
This document covers how the repository is organized, the tools for the setup wizard, and facts the firmware and wizard depend on. For installation and settings, see the [README](../README.md).
|
||||
|
||||
## Repository layout
|
||||
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `example/apollo-mtr-1.yaml` | Complete device configuration: Apollo's MTR-1 configuration, pinned to a release in Apollo's repository, plus the follow-me package |
|
||||
| `firmware/follow-me.yaml` | The ESPHome package: tracking, the window, effects, night light, WLED pause, and the setup button on the sensor's web page. Its header comment documents the behaviour in detail. |
|
||||
| `firmware/follow-me.h` | WLED connection: a background task for address lookup, LED count, strip type and WLED on/off, and the DDP sender |
|
||||
| `firmware/follow-me-calibration.h` | The stored setup, the stair geometry built from it, and the sensor's `/followme` web addresses |
|
||||
| `firmware/follow-me-wizard.h` | Generated by `wizard/tools/embed.mjs`. The wizard page with its fonts, gzipped. Do not edit it by hand. |
|
||||
| `firmware/follow-me-button.js` | Adds the "Set up Follow-Me" button to the sensor's web page, through `web_server` `js_include` |
|
||||
| `firmware/follow-me-effects.h` | The per-LED effect patterns. It compiles on a desktop for previews. |
|
||||
| `wled/README.md` | The WLED settings that follow-me needs |
|
||||
| `wizard/wizard.html` | The setup wizard, as one page |
|
||||
| `wizard/fonts/` | IBM Plex Sans and Mono, Latin subset, SIL OFL 1.1 |
|
||||
| `wizard/tools/embed.mjs` | Builds `firmware/follow-me-wizard.h` from the wizard and its fonts |
|
||||
| `wizard/tools/serve.mjs` | Serves the wizard from a computer on the local network |
|
||||
| `wizard/tools/drive.mjs` | Runs the wizard in headless Chrome from a list of steps, and saves screenshots |
|
||||
| `wizard/tools/walk-*.json` | Step lists for `drive.mjs`: one complete setup with the simulated walker per stair shape |
|
||||
|
||||
## Changing the wizard
|
||||
|
||||
After a change to `wizard/wizard.html` or `wizard/fonts/`, rebuild the embedded page, then build and install the firmware again:
|
||||
|
||||
```sh
|
||||
node wizard/tools/embed.mjs
|
||||
```
|
||||
|
||||
To serve the wizard from a computer, run this from the repository root:
|
||||
|
||||
```sh
|
||||
node wizard/tools/serve.mjs 8080
|
||||
```
|
||||
|
||||
Then open `http://<computer address>:8080/` on a phone, and enter the sensor's address in the bar at the top. To use the simulated walker instead of the radar, add `?sim=1` to the address.
|
||||
|
||||
To run a complete setup without a phone, run this while `serve.mjs` is running:
|
||||
|
||||
```sh
|
||||
node wizard/tools/drive.mjs "http://127.0.0.1:8080/?sim=1" 1280 820 wizard/tools/walk-l.json
|
||||
```
|
||||
|
||||
`drive.mjs` saves the screenshots named in the step list to the current folder, and prints "no page errors" when the page raised no errors. It uses Google Chrome at its macOS path. To use another Chrome, set `CHROME` to its path.
|
||||
|
||||
## Walk grading
|
||||
|
||||
The Walk screen grades each stretch of the walk: each flight, and the landing. A stretch fails when one of these is true:
|
||||
|
||||
| Check | Fails when |
|
||||
|---|---|
|
||||
| Coverage | The radar has no reading for 3 steps or more |
|
||||
| Drift | More than 25% of the stretch's readings are outside the stairs, farther than half the stair width from the walking line |
|
||||
| Step spacing | A flight's average step is less than half or more than twice 260 mm, or more than twice the other flight's |
|
||||
|
||||
The limits are constants at the top of the walk report section in `wizard/wizard.html`. A setup loaded from the sensor has no radar readings, so only step spacing is checked. "Show raw numbers" shows the walk in the radar's own frame, in mm.
|
||||
|
||||
## Firmware facts
|
||||
|
||||
- Frames go to WLED over DDP, UDP port 4048. Each LED takes 4 bytes on strips with a white LED and 3 bytes on other strips, as WLED reports. A frame is split over as many packets as the strip needs, for strips up to 1500 LEDs.
|
||||
- LED numbers are raw indexes on the WLED controller, the same numbers DDP uses. WLED's segments do not change them.
|
||||
- Where the strip has a gap, for example a jumper across a landing, the firmware finds it from the LED spacing. A window that crosses the gap lights both sides.
|
||||
- The setup is stored in the sensor's flash. A change to the size of the stored setup erases it, and the setup wizard must be run again.
|
||||
|
||||
## Web addresses on the sensor
|
||||
|
||||
| Address | Method | Use |
|
||||
|---|---|---|
|
||||
| `/followme` | GET | The setup wizard |
|
||||
| `/followme/calibration` | GET, POST | The stored setup |
|
||||
| `/followme/targets` | GET | The radar's raw targets, for the wizard's live view |
|
||||
| `/followme/preview` | POST | Lights one LED, for the Match screen |
|
||||
|
||||
These addresses have no authentication. Any device on the network can use them.
|
||||
|
||||
## LD2450 facts
|
||||
|
||||
- The LD2450 reports target x mirrored. The stored setup keeps the radar's own frame. Code that draws radar readings or decides left and right must negate x.
|
||||
- The LD2450 sees about 60° to each side and about 6 m out. The wizard warns when part of the stairs is outside that area.
|
||||
|
||||
## Not built
|
||||
|
||||
- Installing a prebuilt firmware from a web browser.
|
||||
@@ -0,0 +1,79 @@
|
||||
<!--
|
||||
type: reference
|
||||
audience: functional
|
||||
reader: Owner of an Apollo MTR-1 and a WLED strip, with or without Home Assistant. Can flash ESPHome and change WLED settings. Has this repository, their own sensor and their own WLED controller. Does not read the firmware source.
|
||||
owner: follow-me-stairs maintainer
|
||||
last_verified: 2026-10-14. Names, ranges, defaults and behaviour checked against firmware/follow-me.yaml and firmware/follow-me.h. Not checked: how each control renders on the sensor's web page.
|
||||
sources: firmware/follow-me.yaml, firmware/follow-me.h, firmware/follow-me-calibration.h
|
||||
-->
|
||||
|
||||
# Follow-Me settings
|
||||
|
||||
This document lists every control that follow-me adds to the sensor. For installation and setup, see the [README](../README.md).
|
||||
|
||||
## Where the settings are
|
||||
|
||||
| Place | Contents |
|
||||
|---|---|
|
||||
| The sensor's web page, `http://<sensor address>/` | Every control in this document |
|
||||
| Home Assistant, on the sensor's device page | Every control in this document, when the sensor is added to Home Assistant |
|
||||
| The Test screen of the setup wizard | Window, Buffer, Smoothness, Hold, Offset and Approach |
|
||||
|
||||
A change applies immediately. The sensor keeps every setting after a restart.
|
||||
|
||||
## Steps
|
||||
|
||||
Distances are in steps. One step is the average tread depth that the setup wizard measured on your stairs. A Window of 3 steps lights about three treads on any staircase.
|
||||
|
||||
## Lights
|
||||
|
||||
| Control | What it does | Default |
|
||||
|---|---|---|
|
||||
| Follow-Me Glow | The light that follows people. Off turns follow-me off. It sets the brightness, color, white level and effect of the window. | On, white only, 40% |
|
||||
| Follow-Me Night Light | Lights a patch at each end of the stairs. It works when Follow-Me Glow is on or off. | Off. When turned on: white only, 5% |
|
||||
|
||||
Follow-Me Glow effects: Rainbow, New Year's, Valentine's, St. Patrick's, Easter, Fourth of July, Autumn, Halloween, Haunted, Hanukkah, Christmas, Candy Cane, Snowfall, Fireplace, Aurora, Ocean. An effect replaces the color with a moving pattern, inside the window. The pattern stays fixed to the stairs, and the person walks through it. When Follow-Me Glow turns off, the effect goes back to None.
|
||||
|
||||
Where the window and the night light overlap, each LED shows the brighter of the two.
|
||||
|
||||
## Tuning
|
||||
|
||||
| Control | What it does | Range | Default |
|
||||
|---|---|---|---|
|
||||
| Follow-Me Window | The length of the lit window. | 1 to 15 steps | 3 steps |
|
||||
| Follow-Me Buffer | How close the person gets to either end of the window before the window moves. The window stays still while the person is inside it. When the person comes within this distance of either end of the window, the window slides to keep this distance. | 0 to 5 steps | 1 step |
|
||||
| Follow-Me Offset | Moves the window ahead of the person, in the direction they walk. A negative value moves it behind. The shift grows with walking speed, so a person who stands still gets no shift. | -3 to 3 steps | 0 steps |
|
||||
| Follow-Me Approach | A person off the stairs within this distance of either end lights that end of the stairs. The sensor ignores people farther away. | 0 to 6 steps | 2 steps |
|
||||
| Follow-Me Smoothness | How closely the window follows the radar. 0% follows each radar reading. 100% moves slowly and smoothly. | 0 to 100% | 50% |
|
||||
| Follow-Me Hold | How long the window stays lit after the radar loses a person on the stairs. Then the window fades out. | 0 to 60 s | 4 s |
|
||||
| Follow-Me Fade In | The fade-in time of a window that starts away from the ends of the stairs. A window that starts at either end slides in from that end in 0.25 s. | 0.1 to 10 s | 1.5 s |
|
||||
| Follow-Me Fade Out | The fade-out time after Follow-Me Hold ends. When a person walks off either end of the stairs, the window shrinks toward that end and fades out in 1 s. | 0.1 to 10 s | 3 s |
|
||||
| Follow-Me Night Light Size | The length of each night light patch. | 0.5 to 8 steps | 2 steps |
|
||||
|
||||
## Switches and choices
|
||||
|
||||
| Control | What it does | Default |
|
||||
|---|---|---|
|
||||
| Follow-Me Multi-Person | Off: one window follows one person. On: up to three windows follow up to three people. With it on, a new window starts only near either end of the stairs. | Off |
|
||||
| Follow-Me Pause While WLED On | Lets WLED take over the strip while WLED itself is on. On: while WLED itself is on, follow-me sends nothing and the strip shows WLED's own state. WLED can be turned on from its app, its button or Home Assistant. Follow-me starts again when WLED turns off. Off: follow-me lights the strip when WLED is on or off. | On |
|
||||
| Follow-Me RGB White | For strips with no white LED only. Sets how the sensor mixes white from red, green and blue: Warm, Neutral or Cool. It applies to Follow-Me Glow and Follow-Me Night Light. | Warm |
|
||||
|
||||
## Status
|
||||
|
||||
These controls are read-only.
|
||||
|
||||
| Control | What it shows |
|
||||
|---|---|
|
||||
| Follow-Me Setup | What follow-me needs before it can work. See the table below. |
|
||||
| Follow-Me Paused | On while WLED itself is on. When the sensor cannot reach WLED, it shows off. |
|
||||
| Follow-Me Position | Where the followed person is, in steps down from the top edge of the top step. Empty when nobody is followed. It updates every second. |
|
||||
|
||||
| Follow-Me Setup value | Meaning | What to do |
|
||||
|---|---|---|
|
||||
| Ready | Follow-me is working. | Nothing. |
|
||||
| Not set up | The setup wizard has not saved a setup to this sensor. | Open the setup wizard and complete it. |
|
||||
| Radar not found | The sensor cannot find the radar's position readings by name. | Set `radar_x_name` and `radar_y_name` in the device configuration to the names of the radar's target X and Y sensors. See the [README](../README.md#using-a-different-device). |
|
||||
| WLED not found | The sensor cannot reach WLED at the address saved by the setup wizard. | Make sure WLED is on the network at that address. To change the address, open the setup wizard and go to the Match screen. |
|
||||
| No web server | The device configuration has no web server, so the setup wizard cannot run. | Add `web_server:` to the device configuration and install it again. |
|
||||
|
||||
Until Follow-Me Setup shows Ready, the strip stays dark.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Apollo MTR-1 with the follow-me stair light.
|
||||
#
|
||||
# Apollo's stock MTR-1 configuration (pulled from Apollo's repository at a
|
||||
# fixed release) plus the follow-me package from this repository. Put this
|
||||
# file in the ESPHome config folder with this repository checked out beside it
|
||||
# as follow-me-stairs/, add wifi_ssid and wifi_password to secrets.yaml, and
|
||||
# install. Then open http://<device>/followme (or the "Set up Follow-Me"
|
||||
# button on the device's web page) to run the setup wizard.
|
||||
#
|
||||
# The name keeps Apollo's MAC suffix, so a device already adopted in Home
|
||||
# Assistant keeps its entities.
|
||||
|
||||
substitutions:
|
||||
name: apollo-mtr-1
|
||||
version: "26.3.2.1"
|
||||
follow_me_dir: follow-me-stairs/firmware
|
||||
|
||||
esphome:
|
||||
name: "${name}"
|
||||
friendly_name: Apollo MTR-1
|
||||
comment: Apollo MTR-1
|
||||
name_add_mac_suffix: true
|
||||
project:
|
||||
name: "ApolloAutomation.MTR-1"
|
||||
version: "${version}"
|
||||
min_version: 2026.8.2
|
||||
on_boot:
|
||||
- priority: 500
|
||||
then:
|
||||
- text_sensor.template.publish:
|
||||
id: apollo_firmware_version
|
||||
state: "${version}"
|
||||
- priority: -10
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
- lambda: "return id(runTest);"
|
||||
then:
|
||||
- lambda: "id(testScript).execute();"
|
||||
|
||||
logger:
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
id: ota_default
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
ap:
|
||||
ssid: "Apollo MTR1 Hotspot"
|
||||
|
||||
captive_portal:
|
||||
|
||||
packages:
|
||||
core: github://ApolloAutomation/MTR-1/Integrations/ESPHome/Core.yaml@26.3.2.1
|
||||
follow_me: !include follow-me-stairs/firmware/follow-me.yaml
|
||||
@@ -0,0 +1,29 @@
|
||||
// Adds a "Set up Follow-Me" link to ESPHome's stock web page, opening the
|
||||
// setup wizard the device serves at /followme. Loaded by the stock page as
|
||||
// /0.js (web_server js_include in follow-me.yaml).
|
||||
(() => {
|
||||
const ID = 'followme-setup-link';
|
||||
const add = () => {
|
||||
if (document.getElementById(ID)) return;
|
||||
const a = document.createElement('a');
|
||||
a.id = ID;
|
||||
a.href = '/followme';
|
||||
a.textContent = 'Set up Follow-Me';
|
||||
a.style.cssText = [
|
||||
'position:fixed',
|
||||
'right:20px',
|
||||
'bottom:20px',
|
||||
'z-index:9999',
|
||||
'padding:10px 16px',
|
||||
'background:#0e0e10',
|
||||
'color:#ece8df',
|
||||
'border:1px solid rgba(236,232,223,.35)',
|
||||
'border-radius:3px',
|
||||
"font:500 14px/1.2 'IBM Plex Sans',system-ui,sans-serif",
|
||||
'text-decoration:none',
|
||||
].join(';');
|
||||
document.body.appendChild(a);
|
||||
};
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', add);
|
||||
else add();
|
||||
})();
|
||||
@@ -0,0 +1,630 @@
|
||||
#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
|
||||
@@ -0,0 +1,389 @@
|
||||
#pragma once
|
||||
// Effects for the follow-me glow (follow-me.yaml).
|
||||
//
|
||||
// Each effect paints one LED's colour inside a lit window; the renderer then
|
||||
// scales it by the Follow-Me Glow brightness and the window's coverage and
|
||||
// fade, exactly as it scales the plain colour. Patterns are fixed to the
|
||||
// stairs (wall inches), not to the window, so a person walks through them.
|
||||
// Wall inches increase down the stairs; "falling" patterns move toward larger
|
||||
// wall values, "rising" ones toward smaller.
|
||||
//
|
||||
// Colours are written as perceptual (sRGB-like) values 0-1 per R, G, B and
|
||||
// the strip's white channel; paint() squares them (gamma 2) so
|
||||
// saturated mixes such as orange stay orange on the LEDs.
|
||||
//
|
||||
// Nothing here depends on ESPHome, so the file also compiles on a desktop for
|
||||
// previews. EFFECT_NAMES must match the effect names on the Follow-Me Glow
|
||||
// light in follow-me.yaml.
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace follow_me {
|
||||
namespace fx {
|
||||
|
||||
enum Effect {
|
||||
NONE = 0,
|
||||
RAINBOW,
|
||||
NEW_YEARS,
|
||||
VALENTINES,
|
||||
ST_PATRICKS,
|
||||
EASTER,
|
||||
FOURTH_OF_JULY,
|
||||
AUTUMN,
|
||||
HALLOWEEN,
|
||||
HAUNTED,
|
||||
HANUKKAH,
|
||||
CHRISTMAS,
|
||||
CANDY_CANE,
|
||||
SNOWFALL,
|
||||
FIREPLACE,
|
||||
AURORA,
|
||||
OCEAN,
|
||||
EFFECT_COUNT,
|
||||
};
|
||||
|
||||
static const char *const EFFECT_NAMES[EFFECT_COUNT] = {
|
||||
"None", "Rainbow", "New Year's", "Valentine's", "St. Patrick's", "Easter",
|
||||
"Fourth of July", "Autumn", "Halloween", "Haunted", "Hanukkah", "Christmas",
|
||||
"Candy Cane", "Snowfall", "Fireplace", "Aurora", "Ocean",
|
||||
};
|
||||
|
||||
// Length of the stairs in wall inches, for patterns that span them
|
||||
// (Snowfall). Set by the follow-me loop every frame.
|
||||
inline float extent = 240.0f;
|
||||
inline void set_extent(float wall_inches) { extent = fmaxf(wall_inches, 24.0f); }
|
||||
|
||||
inline Effect from_name(const char *name, size_t len) {
|
||||
for (int i = 1; i < EFFECT_COUNT; i++) {
|
||||
if (strlen(EFFECT_NAMES[i]) == len && strncmp(EFFECT_NAMES[i], name, len) == 0) return (Effect) i;
|
||||
}
|
||||
return NONE;
|
||||
}
|
||||
|
||||
struct Rgbw {
|
||||
float r, g, b, w;
|
||||
};
|
||||
|
||||
inline Rgbw scale(const Rgbw &c, float k) { return {c.r * k, c.g * k, c.b * k, c.w * k}; }
|
||||
inline Rgbw mix(const Rgbw &a, const Rgbw &b, float f) {
|
||||
return {a.r + (b.r - a.r) * f, a.g + (b.g - a.g) * f, a.b + (b.b - a.b) * f, a.w + (b.w - a.w) * f};
|
||||
}
|
||||
inline Rgbw lighten(const Rgbw &a, const Rgbw &b) { // per-channel max
|
||||
return {fmaxf(a.r, b.r), fmaxf(a.g, b.g), fmaxf(a.b, b.b), fmaxf(a.w, b.w)};
|
||||
}
|
||||
|
||||
inline float clamp01(float x) { return x < 0.0f ? 0.0f : (x > 1.0f ? 1.0f : x); }
|
||||
inline float smoothstep(float x) {
|
||||
x = clamp01(x);
|
||||
return x * x * (3.0f - 2.0f * x);
|
||||
}
|
||||
inline float fract(float x) { return x - floorf(x); }
|
||||
|
||||
inline uint32_t hash(uint32_t x) {
|
||||
x ^= x >> 16;
|
||||
x *= 0x7feb352du;
|
||||
x ^= x >> 15;
|
||||
x *= 0x846ca68bu;
|
||||
x ^= x >> 16;
|
||||
return x;
|
||||
}
|
||||
// Deterministic random 0-1 for a (seed, n) pair.
|
||||
inline float rnd(uint32_t seed, uint32_t n) { return (hash(n * 0x9e3779b1u ^ hash(seed + 0x632be5abu)) >> 8) / 16777216.0f; }
|
||||
|
||||
// Smooth 1D value noise, 0-1, one feature per unit of x.
|
||||
inline float noise(uint32_t seed, float x) {
|
||||
const float i = floorf(x);
|
||||
const int32_t n = (int32_t) i;
|
||||
return rnd(seed, (uint32_t) n) + (rnd(seed, (uint32_t) (n + 1)) - rnd(seed, (uint32_t) n)) * smoothstep(x - i);
|
||||
}
|
||||
|
||||
// Smooth 2D value noise, 0-1, one feature per unit of x and of y.
|
||||
inline float noise2(uint32_t seed, float x, float y) {
|
||||
const float j = floorf(y);
|
||||
const int32_t m = (int32_t) j;
|
||||
const float a = noise(seed ^ hash((uint32_t) m), x), b = noise(seed ^ hash((uint32_t) (m + 1)), x);
|
||||
return a + (b - a) * smoothstep(y - j);
|
||||
}
|
||||
|
||||
// Soft-edged stripes: 1 for the first `duty` of each `period`, 0 for the
|
||||
// rest, with linear edges `soft` units wide (anti-aliased between LEDs).
|
||||
inline float stripe(float s, float period, float duty, float soft) {
|
||||
const float p = fract(s / period) * period;
|
||||
const float on = duty * period;
|
||||
const float h = soft / 2.0f;
|
||||
const float rise = clamp01((p + h) / soft); // edge at p = 0, wrapped below
|
||||
const float fall = clamp01((on + h - p) / soft);
|
||||
const float wrap = clamp01((p - (period - h)) / soft); // edge at p = period
|
||||
return fmaxf(fminf(rise, fall), wrap);
|
||||
}
|
||||
|
||||
// Blocks of `size` units, each a random entry of the palette, blended over the
|
||||
// last `blend` fraction of each block.
|
||||
inline Rgbw blocks(float s, float size, uint32_t seed, const Rgbw *pal, int n, float blend) {
|
||||
const float u = s / size;
|
||||
const float i = floorf(u);
|
||||
const int32_t k = (int32_t) i;
|
||||
const Rgbw a = pal[hash(seed ^ hash((uint32_t) k)) % n];
|
||||
const Rgbw b = pal[hash(seed ^ hash((uint32_t) (k + 1))) % n];
|
||||
return mix(a, b, smoothstep((u - i - (1.0f - blend)) / blend));
|
||||
}
|
||||
|
||||
// Twinkle envelope 0-1 for one LED: in each `period` seconds the LED flashes
|
||||
// with probability `chance` (fast rise, slower fall). *slot_out is the period
|
||||
// index, for picking the twinkle's colour.
|
||||
inline float twinkle(int led, float t, float period, float chance, uint32_t seed, uint32_t *slot_out = nullptr) {
|
||||
const float u = t / period + rnd(seed, (uint32_t) led);
|
||||
const uint32_t slot = (uint32_t) floorf(u);
|
||||
if (slot_out) *slot_out = slot;
|
||||
if (rnd(seed ^ 0x5bd1e995u, slot * 1031u + (uint32_t) led) > chance) return 0.0f;
|
||||
const float f = u - floorf(u);
|
||||
const float attack = 0.12f;
|
||||
if (f < attack) return f / attack;
|
||||
const float d = 1.0f - (f - attack) / (1.0f - attack);
|
||||
return d * d;
|
||||
}
|
||||
|
||||
inline Rgbw hsv(float h, float s, float v) {
|
||||
h = fract(h) * 6.0f;
|
||||
const int i = (int) h;
|
||||
const float f = h - i;
|
||||
const float p = v * (1.0f - s), q = v * (1.0f - s * f), t = v * (1.0f - s * (1.0f - f));
|
||||
switch (i) {
|
||||
case 0: return {v, t, p, 0};
|
||||
case 1: return {q, v, p, 0};
|
||||
case 2: return {p, v, t, 0};
|
||||
case 3: return {p, q, v, 0};
|
||||
case 4: return {t, p, v, 0};
|
||||
default: return {v, p, q, 0};
|
||||
}
|
||||
}
|
||||
|
||||
// Firework burst: every `period` seconds a ring expands from a random point
|
||||
// inside the window and fades. Returns 0-1; *slot_out picks its colour.
|
||||
inline float burst(float wall, float t, float period, uint32_t seed, float focus, float span, uint32_t *slot_out) {
|
||||
const float u = t / period;
|
||||
const uint32_t slot = (uint32_t) floorf(u);
|
||||
*slot_out = slot;
|
||||
const float f = u - floorf(u);
|
||||
const float life = 0.75f; // fraction of the period the burst is visible
|
||||
if (f > life) return 0.0f;
|
||||
const float age = f / life;
|
||||
const float centre = focus + (rnd(seed, slot) - 0.5f) * span * 0.8f;
|
||||
const float radius = 2.0f + 12.0f * sqrtf(age);
|
||||
const float d = fabsf(wall - centre);
|
||||
if (d > radius) return 0.0f;
|
||||
const float ring = 0.35f + 0.65f * clamp01(1.0f - (radius - d) / 3.0f); // brighter rim
|
||||
const float fade = (1.0f - age) * (1.0f - age);
|
||||
return ring * fade;
|
||||
}
|
||||
|
||||
// Colour of one LED for an effect. led is the LED index, wall the LED's
|
||||
// position in wall inches, t seconds (wraps daily), focus the centre of the
|
||||
// window lighting this LED and span its length in inches.
|
||||
inline Rgbw paint(Effect e, int led, float wall, float t, float focus, float span) {
|
||||
Rgbw c = {0, 0, 0, 0};
|
||||
switch (e) {
|
||||
case NONE:
|
||||
case EFFECT_COUNT:
|
||||
break;
|
||||
|
||||
case RAINBOW:
|
||||
// One full rainbow per 30 in of stairs, drifting slowly downward.
|
||||
c = hsv(wall / 30.0f - t / 12.0f, 1.0f, 1.0f);
|
||||
break;
|
||||
|
||||
case NEW_YEARS: {
|
||||
// Champagne gold with fast silver and gold confetti.
|
||||
static const Rgbw GOLD = {1.0f, 0.62f, 0.12f, 0.35f};
|
||||
static const Rgbw CONFETTI[3] = {{0.55f, 0.6f, 0.9f, 1.0f}, {1.0f, 0.75f, 0.2f, 0.6f}, {0, 0, 0, 1.0f}};
|
||||
c = scale(GOLD, 0.45f + 0.15f * noise(11, wall / 8.0f + t * 0.6f));
|
||||
uint32_t slot;
|
||||
const float tw = twinkle(led, t, 0.7f, 0.35f, 12, &slot);
|
||||
if (tw > 0.0f) c = mix(c, CONFETTI[hash(slot * 7u + led) % 3], tw);
|
||||
break;
|
||||
}
|
||||
|
||||
case VALENTINES: {
|
||||
// Pink and red drifting together, pulsing with a heartbeat.
|
||||
static const Rgbw PINK = {1.0f, 0.3f, 0.55f, 0.1f};
|
||||
static const Rgbw RED = {1.0f, 0.02f, 0.12f, 0};
|
||||
c = mix(PINK, RED, smoothstep(noise(21, wall / 14.0f - t * 0.25f) * 1.6f - 0.3f));
|
||||
const float p = fract(t / 1.4f);
|
||||
const float lub = expf(-p * 14.0f), dub = p > 0.22f ? 0.7f * expf(-(p - 0.22f) * 14.0f) : 0.0f;
|
||||
c = scale(c, 0.5f + 0.5f * fminf(1.0f, lub + dub));
|
||||
break;
|
||||
}
|
||||
|
||||
case ST_PATRICKS: {
|
||||
// Drifting greens with gold glints.
|
||||
static const Rgbw GREENS[3] = {{0, 1.0f, 0.2f, 0}, {0.3f, 1.0f, 0, 0}, {0, 0.85f, 0.45f, 0}};
|
||||
static const Rgbw GOLD = {1.0f, 0.65f, 0, 0.4f};
|
||||
c = blocks(wall - t * 2.0f, 7.0f, 31, GREENS, 3, 0.4f);
|
||||
c = mix(c, GOLD, twinkle(led, t, 1.6f, 0.18f, 32));
|
||||
break;
|
||||
}
|
||||
|
||||
case EASTER: {
|
||||
// Slow pastel blocks.
|
||||
static const Rgbw PASTELS[5] = {{1.0f, 0.4f, 0.6f, 0.15f}, {0.35f, 1.0f, 0.55f, 0.1f}, {0.7f, 0.45f, 1.0f, 0.1f},
|
||||
{1.0f, 0.8f, 0.3f, 0.15f}, {0.4f, 0.7f, 1.0f, 0.1f}};
|
||||
c = blocks(wall - t * 1.5f, 9.0f, 41, PASTELS, 5, 0.5f);
|
||||
break;
|
||||
}
|
||||
|
||||
case FOURTH_OF_JULY: {
|
||||
// Night-sky navy with red, white and blue fireworks around the person.
|
||||
static const Rgbw SKY = {0, 0.02f, 0.3f, 0};
|
||||
static const Rgbw SPARK[3] = {{1.0f, 0.02f, 0.05f, 0}, {0.2f, 0.2f, 0.35f, 1.0f}, {0.15f, 0.35f, 1.0f, 0}};
|
||||
c = SKY;
|
||||
uint32_t slot;
|
||||
float b = burst(wall, t, 1.7f, 51, focus, span, &slot);
|
||||
if (b > 0.0f) c = lighten(c, scale(SPARK[slot % 3], b));
|
||||
b = burst(wall, t + 0.6f, 2.3f, 52, focus, span, &slot);
|
||||
if (b > 0.0f) c = lighten(c, scale(SPARK[(slot + 1) % 3], b));
|
||||
const float tw = twinkle(led, t, 0.9f, 0.08f, 53);
|
||||
if (tw > 0.0f) c = lighten(c, scale(SPARK[1], tw));
|
||||
break;
|
||||
}
|
||||
|
||||
case AUTUMN: {
|
||||
// Rust, amber, gold and russet leaves drifting down the stairs.
|
||||
static const Rgbw LEAVES[4] = {{0.85f, 0.2f, 0, 0}, {1.0f, 0.5f, 0, 0}, {1.0f, 0.75f, 0.08f, 0.1f},
|
||||
{0.6f, 0.08f, 0.02f, 0}};
|
||||
c = blocks(wall - t * 3.0f, 5.0f, 61, LEAVES, 4, 0.35f);
|
||||
c = scale(c, 0.8f + 0.2f * noise(62, wall / 3.0f - t * 1.5f));
|
||||
break;
|
||||
}
|
||||
|
||||
case HALLOWEEN: {
|
||||
// Jack-o'-lantern orange with candle flicker; purple patches drift
|
||||
// through and the odd LED flashes slime green.
|
||||
static const Rgbw ORANGE = {1.0f, 0.38f, 0, 0};
|
||||
static const Rgbw PURPLE = {0.6f, 0, 1.0f, 0};
|
||||
static const Rgbw SLIME = {0.35f, 1.0f, 0, 0};
|
||||
const float m = smoothstep((noise(71, wall / 16.0f - t * 0.2f) - 0.5f) * 4.0f);
|
||||
c = mix(ORANGE, PURPLE, m);
|
||||
c = scale(c, 0.55f + 0.45f * noise2(72, t * 6.0f, wall / 4.0f)); // flickers in clumps of a few LEDs
|
||||
c = mix(c, SLIME, twinkle(led, t, 2.5f, 0.05f, 73));
|
||||
break;
|
||||
}
|
||||
|
||||
case HAUNTED: {
|
||||
// Cold ghost light that breathes, with a slow dark patch wandering
|
||||
// through it, and every few seconds the whole thing stutters like a
|
||||
// dying bulb.
|
||||
static const Rgbw GHOST = {0.12f, 0.45f, 0.75f, 0.55f};
|
||||
float v = 0.75f + 0.25f * sinf(t * 1.3f);
|
||||
v *= 0.35f + 0.65f * smoothstep((fabsf(wall - (focus + 0.6f * span * sinf(t * 0.4f))) - 2.0f) / 5.0f);
|
||||
const float cycle = 6.0f;
|
||||
const uint32_t slot = (uint32_t) floorf(t / cycle);
|
||||
const float local = t - slot * cycle - rnd(81, slot) * 4.5f;
|
||||
if (rnd(82, slot) < 0.65f && local >= 0.0f && local < 1.1f) {
|
||||
const uint32_t q = (uint32_t) (local * 18.0f);
|
||||
if (rnd(83, slot * 64u + q) < 0.5f) v *= 0.06f;
|
||||
}
|
||||
c = scale(GHOST, v);
|
||||
break;
|
||||
}
|
||||
|
||||
case HANUKKAH: {
|
||||
// Blue and white blocks with a soft shimmer and white sparkles.
|
||||
static const Rgbw BLUE = {0, 0.2f, 1.0f, 0};
|
||||
static const Rgbw WHITE = {0.25f, 0.3f, 0.55f, 0.9f};
|
||||
c = mix(BLUE, WHITE, stripe(wall - t * 1.2f, 14.0f, 0.4f, 3.0f));
|
||||
c = scale(c, 0.75f + 0.25f * noise(91, wall / 4.0f + t * 1.2f));
|
||||
c = mix(c, WHITE, twinkle(led, t, 1.8f, 0.15f, 92));
|
||||
break;
|
||||
}
|
||||
|
||||
case CHRISTMAS: {
|
||||
// Red and green bands marching down the stairs with warm-white twinkles.
|
||||
static const Rgbw RED = {1.0f, 0.02f, 0.02f, 0};
|
||||
static const Rgbw GREEN = {0, 1.0f, 0.12f, 0};
|
||||
static const Rgbw TWINKLE = {0.25f, 0.18f, 0.05f, 1.0f};
|
||||
c = mix(GREEN, RED, stripe(wall - t * 2.0f, 12.0f, 0.5f, 1.5f));
|
||||
c = mix(c, TWINKLE, twinkle(led, t, 1.5f, 0.2f, 101));
|
||||
break;
|
||||
}
|
||||
|
||||
case CANDY_CANE: {
|
||||
// Red and white stripes scrolling down.
|
||||
static const Rgbw RED = {1.0f, 0, 0.04f, 0};
|
||||
static const Rgbw WHITE = {0.2f, 0.2f, 0.2f, 1.0f};
|
||||
c = mix(WHITE, RED, stripe(wall - t * 3.0f, 8.0f, 0.5f, 1.5f));
|
||||
break;
|
||||
}
|
||||
|
||||
case SNOWFALL: {
|
||||
// Icy blue with snowflakes falling down the stairs.
|
||||
static const Rgbw ICE = {0, 0.12f, 0.45f, 0.05f};
|
||||
static const Rgbw FLAKE = {0.3f, 0.4f, 0.6f, 1.0f};
|
||||
// Flakes fall from above the top to below the bottom and reappear at
|
||||
// the top; their count keeps one flake per 8.6 in of fall on any stairs.
|
||||
static const float MARGIN = 20.0f;
|
||||
static const int MAX_FLAKES = 160;
|
||||
const float loop = extent + 2.0f * MARGIN;
|
||||
const int flakes = std::min(MAX_FLAKES, (int) (loop / 8.6f) + 1);
|
||||
// Flake positions, computed once per frame.
|
||||
static float flake_t = -1.0f, flake_pos[MAX_FLAKES], flake_v[MAX_FLAKES];
|
||||
if (t != flake_t) {
|
||||
flake_t = t;
|
||||
for (int k = 0; k < flakes; k++) {
|
||||
const float speed = 4.0f + 5.0f * rnd(111, k);
|
||||
flake_pos[k] = fmodf(rnd(112, k) * loop + speed * t, loop) - MARGIN;
|
||||
flake_v[k] = 0.55f + 0.45f * rnd(113, k);
|
||||
}
|
||||
}
|
||||
c = ICE;
|
||||
for (int k = 0; k < flakes; k++) {
|
||||
const float d = fabsf(wall - flake_pos[k]);
|
||||
if (d < 1.6f) c = lighten(c, scale(FLAKE, flake_v[k] * (1.0f - d / 1.6f)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case FIREPLACE: {
|
||||
// Flames rising up the stairs: deep red embers to orange to yellow tips.
|
||||
static const Rgbw EMBER = {0.45f, 0, 0, 0};
|
||||
static const Rgbw FLAME = {1.0f, 0.32f, 0, 0};
|
||||
static const Rgbw TIP = {1.0f, 0.72f, 0.1f, 0.35f};
|
||||
float heat = 0.6f * noise(121, (wall + t * 10.0f) / 4.0f) + 0.4f * noise(122, (wall + t * 18.0f) / 1.5f);
|
||||
heat = clamp01((heat - 0.2f) * 1.7f);
|
||||
c = heat < 0.5f ? mix(EMBER, FLAME, heat * 2.0f) : mix(FLAME, TIP, (heat - 0.5f) * 2.0f);
|
||||
break;
|
||||
}
|
||||
|
||||
case AURORA: {
|
||||
// Green, teal and violet curtains drifting slowly with a shimmer.
|
||||
static const Rgbw GREEN = {0, 1.0f, 0.35f, 0};
|
||||
static const Rgbw TEAL = {0, 0.7f, 0.85f, 0};
|
||||
static const Rgbw VIOLET = {0.55f, 0.05f, 1.0f, 0};
|
||||
const float h = noise(131, wall / 30.0f - t * 0.08f);
|
||||
c = h < 0.5f ? mix(GREEN, TEAL, h * 2.0f) : mix(TEAL, VIOLET, (h - 0.5f) * 2.0f);
|
||||
c = scale(c, 0.45f + 0.55f * noise(132, wall / 9.0f + t * 0.35f));
|
||||
break;
|
||||
}
|
||||
|
||||
case OCEAN: {
|
||||
// Deep blue swells rolling down the stairs with the odd white cap.
|
||||
static const Rgbw DEEP = {0, 0.06f, 0.6f, 0};
|
||||
static const Rgbw SHALLOW = {0, 0.6f, 0.9f, 0};
|
||||
static const Rgbw FOAM = {0.3f, 0.5f, 0.7f, 0.8f};
|
||||
const float swell = 0.5f + 0.5f * sinf(6.2831853f * (wall / 40.0f - t / 7.0f));
|
||||
const float n = noise(141, wall / 10.0f - t * 0.4f);
|
||||
const float h = clamp01(0.65f * swell + 0.35f * n);
|
||||
c = mix(DEEP, SHALLOW, h);
|
||||
c = mix(c, FOAM, smoothstep((h - 0.82f) * 6.0f));
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Gamma 2 per channel.
|
||||
return {clamp01(c.r) * clamp01(c.r), clamp01(c.g) * clamp01(c.g), clamp01(c.b) * clamp01(c.b),
|
||||
clamp01(c.w) * clamp01(c.w)};
|
||||
}
|
||||
|
||||
} // namespace fx
|
||||
} // namespace follow_me
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -0,0 +1,988 @@
|
||||
# Follow-me stair light. ESPHome package for an LD2450 radar device such as
|
||||
# the Apollo MTR-1 (ESP32, esp-idf, ESPHome 2026.8.2 or newer, web_server).
|
||||
#
|
||||
# Reads the radar's three tracked targets, keeps the ones on the stairs, and
|
||||
# streams a lit window around each person's position to a WLED controller over
|
||||
# DDP (UDP port 4048), 4 bytes per LED on strips with a white channel and 3
|
||||
# otherwise, as WLED reports. The stream lights the strip only while it is
|
||||
# being sent; WLED returns to its own state after its realtime timeout.
|
||||
#
|
||||
# Everything about the stairs, the strip and the WLED address comes from the
|
||||
# calibration the setup wizard stores on the device
|
||||
# (follow-me-calibration.h). The device serves the wizard at /followme, and
|
||||
# the stock web page links to it. The LED count and strip type come from WLED
|
||||
# (follow-me.h). "Follow-Me Setup" reports what is missing: "No web server",
|
||||
# "Not set up", "Radar not found", "WLED not found", or "Ready".
|
||||
#
|
||||
# Radar targets are found by name (substitutions radar_x_name, radar_y_name;
|
||||
# Apollo's names by default).
|
||||
#
|
||||
# Manual control: with "Follow-Me Pause While WLED On" set (the default),
|
||||
# while the WLED controller itself is on (turned on from Home Assistant, the
|
||||
# WLED app or its button), follow-me sends nothing and the strip shows WLED's
|
||||
# own state. follow-me.h polls WLED's on/off on a background task and makes
|
||||
# WLED leave realtime mode the moment it turns on; follow-me resumes, starting
|
||||
# fresh, once WLED is off again. "Follow-Me Paused" shows WLED's on/off. An
|
||||
# unreachable WLED counts as off. With the switch cleared, follow-me streams
|
||||
# whatever WLED's own state is.
|
||||
#
|
||||
# Behaviour:
|
||||
# - Positions are wall inches: inches along the stairs, 0 at the top edge
|
||||
# of the top step, increasing down the stairs. Each target is projected
|
||||
# onto its nearest point on the calibrated walking path. Targets farther
|
||||
# than GATE_WIDTHS stair widths from the path (GATE_END_STEPS steps beyond
|
||||
# either end of it) are ignored: people on the floors above and below,
|
||||
# and reflections.
|
||||
# - Distances scale with the staircase: "steps" are the calibration's mean
|
||||
# tread run. "Follow-Me Window", "Follow-Me Buffer", "Follow-Me Night Light
|
||||
# Size", "Follow-Me Offset" and "Follow-Me Approach" are in steps, as are
|
||||
# the internal ranges.
|
||||
# - Approach: a person off the stairs within "Follow-Me Approach" steps of
|
||||
# either end (2 by default) is followed, with the window held at that end
|
||||
# of the stairs, so the light is on before they step onto the stairs;
|
||||
# farther out they are ignored.
|
||||
# - Gaps: where the strip has a gap (a jumper across a landing, a corner),
|
||||
# windows are drawn with the gap squeezed to the strip's usual LED
|
||||
# spacing, so a window crossing it lights both sides
|
||||
# (follow-me-calibration.h).
|
||||
# - "Follow-Me Offset" puts the window that many steps ahead of the person
|
||||
# in the direction they walk (higher going up, lower going down; negative
|
||||
# values put it behind), 0 by default. It blends in with walking speed,
|
||||
# reaching full strength at OFFSET_FULL_SPEED, so standing still has no
|
||||
# shift.
|
||||
# - Motion: an alpha-beta filter estimates position and walking speed from
|
||||
# the radar frames, and the position is taken LEAD seconds ahead of the
|
||||
# estimate (cancelling radar latency). "Follow-Me Smoothness" sets the
|
||||
# filter and easing weight: 0% follows the radar closely, 100% is slow and
|
||||
# floaty.
|
||||
# - The light is a solid window "Follow-Me Window" steps long. The window
|
||||
# stays still while the person is inside it and slides only when the
|
||||
# person comes within "Follow-Me Buffer" steps of either end, by just
|
||||
# enough to keep that margin. Its edges are anti-aliased, so only the
|
||||
# first and last LED are ever partly lit. The window never moves faster
|
||||
# than MAX_SPEED and its motion is eased. Only the stairs are lit: parts
|
||||
# of the window past either end of the stairs or the strip stay dark.
|
||||
# - Arrival: when the light turns on with the person within ENTRANCE_STEPS
|
||||
# steps of either end of the stairs (at most ENTRANCE_MAX_FRACTION of
|
||||
# their length), the window enters from that end and springs to the
|
||||
# person (underdamped, about 20% overshoot), fading in over ENTRANCE_FADE
|
||||
# seconds. Anywhere else it appears centred on the person and fades in
|
||||
# over "Follow-Me Fade In" seconds. On loss it holds for "Follow-Me Hold"
|
||||
# seconds, then fades out in place.
|
||||
# - Leaving: once a person who was on the stairs is past either end, there
|
||||
# is no hold; the window shrinks toward that end while fading out over
|
||||
# EXIT_FADE seconds. It stays dark while that person lingers off the
|
||||
# stairs, and arrives again when they step back on, move REARM_STEPS back
|
||||
# toward the stairs from the farthest point they reached, or have been out
|
||||
# of sight for "Follow-Me Hold" seconds. A person first seen off the
|
||||
# stairs arrives as usual. Fades follow a smoothstep curve.
|
||||
# - People: with "Follow-Me Multi-Person" off, one window follows one
|
||||
# person (the target nearest the current window). With it on, up to three
|
||||
# windows follow up to three people; radar targets are matched to windows
|
||||
# by nearest position (at most ASSOC_JUMP_S at MAX_SPEED apart), and where
|
||||
# windows overlap each LED takes the brighter. In multi-person mode a new
|
||||
# window starts only for a target near either end of the stairs (the
|
||||
# entrance range) and at least DUP_GAP_STEPS from every existing window,
|
||||
# so reflections do not get one; a person the radar loses mid-stairs for
|
||||
# longer than "Follow-Me Hold" is picked up again only near an end.
|
||||
# - Look: the "Follow-Me Glow" light sets on/off (follow-me enabled),
|
||||
# brightness and colour. It is an RGBW light so a white channel is its own
|
||||
# control; the default is white only at 40%. All windows share it. Its
|
||||
# channel values are the window's peak; ESPHome's gamma is off and the
|
||||
# window edges use gamma 2.2 (see wled/README.md). No trail, no
|
||||
# dithering. On a strip without a white channel the white channel is mixed
|
||||
# from red, green and blue as "Follow-Me RGB White" picks (warm, neutral
|
||||
# or cool).
|
||||
# - Effects: picking an effect on the "Follow-Me Glow" light (Rainbow,
|
||||
# holiday themes, Fireplace, Aurora, Ocean and others) replaces its colour
|
||||
# with a moving per-LED pattern at the light's brightness, inside the same
|
||||
# windows, fades and entrance. The patterns live in follow-me-effects.h
|
||||
# and are fixed to the stairs, so a person walks through them;
|
||||
# firework-style ones burst inside the person's window. The effects
|
||||
# themselves do nothing in ESPHome; the stream paints them. Turning the
|
||||
# light off resets the effect to None (ESPHome behaviour). At boot, an
|
||||
# effect name without a pattern is logged as an error.
|
||||
# - Night light: while the "Follow-Me Night Light" light is on, a patch
|
||||
# "Follow-Me Night Light Size" steps long is lit at each end of the
|
||||
# stairs, with a raised-cosine fade over NL_EDGE_STEPS on its inner side.
|
||||
# It is its own RGBW light (default white only at 5%, 1 s transitions)
|
||||
# and runs whether or not follow-me is enabled. Windows draw over it: each
|
||||
# LED channel takes the brighter of the window and the night light.
|
||||
# Scheduling it is left to Home Assistant.
|
||||
# - LED preview: while the setup wizard asks for it (/followme/preview), the
|
||||
# stream shows only the LED being matched, overriding everything above.
|
||||
#
|
||||
# Strip: the wizard's LED anchors pin LED numbers to spots along the path;
|
||||
# LED positions are interpolated between anchors and extrapolated past the
|
||||
# outer ones, so strips in pieces, with gaps, or running in either direction
|
||||
# map correctly. LED numbers are raw indexes on the WLED controller, as DDP
|
||||
# addresses them, independent of WLED's segments. Up to MAX_STREAM_LEDS LEDs.
|
||||
|
||||
esphome:
|
||||
includes:
|
||||
- ${follow_me_dir}/follow-me.h
|
||||
- ${follow_me_dir}/follow-me-effects.h
|
||||
- ${follow_me_dir}/follow-me-wizard.h
|
||||
- ${follow_me_dir}/follow-me-calibration.h
|
||||
|
||||
# Adds a "Set up Follow-Me" link to the stock web page, opening the setup
|
||||
# wizard served at /followme. Merges with the device's own web_server.
|
||||
# The stock web page, served from the device so it works without internet,
|
||||
# with a "Set up Follow-Me" button that opens the wizard (/followme).
|
||||
web_server:
|
||||
local: true
|
||||
js_include: ${follow_me_dir}/follow-me-button.js
|
||||
|
||||
substitutions:
|
||||
# Folder holding the follow-me headers, relative to the device file
|
||||
# (ESPHome resolves includes from the device file, not from this package).
|
||||
follow_me_dir: .
|
||||
# Names of the radar's target X/Y sensors (raw state in mm); %d is the
|
||||
# target number, 1-3. The defaults are Apollo's.
|
||||
radar_x_name: "Target-%d X"
|
||||
radar_y_name: "Target-%d Y"
|
||||
|
||||
globals:
|
||||
# Follow-Me Glow channel values, 0-1.
|
||||
- id: fm_red
|
||||
type: float
|
||||
restore_value: no
|
||||
initial_value: "0"
|
||||
- id: fm_green
|
||||
type: float
|
||||
restore_value: no
|
||||
initial_value: "0"
|
||||
- id: fm_blue
|
||||
type: float
|
||||
restore_value: no
|
||||
initial_value: "0"
|
||||
- id: fm_white
|
||||
type: float
|
||||
restore_value: no
|
||||
initial_value: "0"
|
||||
# Follow-Me Night Light channel values, 0-1.
|
||||
- id: nl_red
|
||||
type: float
|
||||
restore_value: no
|
||||
initial_value: "0"
|
||||
- id: nl_green
|
||||
type: float
|
||||
restore_value: no
|
||||
initial_value: "0"
|
||||
- id: nl_blue
|
||||
type: float
|
||||
restore_value: no
|
||||
initial_value: "0"
|
||||
- id: nl_white
|
||||
type: float
|
||||
restore_value: no
|
||||
initial_value: "0"
|
||||
|
||||
output:
|
||||
# Follow-Me Glow channel values (brightness applied), read by the stream.
|
||||
- platform: template
|
||||
id: fm_out_r
|
||||
type: float
|
||||
write_action:
|
||||
- lambda: id(fm_red) = state;
|
||||
- platform: template
|
||||
id: fm_out_g
|
||||
type: float
|
||||
write_action:
|
||||
- lambda: id(fm_green) = state;
|
||||
- platform: template
|
||||
id: fm_out_b
|
||||
type: float
|
||||
write_action:
|
||||
- lambda: id(fm_blue) = state;
|
||||
- platform: template
|
||||
id: fm_out_w
|
||||
type: float
|
||||
write_action:
|
||||
- lambda: id(fm_white) = state;
|
||||
# Follow-Me Night Light channel values (brightness applied).
|
||||
- platform: template
|
||||
id: nl_out_r
|
||||
type: float
|
||||
write_action:
|
||||
- lambda: id(nl_red) = state;
|
||||
- platform: template
|
||||
id: nl_out_g
|
||||
type: float
|
||||
write_action:
|
||||
- lambda: id(nl_green) = state;
|
||||
- platform: template
|
||||
id: nl_out_b
|
||||
type: float
|
||||
write_action:
|
||||
- lambda: id(nl_blue) = state;
|
||||
- platform: template
|
||||
id: nl_out_w
|
||||
type: float
|
||||
write_action:
|
||||
- lambda: id(nl_white) = state;
|
||||
|
||||
light:
|
||||
- platform: rgbw
|
||||
id: follow_me_glow
|
||||
name: "Follow-Me Glow"
|
||||
icon: mdi:stairs
|
||||
red: fm_out_r
|
||||
green: fm_out_g
|
||||
blue: fm_out_b
|
||||
white: fm_out_w
|
||||
color_interlock: false
|
||||
gamma_correct: 1.0
|
||||
default_transition_length: 500ms
|
||||
restore_mode: RESTORE_DEFAULT_ON
|
||||
initial_state:
|
||||
state: true
|
||||
color_mode: RGB_WHITE
|
||||
brightness: 40%
|
||||
color_brightness: 0% # RGB off; ESPHome normalises an all-zero red/green/blue to white
|
||||
white: 100%
|
||||
# Effects only name a pattern; the stream paints it (follow-me-effects.h,
|
||||
# whose EFFECT_NAMES must match these names).
|
||||
effects:
|
||||
- lambda:
|
||||
name: "Rainbow"
|
||||
update_interval: 1h
|
||||
lambda: "// Painted by the follow-me stream."
|
||||
- lambda:
|
||||
name: "New Year's"
|
||||
update_interval: 1h
|
||||
lambda: "// Painted by the follow-me stream."
|
||||
- lambda:
|
||||
name: "Valentine's"
|
||||
update_interval: 1h
|
||||
lambda: "// Painted by the follow-me stream."
|
||||
- lambda:
|
||||
name: "St. Patrick's"
|
||||
update_interval: 1h
|
||||
lambda: "// Painted by the follow-me stream."
|
||||
- lambda:
|
||||
name: "Easter"
|
||||
update_interval: 1h
|
||||
lambda: "// Painted by the follow-me stream."
|
||||
- lambda:
|
||||
name: "Fourth of July"
|
||||
update_interval: 1h
|
||||
lambda: "// Painted by the follow-me stream."
|
||||
- lambda:
|
||||
name: "Autumn"
|
||||
update_interval: 1h
|
||||
lambda: "// Painted by the follow-me stream."
|
||||
- lambda:
|
||||
name: "Halloween"
|
||||
update_interval: 1h
|
||||
lambda: "// Painted by the follow-me stream."
|
||||
- lambda:
|
||||
name: "Haunted"
|
||||
update_interval: 1h
|
||||
lambda: "// Painted by the follow-me stream."
|
||||
- lambda:
|
||||
name: "Hanukkah"
|
||||
update_interval: 1h
|
||||
lambda: "// Painted by the follow-me stream."
|
||||
- lambda:
|
||||
name: "Christmas"
|
||||
update_interval: 1h
|
||||
lambda: "// Painted by the follow-me stream."
|
||||
- lambda:
|
||||
name: "Candy Cane"
|
||||
update_interval: 1h
|
||||
lambda: "// Painted by the follow-me stream."
|
||||
- lambda:
|
||||
name: "Snowfall"
|
||||
update_interval: 1h
|
||||
lambda: "// Painted by the follow-me stream."
|
||||
- lambda:
|
||||
name: "Fireplace"
|
||||
update_interval: 1h
|
||||
lambda: "// Painted by the follow-me stream."
|
||||
- lambda:
|
||||
name: "Aurora"
|
||||
update_interval: 1h
|
||||
lambda: "// Painted by the follow-me stream."
|
||||
- lambda:
|
||||
name: "Ocean"
|
||||
update_interval: 1h
|
||||
lambda: "// Painted by the follow-me stream."
|
||||
- platform: rgbw
|
||||
id: follow_me_night_light
|
||||
name: "Follow-Me Night Light"
|
||||
icon: mdi:weather-night
|
||||
red: nl_out_r
|
||||
green: nl_out_g
|
||||
blue: nl_out_b
|
||||
white: nl_out_w
|
||||
color_interlock: false
|
||||
gamma_correct: 1.0
|
||||
default_transition_length: 1s
|
||||
restore_mode: RESTORE_DEFAULT_OFF
|
||||
initial_state:
|
||||
state: false
|
||||
color_mode: RGB_WHITE
|
||||
brightness: 5%
|
||||
color_brightness: 0% # RGB off; ESPHome normalises an all-zero red/green/blue to white
|
||||
white: 100%
|
||||
|
||||
switch:
|
||||
- platform: template
|
||||
id: follow_me_multi
|
||||
name: "Follow-Me Multi-Person"
|
||||
icon: mdi:account-multiple
|
||||
entity_category: config
|
||||
optimistic: true
|
||||
restore_mode: RESTORE_DEFAULT_OFF
|
||||
- platform: template
|
||||
id: follow_me_pause_on
|
||||
name: "Follow-Me Pause While WLED On"
|
||||
icon: mdi:hand-back-right
|
||||
entity_category: config
|
||||
optimistic: true
|
||||
restore_mode: RESTORE_DEFAULT_ON
|
||||
|
||||
select:
|
||||
- platform: template
|
||||
id: follow_me_rgb_white
|
||||
name: "Follow-Me RGB White"
|
||||
icon: mdi:palette
|
||||
entity_category: config
|
||||
options: ["Warm", "Neutral", "Cool"]
|
||||
initial_option: "Neutral"
|
||||
optimistic: true
|
||||
restore_value: true
|
||||
|
||||
number:
|
||||
- platform: template
|
||||
id: follow_me_night_light_size
|
||||
name: "Follow-Me Night Light Size"
|
||||
icon: mdi:arrow-expand-horizontal
|
||||
entity_category: config
|
||||
unit_of_measurement: "steps"
|
||||
mode: box
|
||||
min_value: 0.5
|
||||
max_value: 8
|
||||
step: 0.5
|
||||
initial_value: 2
|
||||
optimistic: true
|
||||
restore_value: true
|
||||
- platform: template
|
||||
id: follow_me_approach
|
||||
name: "Follow-Me Approach"
|
||||
icon: mdi:walk
|
||||
entity_category: config
|
||||
unit_of_measurement: "steps"
|
||||
mode: box
|
||||
min_value: 0
|
||||
max_value: 6
|
||||
step: 0.5
|
||||
initial_value: 2
|
||||
optimistic: true
|
||||
restore_value: true
|
||||
- platform: template
|
||||
id: follow_me_window
|
||||
name: "Follow-Me Window"
|
||||
icon: mdi:arrow-expand-vertical
|
||||
entity_category: config
|
||||
unit_of_measurement: "steps"
|
||||
mode: box
|
||||
min_value: 1
|
||||
max_value: 15
|
||||
step: 0.5
|
||||
initial_value: 3
|
||||
optimistic: true
|
||||
restore_value: true
|
||||
- platform: template
|
||||
id: follow_me_buffer
|
||||
name: "Follow-Me Buffer"
|
||||
icon: mdi:arrow-collapse-vertical
|
||||
entity_category: config
|
||||
unit_of_measurement: "steps"
|
||||
mode: box
|
||||
min_value: 0
|
||||
max_value: 5
|
||||
step: 0.25
|
||||
initial_value: 1
|
||||
optimistic: true
|
||||
restore_value: true
|
||||
- platform: template
|
||||
id: follow_me_hold
|
||||
name: "Follow-Me Hold"
|
||||
icon: mdi:timer-sand
|
||||
entity_category: config
|
||||
unit_of_measurement: "s"
|
||||
mode: box
|
||||
min_value: 0
|
||||
max_value: 60
|
||||
step: 1
|
||||
initial_value: 4
|
||||
optimistic: true
|
||||
restore_value: true
|
||||
- platform: template
|
||||
id: follow_me_offset
|
||||
name: "Follow-Me Offset"
|
||||
icon: mdi:arrow-up-down
|
||||
entity_category: config
|
||||
unit_of_measurement: "steps"
|
||||
mode: box
|
||||
min_value: -3
|
||||
max_value: 3
|
||||
step: 0.25
|
||||
initial_value: 0
|
||||
optimistic: true
|
||||
restore_value: true
|
||||
- platform: template
|
||||
id: follow_me_smoothness
|
||||
name: "Follow-Me Smoothness"
|
||||
icon: mdi:waves
|
||||
entity_category: config
|
||||
unit_of_measurement: "%"
|
||||
mode: slider
|
||||
min_value: 0
|
||||
max_value: 100
|
||||
step: 5
|
||||
initial_value: 50
|
||||
optimistic: true
|
||||
restore_value: true
|
||||
- platform: template
|
||||
id: follow_me_fade_in
|
||||
name: "Follow-Me Fade In"
|
||||
icon: mdi:transition
|
||||
entity_category: config
|
||||
unit_of_measurement: "s"
|
||||
mode: box
|
||||
min_value: 0.1
|
||||
max_value: 10
|
||||
step: 0.1
|
||||
initial_value: 1.5
|
||||
optimistic: true
|
||||
restore_value: true
|
||||
- platform: template
|
||||
id: follow_me_fade_out
|
||||
name: "Follow-Me Fade Out"
|
||||
icon: mdi:transition
|
||||
entity_category: config
|
||||
unit_of_measurement: "s"
|
||||
mode: box
|
||||
min_value: 0.1
|
||||
max_value: 10
|
||||
step: 0.1
|
||||
initial_value: 3.0
|
||||
optimistic: true
|
||||
restore_value: true
|
||||
|
||||
binary_sensor:
|
||||
- platform: template
|
||||
name: "Follow-Me Paused"
|
||||
icon: mdi:hand-back-right
|
||||
entity_category: diagnostic
|
||||
lambda: return follow_me::wled_on.load();
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "Follow-Me Position"
|
||||
icon: mdi:map-marker-path
|
||||
entity_category: diagnostic
|
||||
unit_of_measurement: "steps"
|
||||
accuracy_decimals: 1
|
||||
update_interval: 1s
|
||||
lambda: |-
|
||||
const float p = follow_me::display_pos();
|
||||
return follow_me::geo.ready ? p / follow_me::geo.inches_per_step : NAN;
|
||||
|
||||
text_sensor:
|
||||
- platform: template
|
||||
name: "Follow-Me Setup"
|
||||
icon: mdi:stairs-box
|
||||
entity_category: diagnostic
|
||||
update_interval: 5s
|
||||
lambda: |-
|
||||
if (!follow_me::web_ready) return {"No web server"};
|
||||
if (!follow_me::geo.ready) return {"Not set up"};
|
||||
if (follow_me::radar_missing) return {"Radar not found"};
|
||||
if (follow_me::wled_leds.load() == 0) return {"WLED not found"};
|
||||
return {"Ready"};
|
||||
|
||||
interval:
|
||||
- interval: 20ms
|
||||
then:
|
||||
- lambda: |-
|
||||
// Distances scale with the calibrated staircase: STEP is its mean
|
||||
// tread run, WIDTH its width (both wall inches). Times and speeds are
|
||||
// about people and the radar, the same everywhere.
|
||||
static const float GATE_WIDTHS = 0.8f; // accepted distance from the path, in stair widths (half the stair plus radar wobble)
|
||||
static const float GATE_END_STEPS = 4.0f; // accepted distance beyond either end of the path
|
||||
static const float EXIT_FADE = 1.0f; // seconds, shrink and fade after leaving the stairs
|
||||
static const float REARM_STEPS = 1.0f; // walking back toward the stairs this far (radar distance) ends lingering
|
||||
static const float ENTRANCE_STEPS = 3.0f; // from an end of the stairs: the entrance animation and new multi-person windows
|
||||
static const float ENTRANCE_MAX_FRACTION = 0.33f; // ... but at most this fraction of the stairs' length
|
||||
static const float ENTRANCE_W = 8.0f; // entrance spring natural frequency, rad/s
|
||||
static const float ENTRANCE_ZETA = 0.45f; // entrance spring damping (about 20% overshoot)
|
||||
static const float ENTRANCE_FADE = 0.25f; // seconds
|
||||
static const uint32_t ENTRANCE_MAX_MS = 1500;
|
||||
static const float DUP_GAP_STEPS = 1.5f; // nearest a new window may start to an existing one
|
||||
static const float NL_EDGE_STEPS = 0.6f; // night light patch soft edge
|
||||
static const float GAMMA = 2.2f;
|
||||
static const float MAX_SPEED = 60.0f; // inches/s (1.5 m/s): window speed cap, above a brisk stair walk
|
||||
static const float ASSOC_JUMP_S = 1.0f; // multi-person: a window's person moves at most MAX_SPEED for this long between matches
|
||||
static const float LEAD = 0.25f; // seconds taken ahead of the estimate (LD2450 latency)
|
||||
static const float OFFSET_FULL_SPEED = 10.0f; // inches/s: "Follow-Me Offset" applies fully from this walking speed
|
||||
static const float STALE_MS = 400; // no new radar frame for this long: speed decays
|
||||
static const int TRAILING_ZEROS = 5; // dark frames sent after fading out
|
||||
static const uint32_t KEEPALIVE_MS = 1000; // resend an unchanged frame this often (WLED leaves realtime mode after its timeout, 2.5 s by default)
|
||||
// White channel as red, green, blue on strips without one ("Follow-Me RGB White").
|
||||
static const float WHITE_MIX[3][3] = {{1.0f, 0.72f, 0.42f}, {1.0f, 0.88f, 0.74f}, {0.86f, 0.92f, 1.0f}};
|
||||
|
||||
// First run: stored calibration, its web endpoints, the WLED watcher,
|
||||
// and a check that the effect names here match follow-me-effects.h.
|
||||
static bool started = false;
|
||||
if (!started) {
|
||||
started = true;
|
||||
follow_me::setup_calibration();
|
||||
// The radar's target X/Y sensors, found by name
|
||||
// (radar_x_name / radar_y_name, %d = target number).
|
||||
for (int t = 0; t < follow_me::MAX_TRACKS; t++) {
|
||||
char xn[64], yn[64];
|
||||
snprintf(xn, sizeof(xn), "${radar_x_name}", t + 1);
|
||||
snprintf(yn, sizeof(yn), "${radar_y_name}", t + 1);
|
||||
for (auto *sens : App.get_sensors()) {
|
||||
if (sens->get_name() == xn) follow_me::radar_x[t] = sens;
|
||||
if (sens->get_name() == yn) follow_me::radar_y[t] = sens;
|
||||
}
|
||||
if (!follow_me::radar_x[t] || !follow_me::radar_y[t]) {
|
||||
follow_me::radar_missing = true;
|
||||
ESP_LOGE("follow_me", "No \"%s\" / \"%s\" sensor; set radar_x_name / radar_y_name", xn, yn);
|
||||
}
|
||||
}
|
||||
for (auto *eff : id(follow_me_glow).get_effects()) {
|
||||
const StringRef n = eff->get_name();
|
||||
if (follow_me::fx::from_name(n.c_str(), n.size()) == follow_me::fx::NONE)
|
||||
ESP_LOGE("follow_me", "Effect \"%s\" has no pattern in follow-me-effects.h", n.c_str());
|
||||
}
|
||||
}
|
||||
follow_me::apply_pending();
|
||||
follow_me::pause_when_on = id(follow_me_pause_on).state;
|
||||
|
||||
static uint32_t last_ms = 0;
|
||||
static int zeros_sent = TRAILING_ZEROS;
|
||||
follow_me::Track *tracks = follow_me::tracks;
|
||||
const int MAX_TRACKS = follow_me::MAX_TRACKS;
|
||||
|
||||
const uint32_t now = millis();
|
||||
float dt = last_ms == 0 ? 0.02f : (now - last_ms) / 1000.0f;
|
||||
last_ms = now;
|
||||
if (dt > 0.2f) dt = 0.2f;
|
||||
|
||||
static uint8_t data[follow_me::MAX_STREAM_LEDS * 4];
|
||||
static uint8_t sent[sizeof(data)];
|
||||
static int sent_len = 0;
|
||||
static uint32_t sent_ip = 0, last_send = 0;
|
||||
const bool net = network::is_connected();
|
||||
|
||||
// LED preview for the setup wizard: overrides everything else.
|
||||
const int preview = follow_me::preview_now(now);
|
||||
if (preview > 0) {
|
||||
for (int i = 0; i < MAX_TRACKS; i++) tracks[i] = follow_me::Track();
|
||||
zeros_sent = TRAILING_ZEROS;
|
||||
const int leds = std::min(follow_me::wled_leds.load(), follow_me::MAX_STREAM_LEDS);
|
||||
const uint32_t ip = follow_me::wled_ip.load();
|
||||
if (!net || leds == 0 || ip == 0) return;
|
||||
const int bpl = follow_me::wled_white.load() ? 4 : 3;
|
||||
const int len = follow_me::preview_frame(data, leds, bpl, preview);
|
||||
if (len == sent_len && ip == sent_ip && memcmp(data, sent, len) == 0 && now - last_send < KEEPALIVE_MS) return;
|
||||
memcpy(sent, data, len);
|
||||
sent_len = len;
|
||||
sent_ip = ip;
|
||||
last_send = now;
|
||||
follow_me::send_ddp(ip, data, len, bpl);
|
||||
return;
|
||||
}
|
||||
|
||||
// Not set up, or WLED not found yet: send nothing.
|
||||
const follow_me::Geometry &geo = follow_me::geo;
|
||||
const int wled_leds = follow_me::wled_leds.load();
|
||||
const uint32_t wled_ip = follow_me::wled_ip.load();
|
||||
if (!geo.ready || wled_leds == 0 || wled_ip == 0) {
|
||||
for (int i = 0; i < MAX_TRACKS; i++) tracks[i] = follow_me::Track();
|
||||
zeros_sent = TRAILING_ZEROS;
|
||||
return;
|
||||
}
|
||||
const bool white = follow_me::wled_white.load();
|
||||
const int NUM_LEDS = std::min(wled_leds, follow_me::MAX_STREAM_LEDS);
|
||||
const float *edge_wall = geo.edge_wall;
|
||||
const float STEP = geo.inches_per_step;
|
||||
const float WIDTH = 2.0f * geo.half_width;
|
||||
const float STAIRS_TOP = 0.0f; // wall inches, top edge of the top step
|
||||
const float STAIRS_BOTTOM = geo.stairs_bottom; // wall inches, front of the bottom step
|
||||
// People off the stairs within "Follow-Me Approach" steps of an end
|
||||
// are followed, their window held at that end; farther out they
|
||||
// are ignored.
|
||||
float approach_steps = id(follow_me_approach).state;
|
||||
if (std::isnan(approach_steps) || approach_steps < 0.0f) approach_steps = 0.0f;
|
||||
const float APPROACH = approach_steps * STEP;
|
||||
const float POS_MIN = STAIRS_TOP, POS_MAX = STAIRS_BOTTOM;
|
||||
const float GATE = GATE_WIDTHS * WIDTH;
|
||||
const float GATE_END = GATE_END_STEPS * STEP;
|
||||
const float REARM = REARM_STEPS * STEP;
|
||||
const float ENTRANCE_RANGE = std::min(ENTRANCE_STEPS * STEP, ENTRANCE_MAX_FRACTION * STAIRS_BOTTOM);
|
||||
const float DUP_GAP = DUP_GAP_STEPS * STEP;
|
||||
const float NL_EDGE = NL_EDGE_STEPS * STEP;
|
||||
const float ASSOC_GATE = MAX_SPEED * ASSOC_JUMP_S;
|
||||
// The lit range is the stairs; LEDs past either end stay dark. The
|
||||
// strip's ends, for the night light and the entrance, are taken
|
||||
// within it.
|
||||
// Drawing happens in geo.edge_draw coordinates: wall inches with the
|
||||
// strip's gaps squeezed out (follow-me-calibration.h), so a window
|
||||
// crossing a gap lights both sides of it.
|
||||
const float *edge_draw = geo.edge_draw;
|
||||
const float DRAW_TOP = follow_me::draw_pos(STAIRS_TOP), DRAW_BOTTOM = follow_me::draw_pos(STAIRS_BOTTOM);
|
||||
float strip_lo = edge_draw[0], strip_hi = edge_draw[0];
|
||||
for (int k = 1; k <= NUM_LEDS; k++) {
|
||||
strip_lo = fminf(strip_lo, edge_draw[k]);
|
||||
strip_hi = fmaxf(strip_hi, edge_draw[k]);
|
||||
}
|
||||
const float STRIP_TOP_END = fmaxf(DRAW_TOP, strip_lo);
|
||||
const float STRIP_BOTTOM_END = fminf(DRAW_BOTTOM, strip_hi);
|
||||
// The same ends in wall inches, for the entrance (tracking is in wall inches).
|
||||
float wall_lo = edge_wall[0], wall_hi = edge_wall[0];
|
||||
for (int k = 1; k <= NUM_LEDS; k++) {
|
||||
wall_lo = fminf(wall_lo, edge_wall[k]);
|
||||
wall_hi = fmaxf(wall_hi, edge_wall[k]);
|
||||
}
|
||||
const float ENTRY_TOP = fmaxf(STAIRS_TOP, wall_lo), ENTRY_BOTTOM = fminf(STAIRS_BOTTOM, wall_hi);
|
||||
follow_me::fx::set_extent(STAIRS_BOTTOM);
|
||||
|
||||
// WLED is on and the pause switch is set: someone is using the
|
||||
// light. Send nothing (not even dark frames, which would re-enter
|
||||
// realtime mode) and start fresh when it turns off.
|
||||
if (follow_me::pause_when_on.load() && follow_me::wled_on.load()) {
|
||||
for (int i = 0; i < MAX_TRACKS; i++) tracks[i] = follow_me::Track();
|
||||
zeros_sent = TRAILING_ZEROS;
|
||||
return;
|
||||
}
|
||||
|
||||
// Radar targets on the stairs path.
|
||||
struct Det {
|
||||
float p, raw, x, y;
|
||||
bool used;
|
||||
};
|
||||
Det det[follow_me::MAX_TRACKS];
|
||||
int nd = 0;
|
||||
sensor::Sensor *const *xs = follow_me::radar_x, *const *ys = follow_me::radar_y;
|
||||
for (int t = 0; t < MAX_TRACKS; t++) {
|
||||
if (!xs[t] || !ys[t]) continue;
|
||||
float x = xs[t]->get_raw_state(), y = ys[t]->get_raw_state();
|
||||
if (std::isnan(x) || std::isnan(y)) continue;
|
||||
x /= 25.4f; // mm to inches
|
||||
y /= 25.4f;
|
||||
float p = 0.0f;
|
||||
bool at_end = false;
|
||||
const float near_d2 = follow_me::project(x, y, &p, &at_end);
|
||||
const float gate = at_end ? GATE_END : GATE;
|
||||
if (near_d2 > gate * gate) continue;
|
||||
if (p < STAIRS_TOP - APPROACH || p > STAIRS_BOTTOM + APPROACH) continue;
|
||||
const float raw = p; // before clamping to the stairs
|
||||
p = fminf(POS_MAX, fmaxf(POS_MIN, p));
|
||||
det[nd++] = {p, raw, x, y, false};
|
||||
}
|
||||
|
||||
// Match targets to windows.
|
||||
const bool enabled = id(follow_me_glow).remote_values.is_on();
|
||||
const bool multi = id(follow_me_multi).state;
|
||||
const int slots = multi ? MAX_TRACKS : 1;
|
||||
for (int i = 0; i < MAX_TRACKS; i++) tracks[i].found = false;
|
||||
auto take = [&](follow_me::Track &tr, Det &d) {
|
||||
tr.found = true;
|
||||
tr.det_p = d.p;
|
||||
tr.det_raw = d.raw;
|
||||
tr.det_x = d.x;
|
||||
tr.det_y = d.y;
|
||||
d.used = true;
|
||||
};
|
||||
if (enabled) {
|
||||
if (!multi) {
|
||||
// One window: the target nearest it, or the first one.
|
||||
int best = -1;
|
||||
float best_d = 1e9f;
|
||||
for (int j = 0; j < nd; j++) {
|
||||
const float d = tracks[0].alive ? fabsf(det[j].p - tracks[0].target) : 0.0f;
|
||||
if (d < best_d) {
|
||||
best_d = d;
|
||||
best = j;
|
||||
}
|
||||
}
|
||||
if (best >= 0) take(tracks[0], det[best]);
|
||||
} else {
|
||||
// Nearest pairs first, within ASSOC_GATE.
|
||||
for (int round = 0; round < MAX_TRACKS; round++) {
|
||||
int bi = -1, bj = -1;
|
||||
float bd = ASSOC_GATE;
|
||||
for (int i = 0; i < slots; i++) {
|
||||
if (!tracks[i].alive || tracks[i].found) continue;
|
||||
for (int j = 0; j < nd; j++) {
|
||||
if (det[j].used) continue;
|
||||
const float d = fabsf(det[j].p - tracks[i].target);
|
||||
if (d <= bd) {
|
||||
bd = d;
|
||||
bi = i;
|
||||
bj = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bi < 0) break;
|
||||
take(tracks[bi], det[bj]);
|
||||
}
|
||||
// New windows: near an end, clear of existing windows.
|
||||
for (int j = 0; j < nd; j++) {
|
||||
if (det[j].used) continue;
|
||||
const bool near_end = det[j].raw >= STAIRS_BOTTOM - ENTRANCE_RANGE ||
|
||||
det[j].raw <= STAIRS_TOP + ENTRANCE_RANGE;
|
||||
if (!near_end) continue;
|
||||
bool clear = true;
|
||||
for (int i = 0; i < MAX_TRACKS; i++) {
|
||||
if (tracks[i].alive && fabsf(det[j].p - tracks[i].target) < DUP_GAP) clear = false;
|
||||
}
|
||||
if (!clear) continue;
|
||||
for (int i = 0; i < slots; i++) {
|
||||
if (tracks[i].alive) continue;
|
||||
tracks[i] = follow_me::Track();
|
||||
tracks[i].alive = true;
|
||||
take(tracks[i], det[j]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
float smooth = id(follow_me_smoothness).state / 100.0f;
|
||||
if (std::isnan(smooth)) smooth = 0.5f;
|
||||
smooth = fminf(1.0f, fmaxf(0.0f, smooth));
|
||||
const float alpha = 0.6f - 0.35f * smooth; // filter gain on position
|
||||
const float beta = alpha * alpha / (2.0f - alpha); // filter gain on speed
|
||||
const float glide = 0.08f + 0.27f * smooth; // window easing time constant, s
|
||||
|
||||
const float length = fmaxf(1.0f, id(follow_me_window).state) * STEP;
|
||||
const float margin = fmaxf(0.0f, id(follow_me_buffer).state) * STEP;
|
||||
const float slack = fmaxf(0.0f, length / 2.0f - margin); // free travel from centre
|
||||
// "Follow-Me Offset": steps ahead of the person in the direction they
|
||||
// walk (negative: behind), blended in with walking speed so a pause
|
||||
// or a turn does not make the window jump.
|
||||
float offset_steps = id(follow_me_offset).state;
|
||||
if (std::isnan(offset_steps)) offset_steps = 0.0f;
|
||||
auto lead_offset = [&](float v) { return offset_steps * STEP * fmaxf(-1.0f, fminf(1.0f, v / OFFSET_FULL_SPEED)); };
|
||||
const float hold_s = id(follow_me_hold).state;
|
||||
const uint32_t hold_ms = (uint32_t) ((std::isnan(hold_s) || hold_s < 0.0f ? 0.0f : hold_s) * 1000.0f);
|
||||
|
||||
float max_level = 0.0f;
|
||||
for (int i = 0; i < MAX_TRACKS; i++) {
|
||||
follow_me::Track &tr = tracks[i];
|
||||
if (!tr.alive && tr.found) tr.alive = true; // single-person window starting
|
||||
if (!tr.alive) continue;
|
||||
|
||||
// Predict.
|
||||
if (tr.tracking) {
|
||||
tr.target += tr.est_v * dt;
|
||||
if (now - tr.last_meas > STALE_MS) tr.est_v *= expf(-dt / 0.25f);
|
||||
}
|
||||
|
||||
if (tr.found) {
|
||||
if (!tr.tracking || tr.level <= 0.0f) {
|
||||
// Start centred on the person.
|
||||
tr.target = tr.det_p;
|
||||
tr.est_v = 0.0f;
|
||||
tr.last_z = tr.det_p;
|
||||
tr.last_meas = now;
|
||||
tr.centre = tr.det_p;
|
||||
tr.limited = tr.det_p;
|
||||
tr.pos = tr.det_p;
|
||||
} else if (tr.det_p != tr.last_z) {
|
||||
// Correct with a new radar frame.
|
||||
float dtm = (now - tr.last_meas) / 1000.0f;
|
||||
dtm = fminf(0.5f, fmaxf(0.05f, dtm));
|
||||
const float r = tr.det_p - tr.target;
|
||||
tr.target += alpha * r;
|
||||
tr.est_v += beta * r / dtm;
|
||||
tr.last_z = tr.det_p;
|
||||
tr.last_meas = now;
|
||||
}
|
||||
tr.tracking = true;
|
||||
tr.last_seen = now;
|
||||
}
|
||||
tr.est_v = fminf(MAX_SPEED, fmaxf(-MAX_SPEED, tr.est_v));
|
||||
|
||||
if (tr.found) {
|
||||
const int side = tr.det_raw > STAIRS_BOTTOM ? 1 : (tr.det_raw < STAIRS_TOP ? -1 : 0);
|
||||
if (side == 0) {
|
||||
tr.on_stairs = true;
|
||||
tr.exit_dir = 0;
|
||||
} else if (tr.on_stairs) {
|
||||
tr.on_stairs = false;
|
||||
tr.exit_dir = side;
|
||||
tr.exit_far = 0.0f;
|
||||
}
|
||||
if (tr.exit_dir != 0) {
|
||||
// Lingering ends when the person heads back toward the stairs.
|
||||
const int e = tr.exit_dir > 0 ? 0 : geo.points - 1; // path starts on the lower floor
|
||||
const float dist = hypotf(tr.det_x - geo.x[e], tr.det_y - geo.y[e]);
|
||||
tr.exit_far = fmaxf(tr.exit_far, dist);
|
||||
if (dist < tr.exit_far - REARM) tr.exit_dir = 0;
|
||||
}
|
||||
} else if (now - tr.last_seen > hold_ms) {
|
||||
tr.on_stairs = false;
|
||||
tr.exit_dir = 0;
|
||||
}
|
||||
|
||||
const bool lit = enabled && i < slots && tr.tracking && tr.exit_dir == 0 &&
|
||||
(now - tr.last_seen) <= hold_ms;
|
||||
|
||||
if (lit && tr.level <= 0.0f) {
|
||||
// Arrival near an end: enter from beyond that end of the strip.
|
||||
const float half = length / 2.0f;
|
||||
if (ENTRY_BOTTOM - tr.target <= ENTRANCE_RANGE) {
|
||||
tr.pos = ENTRY_BOTTOM + half;
|
||||
tr.entering = true;
|
||||
} else if (tr.target - ENTRY_TOP <= ENTRANCE_RANGE) {
|
||||
tr.pos = ENTRY_TOP - half;
|
||||
tr.entering = true;
|
||||
}
|
||||
tr.ent_v = 0.0f;
|
||||
tr.ent_start = now;
|
||||
}
|
||||
if (!lit) tr.entering = false;
|
||||
const float fade_in = tr.entering ? ENTRANCE_FADE : fmaxf(0.1f, id(follow_me_fade_in).state);
|
||||
const float fade_out = tr.exit_dir != 0 ? EXIT_FADE : fmaxf(0.1f, id(follow_me_fade_out).state);
|
||||
tr.level = lit ? fminf(1.0f, tr.level + dt / fade_in) : fmaxf(0.0f, tr.level - dt / fade_out);
|
||||
if (!lit && tr.level <= 0.0f) tr.tracking = false;
|
||||
|
||||
if (tr.tracking && tr.entering) {
|
||||
// Entrance: underdamped spring toward the person, then hand over.
|
||||
const float person = tr.target + tr.est_v * LEAD + lead_offset(tr.est_v);
|
||||
const float acc = ENTRANCE_W * ENTRANCE_W * (person - tr.pos) - 2.0f * ENTRANCE_ZETA * ENTRANCE_W * tr.ent_v;
|
||||
tr.ent_v += acc * dt;
|
||||
tr.pos += tr.ent_v * dt;
|
||||
if (now - tr.ent_start > ENTRANCE_MAX_MS || (fabsf(person - tr.pos) < 0.5f && fabsf(tr.ent_v) < 2.0f)) {
|
||||
tr.entering = false;
|
||||
tr.centre = tr.pos;
|
||||
tr.limited = tr.pos;
|
||||
}
|
||||
} else if (tr.tracking) {
|
||||
// Window centre moves only when the person pushes past the slack.
|
||||
const float person = tr.target + tr.est_v * LEAD + lead_offset(tr.est_v);
|
||||
const float e = person - tr.centre;
|
||||
if (fabsf(e) > slack) tr.centre = person - copysignf(slack, e);
|
||||
const float step = MAX_SPEED * dt;
|
||||
tr.limited += fminf(step, fmaxf(-step, tr.centre - tr.limited));
|
||||
tr.pos += (tr.limited - tr.pos) * (1.0f - expf(-dt / glide));
|
||||
}
|
||||
|
||||
// Free the slot once dark and out of sight.
|
||||
if (!tr.tracking && now - tr.last_seen > hold_ms) tr.alive = false;
|
||||
max_level = fmaxf(max_level, tr.level);
|
||||
}
|
||||
|
||||
if (!net) return;
|
||||
|
||||
const float nl[4] = {id(nl_red) * 255.0f, id(nl_green) * 255.0f, id(nl_blue) * 255.0f,
|
||||
id(nl_white) * 255.0f};
|
||||
const bool nl_on = nl[0] > 0.0f || nl[1] > 0.0f || nl[2] > 0.0f || nl[3] > 0.0f;
|
||||
if (max_level <= 0.0f && !nl_on) {
|
||||
if (zeros_sent >= TRAILING_ZEROS) return;
|
||||
zeros_sent++;
|
||||
} else {
|
||||
zeros_sent = 0;
|
||||
}
|
||||
|
||||
// Frame: 4 bytes per LED (RGBW) or 3 (RGB), sent over DDP.
|
||||
// LED i covers the wall span between edges i and i + 1; its
|
||||
// brightness is the largest fraction of that span inside any window,
|
||||
// so only the end LEDs of a window are partial.
|
||||
const int bpl = white ? 4 : 3;
|
||||
const int frame_len = NUM_LEDS * bpl;
|
||||
memset(data, 0, frame_len);
|
||||
const float *white_mix = WHITE_MIX[std::min<size_t>(id(follow_me_rgb_white).active_index().value_or(1), 2)];
|
||||
const float peak[4] = {id(fm_red) * 255.0f, id(fm_green) * 255.0f, id(fm_blue) * 255.0f,
|
||||
id(fm_white) * 255.0f};
|
||||
// Follow-Me Glow effect: replaces the colour with a per-LED pattern
|
||||
// at the light's brightness (transitions included).
|
||||
const StringRef fx_name = id(follow_me_glow).get_effect_name();
|
||||
const follow_me::fx::Effect effect = follow_me::fx::from_name(fx_name.c_str(), fx_name.size());
|
||||
const LightColorValues &glow_now = id(follow_me_glow).current_values;
|
||||
const float fx_peak = glow_now.get_state() * glow_now.get_brightness() * 255.0f;
|
||||
const float fx_t = (now % 86400000u) / 1000.0f; // wraps daily so float seconds keep ms resolution
|
||||
// Each window's lit span; leaving windows shrink toward the end the
|
||||
// person left by.
|
||||
float win_lo[MAX_TRACKS], win_hi[MAX_TRACKS], win_eased[MAX_TRACKS];
|
||||
for (int k = 0; k < MAX_TRACKS; k++) {
|
||||
const follow_me::Track &tr = tracks[k];
|
||||
win_eased[k] = tr.level * tr.level * (3.0f - 2.0f * tr.level); // smoothstep
|
||||
const float centre = follow_me::draw_pos(tr.pos);
|
||||
float lo = centre - length / 2.0f;
|
||||
float hi = fminf(DRAW_BOTTOM, centre + length / 2.0f);
|
||||
lo = fmaxf(lo, fminf(DRAW_TOP, hi));
|
||||
if (tr.exit_dir > 0) {
|
||||
lo = hi - (hi - lo) * tr.level;
|
||||
} else if (tr.exit_dir < 0) {
|
||||
lo = fmaxf(lo, STRIP_TOP_END);
|
||||
hi = lo + (hi - lo) * tr.level;
|
||||
}
|
||||
win_lo[k] = lo;
|
||||
win_hi[k] = hi;
|
||||
}
|
||||
const float nl_len = fmaxf(0.5f, id(follow_me_night_light_size).state) * STEP;
|
||||
const float nl_edge = fminf(NL_EDGE, nl_len);
|
||||
for (int i = 0; i < NUM_LEDS; i++) {
|
||||
const float a = fminf(edge_draw[i], edge_draw[i + 1]), b = fmaxf(edge_draw[i], edge_draw[i + 1]);
|
||||
float c = 0.0f;
|
||||
int lit_by = 0; // window giving this LED its brightness
|
||||
for (int k = 0; k < MAX_TRACKS; k++) {
|
||||
if (win_eased[k] <= 0.0f) continue;
|
||||
const float cover = (fminf(win_hi[k], b) - fmaxf(win_lo[k], a)) / (b - a);
|
||||
if (cover > 0.0f && fminf(1.0f, cover) * win_eased[k] > c) {
|
||||
c = fminf(1.0f, cover) * win_eased[k];
|
||||
lit_by = k;
|
||||
}
|
||||
}
|
||||
// Night light patch at each end of the strip.
|
||||
float n = 0.0f;
|
||||
const float mid = 0.5f * (a + b);
|
||||
const float wall_mid = 0.5f * (edge_wall[i] + edge_wall[i + 1]); // effects are laid out in wall inches
|
||||
if (nl_on && mid >= DRAW_TOP && mid <= DRAW_BOTTOM) {
|
||||
const float d = fminf(mid - STRIP_TOP_END, STRIP_BOTTOM_END - mid); // inches from the nearer end
|
||||
if (d <= nl_len - nl_edge) {
|
||||
n = 1.0f;
|
||||
} else if (d < nl_len) {
|
||||
const float f = (d - (nl_len - nl_edge)) / nl_edge; // 0 at the patch, 1 at its edge
|
||||
n = powf(0.5f + 0.5f * cosf(3.14159265f * f), GAMMA);
|
||||
}
|
||||
}
|
||||
if (c <= 0.0f && n <= 0.0f) continue;
|
||||
const float g = c > 0.0f ? powf(c, GAMMA) : 0.0f;
|
||||
float led_peak[4] = {peak[0], peak[1], peak[2], peak[3]};
|
||||
if (effect != follow_me::fx::NONE && c > 0.0f) {
|
||||
const follow_me::fx::Rgbw col =
|
||||
follow_me::fx::paint(effect, i, wall_mid, fx_t, tracks[lit_by].pos, length);
|
||||
led_peak[0] = col.r * fx_peak;
|
||||
led_peak[1] = col.g * fx_peak;
|
||||
led_peak[2] = col.b * fx_peak;
|
||||
led_peak[3] = col.w * fx_peak;
|
||||
}
|
||||
float v[4];
|
||||
for (int ch = 0; ch < 4; ch++) v[ch] = fmaxf(led_peak[ch] * g, nl[ch] * n);
|
||||
if (!white)
|
||||
for (int ch = 0; ch < 3; ch++) v[ch] += v[3] * white_mix[ch];
|
||||
for (int ch = 0; ch < bpl; ch++) {
|
||||
const int q = (int) lroundf(v[ch]);
|
||||
data[i * bpl + ch] = (uint8_t) (q > 255 ? 255 : q);
|
||||
}
|
||||
}
|
||||
// Send only frames that changed, plus a keepalive.
|
||||
if (frame_len == sent_len && wled_ip == sent_ip && memcmp(data, sent, frame_len) == 0 &&
|
||||
now - last_send < KEEPALIVE_MS)
|
||||
return;
|
||||
memcpy(sent, data, frame_len);
|
||||
sent_len = frame_len;
|
||||
sent_ip = wled_ip;
|
||||
last_send = now;
|
||||
follow_me::send_ddp(wled_ip, data, frame_len, bpl);
|
||||
@@ -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.
@@ -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);
|
||||
@@ -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)}`);
|
||||
@@ -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}`));
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,40 @@
|
||||
# WLED
|
||||
|
||||
## Settings follow-me needs
|
||||
|
||||
Follow-me streams frames to the WLED controller over DDP (UDP port 4048)
|
||||
while a person is on the stairs. WLED's own state stays off; the stream lights
|
||||
the strip by itself, and the strip returns to WLED's own state after WLED's
|
||||
realtime timeout (2.5 s by default) once frames stop.
|
||||
|
||||
The setup wizard's "Read" checks these settings and "Fix WLED settings" writes
|
||||
them.
|
||||
|
||||
| Setting | Value | Why |
|
||||
|---|---|---|
|
||||
| Sync interfaces → Realtime → Receive UDP realtime | On | Turns on realtime input, DDP included |
|
||||
| Realtime → Use main segment only | Off | When on, realtime LED numbers become segment-relative and the wizard's LED marks shift |
|
||||
| Realtime → LED offset | 0 | Any offset shifts every LED |
|
||||
| Realtime → Force max brightness | On | The stream sets brightness; WLED's master brightness must not scale it again |
|
||||
| Realtime → Disable realtime gamma correction | On | The firmware applies its own gamma (2.2) |
|
||||
| LED preferences → Turn LEDs on after power up | Off (with "Pause While WLED On" set) | While WLED is on, follow-me pauses (manual control wins); a WLED that boots on would leave follow-me paused after every power cycle |
|
||||
| LED type / count | Set to match the strip | Follow-me reads both from WLED |
|
||||
|
||||
Manual control: the firmware polls WLED's `/json/state`. With "Follow-Me Pause
|
||||
While WLED On" set (the default), while WLED reports itself on (turned on from Home Assistant, the WLED app or its button),
|
||||
follow-me sends nothing and makes WLED leave realtime mode at once.
|
||||
|
||||
Tested with WLED 16 (RGBW strip).
|
||||
|
||||
## Glow rules
|
||||
|
||||
The firmware's glow follows rules found while tuning the look on a real strip:
|
||||
|
||||
- No temporal dithering. At the lowest brightness steps the toggling is
|
||||
visible as flicker.
|
||||
- No fading trail.
|
||||
- The glow shape reaches exactly zero at its edge. A Gaussian lingers at the
|
||||
lowest steps, where each 8-bit step is visible.
|
||||
- Brightness comes only from the stream, not WLED's master brightness, to
|
||||
avoid quantising twice.
|
||||
- Gamma 2.2 before quantising to 8 bits.
|
||||
Reference in new issue
Block a user