diff --git a/AGENTS.md b/AGENTS.md index 44598c4..38734dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/devices.json b/devices.json index 16397d5..799f717 100644 --- a/devices.json +++ b/devices.json @@ -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": { diff --git a/docs/m1-wled-migration.md b/docs/m1-wled-migration.md index 4ed391e..94a70c5 100644 --- a/docs/m1-wled-migration.md +++ b/docs/m1-wled-migration.md @@ -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": { "": "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. diff --git a/js/views/device.js b/js/views/device.js index 0d45711..9519033 100644 --- a/js/views/device.js +++ b/js/views/device.js @@ -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 ` +

If you did not set Wi-Fi during install, join the device's own + WLED-AP network (password wled1234) and pick your network + at 4.3.2.1. Once it is on your Wi-Fi, go to + Settings → Devices & services in Home Assistant, where it appears as a + discovered WLED device. Click Configure, and you're done. +

+
+ Want to customize the firmware? +

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 http://wled.local), + or use the WLED mobile app, to change effects, colors, presets and segments. + Later firmware updates install from that UI under + Config → Security & Updates → Manual OTA Update using the + _ota.bin asset from the latest release.

+
`; + } + return ` +

After installing, the device broadcasts itself on your network. + In Home Assistant go to Settings → Devices & services — it appears as a + discovered ESPHome device. Click Configure, and you're done. +

+
+ Want to customize the firmware? +

Apollo firmware ships with dashboard_import, so the device also shows up in the + ESPHome Dashboard (or the ESPHome add-on in Home Assistant) under + Discovered. Click Take control to pull its configuration + into the dashboard, then edit it and flash updates over Wi-Fi.

+
`; +} + function segHtml(id, label, keys, active, dataAttr) { if (keys.length < 2) return ''; return ` @@ -84,17 +130,7 @@ export function renderDevice(el, device) {

3 Add to Home Assistant

-

After installing, the device broadcasts itself on your network. - In Home Assistant go to Settings → Devices & services — it appears as a - discovered ESPHome device. Click Configure, and you're done. -

-
- Want to customize the firmware? -

Apollo firmware ships with dashboard_import, so the device also shows up in the - ESPHome Dashboard (or the ESPHome add-on in Home Assistant) under - Discovered. Click Take control to pull its configuration - into the dashboard, then edit it and flash updates over Wi-Fi.

-
+

Full ${device.name} setup guide on the wiki →

`; @@ -102,6 +138,11 @@ export function renderDevice(el, device) { 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); @@ -132,6 +173,7 @@ export function renderDevice(el, device) { renderConfig(); renderReleaseNotes(); renderLinks(); + renderStepThree(); }); } @@ -264,6 +306,7 @@ export function renderDevice(el, device) { renderReleaseNotes(); renderConfig(); renderLinks(); + renderStepThree(); }); renderVariantSeg(); @@ -271,4 +314,5 @@ export function renderDevice(el, device) { renderReleaseNotes(); renderConfig(); renderLinks(); + renderStepThree(); } diff --git a/scripts/test_validate_registry.py b/scripts/test_validate_registry.py index 775f606..7d0a2ac 100644 --- a/scripts/test_validate_registry.py +++ b/scripts/test_validate_registry.py @@ -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() diff --git a/scripts/validate_registry.py b/scripts/validate_registry.py index 760d2ec..dfd5f3b 100644 --- a/scripts/validate_registry.py +++ b/scripts/validate_registry.py @@ -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: @@ -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(): diff --git a/tests/installer.spec.js b/tests/installer.spec.js index da77463..5e9f145 100644 --- a/tests/installer.spec.js +++ b/tests/installer.spec.js @@ -316,7 +316,8 @@ 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'); @@ -324,6 +325,48 @@ test('step 3 explains taking control in the ESPHome Dashboard', async ({ page }) 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