Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,3 +311,33 @@ its unique suffix (apollo-led-matrix-xxxxxx); only the hotspot name
changed. FIRMWARE change: b8 app no longer byte-identical to b7, virgin
boot + battery re-run before the walkthrough; the SSID fix reaches
existing units via OTA, unlike the FS card.

### D27. b9 (2026-10-25): a no-WiFi owner's launch preset wins over the setup signpost
BUG in b8 (reported by Justin): with no WiFi configured, beginStrip() applied
preset 9 (dog, then setup card) instead of ANY launch preset, so an AP-only
owner's launch preset was saved and shown in the UI but never ran at boot.
That contradicts D20 (full control with no network). No earlier decision or
QA row covered it; the D13 gate never set a launch preset on an AP-only unit.
FIX: the signpost plays only while WiFi is unconfigured AND the launch
preset is still the factory one (cfg.json def.ps 4) AND the owner has not
chosen one AND presets.json has a named preset 9. "Chosen" is a new
persisted flag (cfg def.psc). It is set when Config > LED & Hardware is
saved while no WiFi is configured (the "Apply preset N at boot" field under
Power up), or when "Apply at boot" is ticked on a DIFFERENT preset in the
preset editor (that box is pre-ticked on the current launch preset, so just
re-saving the dog preset does not count). Choosing the dog itself therefore
works through LED & Hardware. Compiled in only by [env:apollo_m1]
(-D FACTORY_SIGNPOST_PRESET=9 -D FACTORY_BOOT_PRESET=4); other envs of the
fork boot like upstream. No apollo/fs change, so it reaches b8 units by OTA:
an owner who already set a launch preset other than 4 gets it at once.
Also fixes a wiped filesystem (factory reset) and a WLED-MM upgrade without
preset 9, which now run their launch preset (0 = plain Apollo blue) instead
of applying a missing preset 9.
TRADEOFF, ACCEPTED (Justin, 2026-10-25; the narrower "only when the launch
preset number changes" was rejected because an owner could then never pick
the dog, already the factory value, from that page): saving LED & Hardware without WiFi for
ANY reason (for example the HUB75 color order from PR #8) counts as choosing
the launch preset shown there, so the boot signpost stops. The card stays
available on demand as preset 5 "Setup". There is no UI way back to the
signpost short of a full install; do not tell owners to use launch preset
9 for it, because 9 keeps showing the hotspot card after WiFi is set up.
27 changes: 24 additions & 3 deletions QA_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,9 +270,10 @@ unit. This is the D20 acceptance: AP mode is a first-class destination.
STATIC setup card (D24): STEP 1 JOIN APOLLO WIFI HOTSPOT / 21px QR /
STEP 2 SCAN OR OPEN BROWSER TO 4.3.2.1, at brightness 220, holding
indefinitely. NO cycling, no page turns.
- [ ] Reset or power-cycle: dog then card return (boot preset 9 playlist
self-heals; customer preset list shows 5 "Setup" to recall the card).
- [ ] Phone joins "Apollo M-1-xxxxxx" (open). Accept any "no internet -
- [ ] Reset or power-cycle, no launch preset saved: dog then card return
(boot preset 9 playlist self-heals; customer preset list shows 5
"Setup" to recall the card).
- [ ] Phone joins "Apollo M-1" (open). Accept any "no internet -
stay connected?" prompt. NO captive popup should appear (D22).
- [ ] Scan the QR off the panel with the camera app in NORMAL ROOM LIGHT:
browser opens http://4.3.2.1 (with mobile data still ON).
Expand Down Expand Up @@ -305,3 +306,23 @@ PASS ("everything looks great"), with Sound Bars verified as
applies-cleanly-idle on this no-mic unit (reactivity row inherited from
the 2026-07-15 glass verification on the mic-equipped unit; audio code
untouched b7->b8). SHIP-READY RE-ASSERTED for bundle b8.

## D27. Launch preset without WiFi (b9+; run on a SECOND factory-fresh unit,
## never on the D13 unit: these steps mark the launch preset as chosen, which
## cannot be undone from the UI and would break D13's final provision row)
Phone on the "Apollo M-1" hotspot, 4.3.2.1, no WiFi ever configured.
- [ ] Full install, power-cycle twice without saving any settings: dog then
card both times.
- [ ] Preset editor: open 4 "Apollo Dog" (Apply at boot is pre-ticked),
rename it, save; power-cycle: dog then card STILL return.
- [ ] Config > LED & Hardware > Power up: "Apply preset 3 at boot" > Save;
power-cycle: Sound Bars starts, no dog, no card. /json/cfg shows
def {"ps":3,"psc":true}.
- [ ] Same field = 4 > Save; power-cycle: dog only, no card.
- [ ] Same field = 2 > Save; power-cycle: Apollo Blue preset.
- [ ] Preset editor: tick "Apply at boot" on 1 "Scrolling Text", save;
power-cycle: Scrolling Text plays.
- [ ] Preset 5 "Setup" from the preset list still shows the card on demand.
- [ ] OTA path (third unit, or after a full install of b8): on b8 set the
LED & Hardware launch preset to 3 (dog then card still show: the b8
bug), OTA M-1_ota.bin b9, power-cycle: Sound Bars, no re-save needed.
5 changes: 3 additions & 2 deletions apollo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ tree itself. Rules for branches, upstream WLED and releases: CLAUDE.md in the re
- fs/presets.json - the factory presets: 1 Scrolling Text, 2 Apollo Blue, 3 Sound
Bars, 4 Apollo Dog, 5 Setup (the first-boot setup card at brightness 220), and
9 First Boot (a playlist that plays the dog once for 4 s, then holds the Setup
card; applied automatically at boot while WiFi is unconfigured, see
FACTORY_SIGNPOST_PRESET). Historical note: units shipped on WLED-MM carried no
card; applied at boot while WiFi is unconfigured and the owner has not chosen a
launch preset, see FACTORY_SIGNPOST_PRESET in [env:apollo_m1] and DECISIONS D27).
Historical note: units shipped on WLED-MM carried no
presets; these first shipped with the 16.0.1 migration.
- fs/apollo_dog.gif - the animated first-boot dog (D19).
- fs/setup.gif - the static setup card (D24): join instructions, QR code, and the
Expand Down
116 changes: 116 additions & 0 deletions apollo/boot-signpost-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// AI: below section was generated by an AI
// Host-side check of the Apollo M-1 boot signpost in wled00/wled.cpp beginStrip() (DECISIONS D23/D24/D27).
// Runs with `npm test` (node --test). It reads the signpost condition from the C++ source and the signpost
// presets from [env:apollo_m1], evaluates the condition for each boot case, and asserts:
// - a factory-fresh unit with no WiFi (factory def.ps, no def.psc) still boots the signpost playlist,
// - a no-WiFi owner's launch preset is honoured once chosen (Apollo Dog included) or when it is not the factory one,
// - WiFi-configured units boot their own preset exactly as upstream,
// - the signpost is compiled in only where [env:apollo_m1] enables it, and the values match apollo/fs,
// - every code path that writes bootPreset from the UI also sets bootPresetChosen, and cfg.cpp persists it.
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');

const root = path.join(__dirname, '..');
const read = f => fs.readFileSync(path.join(root, f), 'utf8');
const stripIniComments = s => s.split('\n').map(l => l.replace(/;.*$/, '')).join('\n');

// [env:apollo_m1] build flags (section runs to the next [section] or end of file)
const ini = read('platformio.ini');
const envStart = ini.indexOf('[env:apollo_m1]');
assert.ok(envStart >= 0, '[env:apollo_m1] not found in platformio.ini');
const envEnd = ini.indexOf('\n[', envStart + 1);
const envM1 = stripIniComments(ini.slice(envStart, envEnd < 0 ? undefined : envEnd));
const flag = name => { const m = envM1.match(new RegExp(`-D\\s*${name}=(\\d+)`)); return m ? +m[1] : undefined; };
const SIGNPOST = flag('FACTORY_SIGNPOST_PRESET');
const FACTORY_BOOT = flag('FACTORY_BOOT_PRESET');

// beginStrip() body and the signpost condition inside its #ifdef
const wledCpp = read('wled00/wled.cpp');
const bsStart = wledCpp.indexOf('void WLED::beginStrip()');
const beginStrip = wledCpp.slice(bsStart, wledCpp.indexOf('\nvoid WLED::', bsStart + 1));
const gated = beginStrip.match(/#ifdef FACTORY_SIGNPOST_PRESET([\s\S]*?)#endif/);
// if (<cond>) { String x; if (getPresetName(FACTORY_SIGNPOST_PRESET, x)) bootPs = FACTORY_SIGNPOST_PRESET; }
const condMatch = gated && gated[1].match(/if \(([^;{}]*?)\)\s*\{\s*String (\w+);\s*if \(getPresetName\(FACTORY_SIGNPOST_PRESET, \2\)\)\s*bootPs\s*=\s*FACTORY_SIGNPOST_PRESET;\s*\}/);

// boot decision as compiled for the M-1: returns the preset applied, or 'DEFAULT_COLOR' when none.
// signpostExists models getPresetName(9): false when presets.json has no named preset 9 (e.g. a WLED-MM upgrade).
function bootM1(wifiConfigured, chosen, bootPreset, signpostExists = true) {
const cond = condMatch[1];
const ids = new Set(cond.match(/[A-Za-z_]\w*/g));
const allowed = ['WLED_WIFI_CONFIGURED', 'bootPresetChosen', 'bootPreset', 'FACTORY_BOOT_PRESET', 'FACTORY_SIGNPOST_PRESET'];
assert.ok([...ids].every(id => allowed.includes(id)), `unexpected identifier in signpost condition: ${cond}`);
assert.match(cond, /^[\s!&|=()\w]+$/, `signpost condition is not a plain boolean expression: ${cond}`);
const fn = new Function(...allowed, `return (${cond});`);
const ps = (fn(wifiConfigured, chosen, bootPreset, FACTORY_BOOT, SIGNPOST) && signpostExists) ? SIGNPOST : bootPreset;
return ps > 0 ? ps : 'DEFAULT_COLOR';
}

test('signpost is compiled in only by [env:apollo_m1]', () => {
assert.strictEqual(SIGNPOST, 9, '-D FACTORY_SIGNPOST_PRESET missing from [env:apollo_m1]');
assert.strictEqual(FACTORY_BOOT, 4, '-D FACTORY_BOOT_PRESET missing from [env:apollo_m1]');
const elsewhere = stripIniComments(ini.slice(0, envStart)).match(/FACTORY_SIGNPOST_PRESET/g);
assert.strictEqual(elsewhere, null, 'another env defines FACTORY_SIGNPOST_PRESET');
for (const f of ['wled00/const.h', 'wled00/wled.h']) {
assert.doesNotMatch(read(f), /#define\s+FACTORY_SIGNPOST_PRESET/, `${f} must not define a default`);
}
assert.ok(condMatch, 'signpost condition not found inside #ifdef FACTORY_SIGNPOST_PRESET in beginStrip()');
assert.match(beginStrip, /byte bootPs = bootPreset;/);
assert.match(beginStrip, /if \(bootPs > 0\) \{\s*applyPreset\(bootPs, CALL_MODE_INIT\);/);
assert.doesNotMatch(beginStrip.replace(gated[0], ''), /FACTORY_SIGNPOST_PRESET|bootPresetChosen/, 'signpost code outside the #ifdef');
});

test('flag values match the factory filesystem', () => {
const cfg = JSON.parse(read('apollo/fs/cfg.json'));
const presets = JSON.parse(read('apollo/fs/presets.json'));
assert.strictEqual(cfg.def.ps, FACTORY_BOOT, 'FACTORY_BOOT_PRESET must equal apollo/fs/cfg.json def.ps');
assert.strictEqual(cfg.def.psc, undefined, 'factory cfg.json must not mark the boot preset as chosen');
assert.strictEqual(presets[FACTORY_BOOT].seg[0].n, 'apollo_dog.gif');
const pl = presets[SIGNPOST].playlist;
assert.ok(pl, `preset ${SIGNPOST} must be the signpost playlist`);
assert.deepStrictEqual(pl.ps, [FACTORY_BOOT]);
assert.strictEqual(presets[pl.end].seg[0].n, 'setup.gif', 'signpost playlist must end on the Setup card');
});

test('boot behaviour per case', () => {
const D = FACTORY_BOOT;
// [wifiConfigured, bootPresetChosen, bootPreset, expected, case]
const cases = [
[false, false, D, SIGNPOST, 'factory-fresh, no WiFi: dog then setup card'],
[false, false, 3, 3, 'no WiFi, launch preset 3 (also b8 units updated by OTA, no psc key)'],
[false, true, 3, 3, 'no WiFi, owner chose 3'],
[false, true, D, D, 'no WiFi, owner chose Apollo Dog'],
[false, true, 0, 'DEFAULT_COLOR', 'no WiFi, owner chose none'],
[false, true, SIGNPOST, SIGNPOST, 'no WiFi, owner chose First Boot on purpose'],
[false, false, 0, 'DEFAULT_COLOR', 'wiped filesystem: Apollo blue, no missing-preset error'],
[false, false, 23, 23, 'WLED-MM upgrade, no WiFi, own boot preset'],
[true, false, D, D, 'WiFi configured: factory dog (D24 self-correction)'],
[true, false, 0, 'DEFAULT_COLOR', 'WiFi configured, no boot preset'],
[true, true, 7, 7, 'WiFi configured, owner preset'],
];
for (const [wifi, chosen, bp, want, name] of cases) assert.strictEqual(bootM1(wifi, chosen, bp), want, name);
// no named preset 9 in presets.json (WLED-MM upgrade, or the owner deleted it): the owner's preset 4 runs
assert.strictEqual(bootM1(false, false, D, false), D, 'no preset 9: launch preset 4 runs instead of a missing preset');
});

test('every UI write of bootPreset marks it chosen, and cfg.cpp persists the flag', () => {
for (const f of ['wled00/set.cpp', 'wled00/presets.cpp', 'wled00/json.cpp']) {
const lines = read(f).split('\n');
lines.forEach((l, i) => {
if (!/\bbootPreset\s*=[^=]/.test(l)) return;
const around = lines.slice(Math.max(0, i - 4), i + 4).join('\n'); // the guard may sit just before or just after the write
assert.match(around, /#ifdef FACTORY_SIGNPOST_PRESET\s*\n[^\n]*bootPresetChosen = true;/, `${f}:${i + 1} writes bootPreset without setting bootPresetChosen`);
});
}
// LED & Hardware save counts only without WiFi; "Apply at boot" counts only when it picks a different preset
// (the box is pre-ticked on the current boot preset, so re-saving the dog preset must not end the signpost)
assert.match(read('wled00/set.cpp'), /if \(!WLED_WIFI_CONFIGURED\) bootPresetChosen = true;/);
assert.match(read('wled00/presets.cpp'), /if \(\(sObj\[FPSTR\(bootPS\)\] \| bootPreset\) != bootPreset\) bootPresetChosen = true;/);
const cfg = read('wled00/cfg.cpp');
assert.match(cfg, /CJSON\(bootPreset, def\["ps"\]\);\s*#ifdef FACTORY_SIGNPOST_PRESET\s*CJSON\(bootPresetChosen, def\["psc"\]\);/);
assert.match(cfg, /def\["ps"\] = bootPreset;\s*#ifdef FACTORY_SIGNPOST_PRESET\s*def\["psc"\] = bootPresetChosen;/);
assert.match(read('wled00/wled.h'), /#ifdef FACTORY_SIGNPOST_PRESET\s*WLED_GLOBAL bool bootPresetChosen _INIT\(false\);/);
});
// AI: end
28 changes: 20 additions & 8 deletions apollo/wiki-rewrites/no-wifi-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,9 @@ panel shows a setup card and keeps showing it until you are set up. The
card has both steps and a QR code, so there is nothing to wait for.

1. **STEP 1 - join the M-1's WiFi.** On your phone or computer, open the
WiFi list and join the network called **Apollo M-1-xxxxxx** (the letters
at the end are unique to your unit). There is no password. If your phone
warns "this network has no internet - stay connected?", choose **stay
connected**. You can leave mobile data on.
WiFi list and join the network called **Apollo M-1**. There is no
password. If your phone warns "this network has no internet - stay
connected?", choose **stay connected**. You can leave mobile data on.
2. **STEP 2 - scan the code on the card.** Point your camera app at the QR
code at the bottom of the card and tap the link. Your browser opens the
M-1's control page.
Expand All @@ -38,10 +37,23 @@ ever touching a network.

### Good to know

- The setup card only appears while the M-1 has no WiFi configured. Once
you connect it to your home WiFi, it boots straight to the Apollo dog
instead, and you reach it at its normal network address. You can always
bring the card back from the preset list (preset "Setup").
- The setup card appears at power-on only while the M-1 has no WiFi
configured and you have not chosen what it should start with. Once you
connect it to your home WiFi, it starts on its launch preset instead (the
Apollo dog, unless you changed it), and you reach it at its normal network
address. You can always bring the card back from the preset list (preset
"Setup").
- **Staying without WiFi? Choose what it starts with (firmware b9 or
later).** Open **Config > LED & Hardware**, scroll to **Power up**, set
**Apply preset ___ at boot** (4 = Apollo dog, 1 = Scrolling Text,
2 = Apollo Blue) and press **Save**. Or open a different preset, tick
**Apply at boot**, and save it. From then on it starts on that preset,
without the setup card.
- Saving the **LED & Hardware** page while the M-1 has no WiFi, for any
reason (for example changing the color order), also counts as choosing:
it starts on the preset shown in **Apply preset ___ at boot** and the
setup card no longer appears at power-on. The card is still in the preset
list as "Setup".
- The hotspot stays available as a fallback: if the M-1 ever cannot reach
your WiFi, it opens its hotspot again and 4.3.2.1 works as above.
- Uploading GIFs from the **WLED phone app** does not work (a known bug in
Expand Down
1 change: 1 addition & 0 deletions platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -916,6 +916,7 @@ build_flags = ${env:esp32s3dev_16MB_opi_hub75.build_flags}
-D WLED_MDNS_PREFIX='"apollo-led-matrix"'
;; factory boot: one 64x64 half-scan panel as a 2D matrix, brightness limiter off
-D DEFAULT_COLOR=0x4379AA ;; Apollo blue welcome color (needs the FX.h ifndef guard)
-D FACTORY_SIGNPOST_PRESET=9 -D FACTORY_BOOT_PRESET=4 ;; setup signpost (D24/D27): apollo/fs presets.json 9 and cfg.json def.ps; other envs compile it out
-D DATA_PINS=64,64,1,1,1 ;; HUB75 slots = panelW,panelH,chain,rows,cols (not GPIOs)
-D MAX_LEDS=16388 ;; 4-panel chain is EXACTLY 16384 and the driver guard is >=; +4 pad mirrors WLED-MM (QA D10 finding)
-D MAX_LED_MEMORY=262144 ;; 4-panel chain estimate is 180224B; stock 192K cap leaves 17K margin before the bus is silently replaced by a placeholder = dark panels (QA D10)
Expand Down
6 changes: 6 additions & 0 deletions wled00/cfg.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,9 @@ bool deserializeConfig(JsonObject doc, bool fromFS) {

JsonObject def = doc["def"];
CJSON(bootPreset, def["ps"]);
#ifdef FACTORY_SIGNPOST_PRESET
CJSON(bootPresetChosen, def["psc"]); // absent in the factory cfg.json and on b8 units: not chosen
#endif
CJSON(turnOnAtBoot, def["on"]); // true
CJSON(briS, def["bri"]); // 128

Expand Down Expand Up @@ -1127,6 +1130,9 @@ void serializeConfig(JsonObject root) {

JsonObject def = root.createNestedObject("def");
def["ps"] = bootPreset;
#ifdef FACTORY_SIGNPOST_PRESET
def["psc"] = bootPresetChosen;
#endif
def["on"] = turnOnAtBoot;
def["bri"] = briS;

Expand Down
4 changes: 0 additions & 4 deletions wled00/const.h
Original file line number Diff line number Diff line change
Expand Up @@ -457,10 +457,6 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit");
#define SEG_CAPABILITY_W 0x02
#define SEG_CAPABILITY_CCT 0x04

#ifndef FACTORY_SIGNPOST_PRESET
#define FACTORY_SIGNPOST_PRESET 9 // preset applied at boot while WiFi is unconfigured (setup signpost); no-op if the preset does not exist
#endif

// WLED Error modes
#define ERR_NONE 0 // All good :)
#define ERR_DENIED 1 // Permission denied
Expand Down
3 changes: 3 additions & 0 deletions wled00/presets.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,9 @@ void savePreset(byte index, const char* pname, JsonObject sObj)

const char *bootPS = PSTR("bootps");
if (!sObj[FPSTR(bootPS)].isNull()) {
#ifdef FACTORY_SIGNPOST_PRESET
if ((sObj[FPSTR(bootPS)] | bootPreset) != bootPreset) bootPresetChosen = true; // a newly ticked "Apply at boot" stops the setup signpost; re-saving the pre-ticked boot preset does not
#endif
bootPreset = sObj[FPSTR(bootPS)] | bootPreset;
sObj.remove(FPSTR(bootPS));
configNeedsWrite = true;
Expand Down
3 changes: 3 additions & 0 deletions wled00/set.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,9 @@ void handleSettingsSet(AsyncWebServerRequest *request, byte subPage)
turnOnAtBoot = request->hasArg(F("BO"));
t = request->arg(F("BP")).toInt();
if (t <= 250) bootPreset = t;
#ifdef FACTORY_SIGNPOST_PRESET
if (!WLED_WIFI_CONFIGURED) bootPresetChosen = true; // saving LED & Hardware without WiFi confirms "Apply preset N at boot": the setup signpost stops
#endif
gammaCorrectBri = request->hasArg(F("GB"));
gammaCorrectCol = request->hasArg(F("GC"));
gammaCorrectVal = request->arg(F("GV")).toFloat();
Expand Down
Loading
Loading