diff --git a/devices.json b/devices.json
index a0f0cf8..fb8a782 100644
--- a/devices.json
+++ b/devices.json
@@ -239,6 +239,11 @@
"stable": {
"WLED 16.0.1 (Rev6 only)": "ApolloAutomation/WLED-M1"
}
+ },
+ "installers": {
+ "stable": {
+ "WLED 16.0.1 (Rev6 only)": null
+ }
}
}
]
diff --git a/js/views/device.js b/js/views/device.js
index 4a46de8..f976e76 100644
--- a/js/views/device.js
+++ b/js/views/device.js
@@ -30,6 +30,11 @@ function repoFor(device, channel, variant) {
return (device.repos && device.repos[channel] && device.repos[channel][variant]) || device.repo;
}
+function classicInstallerFor(device, channel, variant) {
+ const map = device.installers && device.installers[channel];
+ return map && variant in map ? map[variant] : device.githubPagesInstaller;
+}
+
function segHtml(id, label, keys, active, dataAttr) {
if (keys.length < 2) return '';
return `
@@ -56,11 +61,7 @@ export function renderDevice(el, device) {
@@ -100,6 +101,18 @@ export function renderDevice(el, device) {
const variantSlot = el.querySelector('#variant-slot');
const installSlot = el.querySelector('#install-slot');
+ const linksSlot = el.querySelector('#links-slot');
+
+ function renderLinks() {
+ const repo = repoFor(device, channel, variant);
+ const classic = classicInstallerFor(device, channel, variant);
+ const parts = [
+ `Setup guide`,
+ `GitHub`,
+ ];
+ if (classic) parts.push(`Classic installer`);
+ linksSlot.innerHTML = parts.join(' · ');
+ }
function renderVariantSeg() {
variantSlot.innerHTML = segHtml('variant-seg', 'Variant', Object.keys(device.firmware[channel]), variant, 'variant');
@@ -117,6 +130,7 @@ export function renderDevice(el, device) {
renderInstall();
renderConfig();
renderReleaseNotes();
+ renderLinks();
});
}
@@ -124,6 +138,7 @@ export function renderDevice(el, device) {
const myEpoch = epoch;
const manifest = selectedManifest(device, channel, variant);
const repo = repoFor(device, channel, variant);
+ const classic = classicInstallerFor(device, channel, variant);
if (hasSerial) {
installSlot.innerHTML = `
@@ -140,7 +155,7 @@ export function renderDevice(el, device) {
`;
// Pin the list element before the fetch: if the user navigates to another
@@ -241,10 +256,12 @@ export function renderDevice(el, device) {
renderInstall();
renderReleaseNotes();
renderConfig();
+ renderLinks();
});
renderVariantSeg();
renderInstall();
renderReleaseNotes();
renderConfig();
+ renderLinks();
}
diff --git a/scripts/test_validate_registry.py b/scripts/test_validate_registry.py
index 7354705..775f606 100644
--- a/scripts/test_validate_registry.py
+++ b/scripts/test_validate_registry.py
@@ -120,5 +120,36 @@ def test_variant_not_in_firmware_errors(self):
self.assertTrue(any("no such firmware variant" in e for e in errs), errs)
+class InstallersShapeChecks(unittest.TestCase):
+ FW = {"stable": {"v16": "https://x/m.json", "v14": "https://y/m.json"}}
+
+ def test_absent_ok(self):
+ self.assertEqual(vr.check_installers_shape(None, self.FW, "dev"), [])
+
+ def test_null_hide_ok(self):
+ self.assertEqual(vr.check_installers_shape({"stable": {"v16": None}}, self.FW, "dev"), [])
+
+ def test_url_override_ok(self):
+ ins = {"stable": {"v16": "https://apolloautomation.github.io/WLED-M1/"}}
+ self.assertEqual(vr.check_installers_shape(ins, self.FW, "dev"), [])
+
+ def test_not_dict_errors(self):
+ errs = vr.check_installers_shape([], self.FW, "dev")
+ self.assertTrue(any("installers" in e for e in errs), errs)
+
+ def test_channel_not_dict_errors(self):
+ errs = vr.check_installers_shape({"stable": "x"}, self.FW, "dev")
+ self.assertTrue(any("stable" in e for e in errs), errs)
+
+ def test_non_https_non_null_errors(self):
+ for bad in ("http://x", "ftp://x", "not-a-url", 5):
+ errs = vr.check_installers_shape({"stable": {"v16": bad}}, self.FW, "dev")
+ self.assertTrue(any("https URL or null" in e for e in errs), (bad, errs))
+
+ def test_variant_not_in_firmware_errors(self):
+ errs = vr.check_installers_shape({"stable": {"ghost": None}}, 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 c462381..760d2ec 100644
--- a/scripts/validate_registry.py
+++ b/scripts/validate_registry.py
@@ -92,6 +92,31 @@ def check_repos_shape(repos, firmware, dev_id):
errs.append(f"{dev_id} repos {channel}/{variant}: no such firmware variant")
return errs
+def check_installers_shape(installers, firmware, dev_id):
+ """Validate the optional `installers` map (channel -> variant -> https URL or null).
+
+ Network-free. `installers` absent (None) is valid. A variant value of null
+ means "this variant has no classic installer, hide the link"; any other
+ value must be an https URL. Every variant key must exist in
+ `firmware[channel]`. Returns a list of error strings.
+ """
+ errs = []
+ if installers is None:
+ return errs
+ if not isinstance(installers, dict):
+ errs.append(f"{dev_id} installers: not an object")
+ return errs
+ for channel, variants in installers.items():
+ if not isinstance(variants, dict):
+ errs.append(f"{dev_id} installers {channel}: not an object")
+ continue
+ for variant, url in variants.items():
+ if url is not None and (not isinstance(url, str) or not url.startswith("https://")):
+ errs.append(f"{dev_id} installers {channel}/{variant}: not an https URL or null")
+ if variant not in firmware.get(channel, {}):
+ errs.append(f"{dev_id} installers {channel}/{variant}: no such firmware variant")
+ return errs
+
def check_manifest(dev_id, channel, variant, murl):
where = f"{dev_id} {channel}/{variant}"
try:
@@ -123,6 +148,7 @@ def main():
config = dev.get("config", {})
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"]))
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 21f991e..106e85e 100644
--- a/tests/installer.spec.js
+++ b/tests/installer.spec.js
@@ -206,6 +206,34 @@ test('release notes follow the selected variant repo (per-variant repos override
.toHaveAttribute('href', `https://github.com/${overrideRepo}/releases`);
});
+test('header GitHub link and classic-installer links follow the selected variant', async ({ page }) => {
+ const d = registry.devices.find((x) => x.installers && x.installers.stable
+ && Object.values(x.installers.stable).some((v) => v === null));
+ test.skip(!d, 'no device that hides a classic installer for a variant');
+ const hiddenVariant = Object.keys(d.installers.stable).find((k) => d.installers.stable[k] === null);
+ const defaultVariant = Object.keys(d.firmware.stable)[0];
+ test.skip(hiddenVariant === defaultVariant, 'hidden installer is on the default variant');
+ const overrideRepo = d.repos && d.repos.stable && d.repos.stable[hiddenVariant];
+
+ await page.route('https://api.github.com/**', (route) => route.fulfill({ status: 403 }));
+ await page.goto(`/#/${d.id}`);
+
+ // Default variant: header GitHub = device repo, Classic installer link present.
+ await expect(page.locator('.links a', { hasText: 'GitHub' }))
+ .toHaveAttribute('href', `https://github.com/${d.repo}`);
+ await expect(page.locator('.links a', { hasText: 'Classic installer' })).toHaveCount(1);
+
+ // Select the variant whose installer is null.
+ await page.locator(`#variant-seg button[data-variant="${hiddenVariant}"]`).click();
+
+ // Header GitHub link now points at the override repo (if set); Classic installer link is gone.
+ if (overrideRepo) {
+ await expect(page.locator('.links a', { hasText: 'GitHub' }))
+ .toHaveAttribute('href', `https://github.com/${overrideRepo}`);
+ }
+ await expect(page.locator('.links a', { hasText: 'Classic installer' })).toHaveCount(0);
+});
+
test('step 3 shows the Home Assistant hand-off', async ({ page }) => {
const d = registry.devices[0];
await page.goto(`/#/${d.id}`);