Skip to content
Merged
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Static site, **no build step** — do not add bundlers, frameworks, or npm deps
- Test: `cd tests && npx playwright test` (fails confusingly if run from repo root). Registry-driven suite.
- Validate registry: `python scripts/validate_registry.py` (network, ~60s).
- `devices.json` is the single source of truth; views, scripts, and tests all read it. Device ids are lowercase-kebab and become URL hashes (`#/air-1`) and image filenames.
- Per-variant overrides in `devices.json` all follow one shape: `channel -> variant -> value`, listing only the variants that differ from the device-level field. `repos` overrides `repo`, `installers` overrides `githubPagesInstaller` (`null` hides the link), `platforms` overrides `platform` (`esphome` default, or `wled`, which selects the step-3 wording). The device-level field stays the fallback, so a device that ships one ecosystem today needs no map.
- **Deploy gotcha:** `.github/workflows/pages.yml` copies an explicit file list into `_site/`. Any new runtime file or directory must be added there or it 404s in production while working locally.
- `vendor/esp-web-tools/` is a pinned upstream copy — never hand-edit.
- Security stance: registry fields are trusted repo content; runtime-fetched content (GitHub releases, manifest-derived filenames) must go through the existing escaping (`esc()`, `encodeURI`) in `js/views/device.js`.
Expand Down
1 change: 1 addition & 0 deletions devices.json
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@
"image": "images/m-1.jpg",
"imageSource": "https://cdn.shopify.com/s/files/1/0792/0959/5187/files/M-1_Full_Kit_bd50d749-ee4d-43fd-b2b9-8093d3459388.jpg?v=1754315671",
"wiki": "https://wiki.apolloautomation.com/products/m1/introduction/",
"platform": "wled",
"repo": "ApolloAutomation/WLED-MM-M1",
"githubPagesInstaller": "https://apolloautomation.github.io/WLED-MM-M1/",
"firmware": {
Expand Down
7 changes: 7 additions & 0 deletions docs/m1-wled-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ Its manifest sets new_install_prompt_erase, so esp-web-tools prompts a full eras
guarantees a factory-fresh 64x64 boot. It is offered for Rev6 only, so Rev4 owners must switch
to the WLED-MM 14.5.1 (Rev4) variant.

The M-1 entry sets `"platform": "wled"`, so step 3 of the wizard gives the WLED onboarding
(WLED-AP fallback, discovered WLED integration, WLED web UI for effects and manual OTA) instead
of the ESPHome Dashboard "Take control" flow. The hardware can run ESPHome, we just do not offer
an ESPHome build yet. When we do, add that manifest as another variant and mark it in a
`platforms` map (`"platforms": { "stable": { "<variant>": "esphome" } }`); step 3 then switches
back to the ESPHome instructions whenever that variant is selected, and stays WLED for the rest.

Because the default variant has no classic installer page of its own, the M-1 page now loads with
the "Classic installer" link hidden and the header GitHub link pointing at WLED-M1; both come
back when a 14.5.1 variant is selected.
Expand Down
66 changes: 55 additions & 11 deletions js/views/device.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,52 @@ function classicInstallerFor(device, channel, variant) {
return map && variant in map ? map[variant] : device.githubPagesInstaller;
}

// Firmware ecosystem for a variant. Mirrors `repos`/`installers`: an optional
// per-variant `platforms` map wins, then the device-level `platform`, then
// esphome. A device offering both WLED and ESPHome builds (the M-1 could) marks
// only the variants that differ from its device-level default.
function platformFor(device, channel, variant) {
const map = device.platforms && device.platforms[channel];
if (map && variant in map) return map[variant];
return device.platform || 'esphome';
}

// Step 3 differs by ecosystem: ESPHome devices are adopted through the ESPHome
// integration and dashboard, WLED devices through the WLED integration and the
// WLED web UI.
function stepThreeHtml(device, platform) {
if (platform === 'wled') {
return `
<p>If you did not set Wi-Fi during install, join the device's own
<strong>WLED-AP</strong> network (password <code>wled1234</code>) and pick your network
at <code>4.3.2.1</code>. Once it is on your Wi-Fi, go to
<strong>Settings → Devices &amp; services</strong> in Home Assistant, where it appears as a
discovered <strong>WLED</strong> device. Click <strong>Configure</strong>, and you're done.
<span class="done-check">✓</span></p>
<details class="customize">
<summary>Want to customize the firmware?</summary>
<p>The ${device.name} runs WLED, so effects and settings are customized in WLED itself.
Open its web UI at the device's IP address (or <code>http://wled.local</code>),
or use the WLED mobile app, to change effects, colors, presets and segments.
Later firmware updates install from that UI under
<strong>Config → Security &amp; Updates → Manual OTA Update</strong> using the
<code>_ota.bin</code> asset from the latest release.</p>
</details>`;
}
return `
<p>After installing, the device broadcasts itself on your network.
In Home Assistant go to <strong>Settings → Devices &amp; services</strong> — it appears as a
discovered <strong>ESPHome</strong> device. Click <strong>Configure</strong>, and you're done.
<span class="done-check">✓</span></p>
<details class="customize">
<summary>Want to customize the firmware?</summary>
<p>Apollo firmware ships with <code>dashboard_import</code>, so the device also shows up in the
<strong>ESPHome Dashboard</strong> (or the ESPHome add-on in Home Assistant) under
<strong>Discovered</strong>. Click <strong>Take control</strong> to pull its configuration
into the dashboard, then edit it and flash updates over Wi-Fi.</p>
</details>`;
}

function segHtml(id, label, keys, active, dataAttr) {
if (keys.length < 2) return '';
return `
Expand Down Expand Up @@ -84,24 +130,19 @@ export function renderDevice(el, device) {

<section class="step" id="step-done">
<h2><span class="num">3</span> Add to Home Assistant</h2>
<p>After installing, the device broadcasts itself on your network.
In Home Assistant go to <strong>Settings → Devices &amp; services</strong> — it appears as a
discovered <strong>ESPHome</strong> device. Click <strong>Configure</strong>, and you're done.
<span class="done-check">✓</span></p>
<details class="customize">
<summary>Want to customize the firmware?</summary>
<p>Apollo firmware ships with <code>dashboard_import</code>, so the device also shows up in the
<strong>ESPHome Dashboard</strong> (or the ESPHome add-on in Home Assistant) under
<strong>Discovered</strong>. Click <strong>Take control</strong> to pull its configuration
into the dashboard, then edit it and flash updates over Wi-Fi.</p>
</details>
<div id="step3-slot"></div>
<p><a href="${device.wiki}">Full ${device.name} setup guide on the wiki →</a></p>
</section>
</div>`;

const variantSlot = el.querySelector('#variant-slot');
const installSlot = el.querySelector('#install-slot');
const linksSlot = el.querySelector('#links-slot');
const stepThreeSlot = el.querySelector('#step3-slot');

function renderStepThree() {
stepThreeSlot.innerHTML = stepThreeHtml(device, platformFor(device, channel, variant));
}

function renderLinks() {
const repo = repoFor(device, channel, variant);
Expand Down Expand Up @@ -132,6 +173,7 @@ export function renderDevice(el, device) {
renderConfig();
renderReleaseNotes();
renderLinks();
renderStepThree();
});
}

Expand Down Expand Up @@ -264,11 +306,13 @@ export function renderDevice(el, device) {
renderReleaseNotes();
renderConfig();
renderLinks();
renderStepThree();
});

renderVariantSeg();
renderInstall();
renderReleaseNotes();
renderConfig();
renderLinks();
renderStepThree();
}
41 changes: 41 additions & 0 deletions scripts/test_validate_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,5 +151,46 @@ def test_variant_not_in_firmware_errors(self):
self.assertTrue(any("no such firmware variant" in e for e in errs), errs)


class PlatformChecks(unittest.TestCase):
def test_absent_means_esphome(self):
self.assertEqual(vr.check_platform(None, "dev"), [])

def test_known_platforms_ok(self):
for p in ("esphome", "wled"):
self.assertEqual(vr.check_platform(p, "dev"), [], p)

def test_unknown_platform_errors(self):
for bad in ("ESPHome", "tasmota", "", 5):
errs = vr.check_platform(bad, "dev")
self.assertTrue(any("platform" in e for e in errs), (bad, errs))


class PlatformsShapeChecks(unittest.TestCase):
FW = {"stable": {"v16": "https://x/m.json", "v14": "https://y/m.json"}}

def test_absent_ok(self):
self.assertEqual(vr.check_platforms_shape(None, self.FW, "dev"), [])

def test_override_ok(self):
pf = {"stable": {"v16": "esphome"}}
self.assertEqual(vr.check_platforms_shape(pf, self.FW, "dev"), [])

def test_not_dict_errors(self):
errs = vr.check_platforms_shape([], self.FW, "dev")
self.assertTrue(any("platforms" in e for e in errs), errs)

def test_channel_not_dict_errors(self):
errs = vr.check_platforms_shape({"stable": "x"}, self.FW, "dev")
self.assertTrue(any("stable" in e for e in errs), errs)

def test_unknown_platform_errors(self):
errs = vr.check_platforms_shape({"stable": {"v16": "tasmota"}}, self.FW, "dev")
self.assertTrue(any("not one of" in e for e in errs), errs)

def test_variant_not_in_firmware_errors(self):
errs = vr.check_platforms_shape({"stable": {"ghost": "wled"}}, self.FW, "dev")
self.assertTrue(any("no such firmware variant" in e for e in errs), errs)


if __name__ == "__main__":
unittest.main()
42 changes: 42 additions & 0 deletions scripts/validate_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,46 @@ def check_installers_shape(installers, firmware, dev_id):
errs.append(f"{dev_id} installers {channel}/{variant}: no such firmware variant")
return errs

PLATFORMS = ("esphome", "wled")

def check_platform(platform, dev_id):
"""Validate the optional `platform` field (firmware ecosystem).

Network-free. Absent (None) means esphome, which is what step 3 of the
wizard falls back to. Anything outside PLATFORMS would silently render the
ESPHome instructions on a device that does not run ESPHome.
"""
if platform is None or platform in PLATFORMS:
return []
return [f"{dev_id} platform: {platform!r} not one of {', '.join(PLATFORMS)}"]

def check_platforms_shape(platforms, firmware, dev_id):
"""Validate the optional per-variant `platforms` map (channel -> variant -> platform).

Network-free. Mirrors `repos`/`installers`: absent (None) is valid, only
variants that differ from the device-level `platform` need listing, and every
variant key must exist in `firmware[channel]`. Returns a list of errors.
"""
errs = []
if platforms is None:
return errs
if not isinstance(platforms, dict):
errs.append(f"{dev_id} platforms: not an object")
return errs
for channel, variants in platforms.items():
if not isinstance(variants, dict):
errs.append(f"{dev_id} platforms {channel}: not an object")
continue
for variant, platform in variants.items():
if platform not in PLATFORMS:
errs.append(
f"{dev_id} platforms {channel}/{variant}: "
f"{platform!r} not one of {', '.join(PLATFORMS)}"
)
if variant not in firmware.get(channel, {}):
errs.append(f"{dev_id} platforms {channel}/{variant}: no such firmware variant")
return errs

def check_manifest(dev_id, channel, variant, murl):
where = f"{dev_id} {channel}/{variant}"
try:
Expand Down Expand Up @@ -149,6 +189,8 @@ def main():
errors.extend(check_config_shape(config, dev["id"]))
errors.extend(check_repos_shape(dev.get("repos"), dev.get("firmware", {}), dev["id"]))
errors.extend(check_installers_shape(dev.get("installers"), dev.get("firmware", {}), dev["id"]))
errors.extend(check_platform(dev.get("platform"), dev["id"]))
errors.extend(check_platforms_shape(dev.get("platforms"), dev.get("firmware", {}), dev["id"]))
if not isinstance(config, dict):
continue
for channel, variants in config.items():
Expand Down
45 changes: 44 additions & 1 deletion tests/installer.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -316,14 +316,57 @@ test('step 3 shows the Home Assistant hand-off', async ({ page }) => {
});

test('step 3 explains taking control in the ESPHome Dashboard', async ({ page }) => {
const d = registry.devices[0];
const d = registry.devices.find((x) => !x.platform || x.platform === 'esphome');
test.skip(!d, 'no esphome device in registry');
await page.goto(`/#/${d.id}`);
const done = page.locator('#step-done');
await expect(done).toContainText('ESPHome Dashboard');
await expect(done).toContainText('Take control');
await expect(done.locator('code')).toContainText('dashboard_import');
});

test('step 3 gives WLED instructions on a WLED device, not ESPHome ones', async ({ page }) => {
const d = registry.devices.find((x) => x.platform === 'wled');
test.skip(!d, 'no wled device in registry');
await page.goto(`/#/${d.id}`);
const done = page.locator('#step-done');
await expect(done).toContainText('discovered');
await expect(done).toContainText('WLED-AP');
await expect(done).toContainText('Manual OTA Update');
// The ESPHome adoption path does not exist on a WLED device.
await expect(done).not.toContainText('ESPHome Dashboard');
await expect(done).not.toContainText('Take control');
await expect(done).not.toContainText('dashboard_import');
});

test('step 3 follows a per-variant platform override', async ({ page }) => {
// No shipping device mixes ecosystems yet, so synthesize one: the M-1 hardware
// can run ESPHome, and when such a build is offered the registry only needs a
// `platforms` entry for that variant.
const src = registry.devices.find((x) => x.platform === 'wled');
test.skip(!src, 'no wled device in registry');
const d = JSON.parse(JSON.stringify(src));
const wledVariant = Object.keys(d.firmware.stable)[0];
const espVariant = 'ESPHome (test)';
d.firmware.stable[espVariant] = d.firmware.stable[wledVariant];
d.platforms = { stable: { [espVariant]: 'esphome' } };

await page.route('**/devices.json', (route) => route.fulfill({ json: { devices: [d] } }));
await page.route('https://api.github.com/**', (route) => route.fulfill({ status: 403 }));
await page.goto(`/#/${d.id}`);

const done = page.locator('#step-done');
await expect(done).toContainText('WLED-AP');

await page.locator(`#variant-seg button[data-variant="${espVariant}"]`).click();
await expect(done).toContainText('ESPHome Dashboard');
await expect(done).not.toContainText('WLED-AP');

// And back, so the override is not a one-way trip.
await page.locator(`#variant-seg button[data-variant="${wledVariant}"]`).click();
await expect(done).toContainText('WLED-AP');
});

function blobFromRaw(raw) {
const m = raw.match(/^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/);
// Mirror the app's rawToBlob fallback: on a non-match, return the raw URL rather
Expand Down
Loading