diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..ff3362d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,96 @@ +name: Bug report +description: Something in the web installer isn't working as expected +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for reporting a problem! Flashing issues in particular are much easier to + diagnose with concrete details - please fill in as much of this as you can. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: A clear description of the bug, including what you expected to happen instead. + placeholder: Tell us what you saw... + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Go to '...' + 2. Select version '...' + 3. Select variant/flash size '...' + 4. Click 'Install' and ... + 5. See error + validations: + required: true + - type: dropdown + id: chip + attributes: + label: Chip + options: + - ESP32 + - ESP32-S3 + - ESP32-C3 + - ESP32-S2 + - ESP8266 + - Other / not sure + validations: + required: false + - type: input + id: board-model + attributes: + label: Specific board (if known) + placeholder: e.g. LilyGO T7-S3, Wemos D1 Mini, generic N16R8 devkit, Adafruit MatrixPortal S3... + - type: dropdown + id: variant + attributes: + label: Variant selected + options: + - Plain + - Audioreactive + - Ethernet + - ESP8266 CPU Frequency Test + - ESP32 V4 + - DEBUG + - HUB75 Matrix + - Not sure + validations: + required: false + - type: input + id: flash-size + attributes: + label: Flash size (or HUB75 layout) selected + placeholder: e.g. 8MB, or "Adafruit MatrixPortal S3" + - type: input + id: wled-version + attributes: + label: WLED version you tried to install + placeholder: e.g. 16.0.1, nightly... + - type: input + id: browser + attributes: + label: Browser + OS + placeholder: e.g. Chrome 126 on Windows 11 + validations: + required: true + - type: textarea + id: console + attributes: + label: Browser console output (if any) + description: Open DevTools (F12) -> Console before/while reproducing, and paste any errors here. + render: shell + - type: textarea + id: serial-log + attributes: + label: Serial/boot log (if the device fails to boot or loops after flashing) + description: If you can capture it with a serial monitor, paste the boot log here. + render: shell + - type: textarea + id: additional + attributes: + label: Anything else? diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a01187d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: WLED firmware issues + url: https://github.com/wled/WLED/issues + about: Report bugs in WLED itself (not this web installer) here. + - name: WLED documentation + url: https://kno.wled.ge + about: Usage questions, setup guides, and general WLED knowledge base. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..80adc19 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,30 @@ +name: Feature request +description: Suggest an idea or improvement for the web installer +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for the suggestion! Please describe what you'd like to see and why. + - type: textarea + id: problem + attributes: + label: What problem would this solve? + description: Is this related to something you ran into, or a board/workflow that isn't well supported today? + placeholder: e.g. "I couldn't find a way to..." + - type: textarea + id: solution + attributes: + label: Describe the solution you'd like + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives you've considered + - type: textarea + id: additional + attributes: + label: Additional context + description: Screenshots, links, related boards/chips/WLED variants, etc. diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml deleted file mode 100644 index 121b4bf..0000000 --- a/.github/workflows/nightly.yaml +++ /dev/null @@ -1,19 +0,0 @@ -name: Nightly Update - disabled -on: - workflow_dispatch: - # repository_dispatch: - # types: [release-nightly] -jobs: - download-bins: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - run: | - echo "Download files listed in https://api.github.com/repos/wled/WLED/releases/200568682/assets" - mkdir -p bin/nightly - cd bin/nightly - find ./ -name \*.bin\* -exec git rm -f {} \; - curl https://api.github.com/repos/wled/WLED/releases/200568682/assets | jq '.[].browser_download_url' | xargs wget - - uses: stefanzweifel/git-auto-commit-action@v5 - with: - file_pattern: '*.bin*' diff --git a/README.md b/README.md index f0520ef..eba6ad2 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,57 @@ -# WLED-WebFlasher -WLED web flasher using the Web Serial API, powered by ESPHome Web +# WLED Web Installer + +A static, GitHub-Pages-friendly web installer for [WLED](https://github.com/wled/WLED), built on +[ESP Web Tools](https://esphome.github.io/esp-web-tools/). + +No build step and no server component: `index.html` fetches the release list straight from the +GitHub Releases API at page-load time and generates an install manifest on the fly for whichever +version / variant / flash size combination the user picks. + +## Features + +- Always up to date - versions come live from the [wled/WLED releases](https://github.com/wled/WLED/releases) API, no manual manifest maintenance. +- Variant selection (Plain, Audioreactive, Ethernet, ESP8266 CPU frequency test, ESP32 V4, DEBUG, HUB75 Matrix) - unavailable variants for the selected version are hidden automatically. +- Flash size selection (1MB / 2MB / 4MB / 8MB / 16MB) for boards that ship multiple binaries (ESP32-S3, ESP8266) - unavailable sizes for the selected version/variant are hidden automatically. +- HUB75 layout selection (board/pinout picker) when the HUB75 Matrix variant is selected. +- Multi-language UI, loaded dynamically from `lang/` - see [Localization](#localization). + +## Local development + +Any static file server works, e.g.: + +```bash +npx serve . +``` + +Then open the printed local URL in Chrome or Edge (Web Serial is required). + +## Deploying + +Push to a repository and enable GitHub Pages for it (Settings -> Pages -> Deploy from branch). +No build step is required - the repository root is the site. + +## Localization + +`lang/languages.json` maps each supported language code to its display name, and +`lang/.json` holds that language's strings. Both are fetched at runtime - `i18n.js` never +hardcodes a language list. + +To add a language: + +1. Copy `lang/en.json` to `lang/.json` (e.g. `lang/de.json`) and translate the values. +2. Add one line to `lang/languages.json`: `"": ""`. + +That's it - the language selector and every `data-i18n` element on the page pick it up on next +load, no code changes required. Missing keys in a translation silently fall back to English. + +## Files + +- `index.html`, `style.css` - page markup/styling. +- `app.js` - release fetching, manifest generation, UI wiring. +- `i18n.js` - loads `lang/` and applies translations. +- `lang/` - one JSON file per language, plus `languages.json` listing them. +- `bin/boot/` - chip bootloaders and partition tables needed to flash bare chips (WLED release assets only contain the application binary). + +## License + +MIT, see `LICENSE`. diff --git a/app.js b/app.js new file mode 100644 index 0000000..5fb702d --- /dev/null +++ b/app.js @@ -0,0 +1,777 @@ +// app.js - WLED Web Installer +// +// Fetches available WLED releases from the GitHub Releases API, builds +// esp-web-tools manifests on the fly (as blob URLs) for every combination of +// version x variant x flash size, and wires up the page's controls. +// +// No build step, no server component - everything below runs directly in +// the browser so the page can be hosted as-is on GitHub Pages. + +(function () { + 'use strict'; + + // --------------------------------------------------------------------- + // Configuration + // --------------------------------------------------------------------- + + const GITHUB_RELEASES_URL = 'https://api.github.com/repos/wled/WLED/releases'; + const CORS_PROXY = 'https://proxy.corsfix.com/?'; + const CACHE_KEY = 'wled_webinstaller_releases_cache'; + const CACHE_TTL = 5 * 60 * 1000; // 5 minutes + const MAX_STABLE_RELEASES = 8; + const MAX_BETA_RELEASES = 2; + + // Base URL for locally-hosted bootloader / partition-table files. These + // are chip-specific (and, for the ESP32-S3, flash-size specific) and are + // shared across all WLED versions. + const bootBase = new URL('bin/boot/', window.location.href).href; + + // --------------------------------------------------------------------- + // Chip boot configuration + // --------------------------------------------------------------------- + // Describes the boot-stage parts (bootloader / partition table) that must + // be flashed before the WLED firmware itself. The firmware part is always + // appended last, at firmwareOffset. + // + // The ESP32-S3 partition table depends on the amount of flash on the + // board, so its bootParts is a function of the selected flash size. + + const CHIP_CONFIG = { + 'ESP32': { + chipFamily: 'ESP32', + fixedFlashSize: '4MB', + bootParts: () => [ + { path: bootBase + 'esp32_bootloader_v4.bin', offset: 0 } + ], + firmwareOffset: 65536 + }, + 'ESP32-C3': { + chipFamily: 'ESP32-C3', + fixedFlashSize: '4MB', + bootParts: () => [ + { path: bootBase + 'esp32-c3_bootloader_v2.bin', offset: 0 } + ], + firmwareOffset: 65536 + }, + 'ESP32-S2': { + chipFamily: 'ESP32-S2', + fixedFlashSize: '4MB', + bootParts: () => [ + { path: bootBase + 'bootloader_s2.bin', offset: 4096 }, + { path: bootBase + 'partitions_s2_4m.bin', offset: 32768 } + ], + firmwareOffset: 65536 + }, + 'ESP32-S3': { + chipFamily: 'ESP32-S3', + defaultFlashSize: '8MB', + // esp-web-tools flashes bootloader images as-is (it never patches the + // flash-size field the way `esptool.py write_flash` does), so we need + // one pre-patched+re-signed bootloader per flash size rather than + // reusing a single 8MB-labeled one for every board. + bootParts: (flashSizeId) => { + const files = { + '4MB': { bootloader: 'bootloader_s3_4m.bin', partitions: 'partitions_s3_4m.bin' }, + '8MB': { bootloader: 'bootloader_s3.bin', partitions: 'partitions_s3_8m.bin' }, + '16MB': { bootloader: 'bootloader_s3_16m.bin', partitions: 'partitions_s3_16m.bin' } + }; + const f = files[flashSizeId] || files['8MB']; + return [ + { path: bootBase + f.bootloader, offset: 0 }, + { path: bootBase + f.partitions, offset: 32768 } + ]; + }, + firmwareOffset: 65536 + }, + 'ESP8266': { + chipFamily: 'ESP8266', + defaultFlashSize: '4MB', + bootParts: () => [], + firmwareOffset: 0 + } + }; + + // Flash sizes shown in the UI, in display order. Not every chip supports + // every size - see VARIANTS below. + const FLASH_SIZES = [ + { id: '1MB', label: '1MB' }, + { id: '2MB', label: '2MB' }, + { id: '4MB', label: '4MB' }, + { id: '8MB', label: '8MB' }, + { id: '16MB', label: '16MB' } + ]; + const DEFAULT_FLASH_SIZE = '4MB'; + + // --------------------------------------------------------------------- + // HUB75 layouts + // --------------------------------------------------------------------- + // Each HUB75 build targets one specific board/pinout combination rather + // than a chip family with a flash-size choice, so it doesn't fit the + // VARIANTS model below - a layout fully determines chip, flash size and + // boot parts all at once. flashSizeLabel is display-only. + + const HUB75_LAYOUTS = [ + { + id: 'esp32_default', + label: 'ESP32 (default pinout)', + chip: 'ESP32', + suffix: '_ESP32_HUB75.bin', + flashSizeLabel: '4MB', + bootParts: () => CHIP_CONFIG['ESP32'].bootParts(), + firmwareOffset: CHIP_CONFIG['ESP32'].firmwareOffset + }, + { + id: 'esp32_forum', + label: 'ESP32 (forum pinout)', + chip: 'ESP32', + suffix: '_ESP32_HUB75_forum_pinout.bin', + flashSizeLabel: '4MB', + bootParts: () => CHIP_CONFIG['ESP32'].bootParts(), + firmwareOffset: CHIP_CONFIG['ESP32'].firmwareOffset + }, + { + id: 'hdwf2', + label: 'Huidu HD-WF2', + chip: 'ESP32-S3', + suffix: '_ESP32-S3_HD-WF2.bin', + // Physically a 4MB board; WLED's own build config for it (uncommonly) + // inherits an 8MB-declared bootloader paired with a scaled-down 4MB + // partition table, so we match that exact pairing here rather than + // "correct" it - it's what upstream actually ships and tests. + flashSizeLabel: '4MB', + bootParts: () => [ + { path: bootBase + 'bootloader_s3.bin', offset: 0 }, + { path: bootBase + 'partitions_s3_hdwf2.bin', offset: 32768 } + ], + firmwareOffset: 65536 + }, + { + id: 'matrixportal', + label: 'Adafruit MatrixPortal S3', + chip: 'ESP32-S3', + suffix: '_ESP32-S3_Adafruit_Matrixportal.bin', + flashSizeLabel: '8MB', + bootParts: () => CHIP_CONFIG['ESP32-S3'].bootParts('8MB'), + firmwareOffset: 65536 + }, + { + id: 'moonhub', + label: 'MOONHUB / LilyGO T7-S3', + chip: 'ESP32-S3', + suffix: '_ESP32-S3_16MB_opi_HUB75.bin', + flashSizeLabel: '16MB', + bootParts: () => CHIP_CONFIG['ESP32-S3'].bootParts('16MB'), + firmwareOffset: 65536 + }, + { + id: 'waveshare', + label: 'Waveshare ESP32-S3-RGB-Matrix', + chip: 'ESP32-S3', + suffix: '_ESP32-S3_Waveshare_HUB75.bin', + flashSizeLabel: '32MB', + bootParts: () => [ + { path: bootBase + 'bootloader_s3_32m.bin', offset: 0 }, + { path: bootBase + 'partitions_s3_32m.bin', offset: 32768 } + ], + firmwareOffset: 65536 + } + ]; + const DEFAULT_HUB75_LAYOUT = HUB75_LAYOUTS[0].id; + + // --------------------------------------------------------------------- + // Variant definitions + // --------------------------------------------------------------------- + // Each variant maps chip families to the asset-name suffix used in GitHub + // release assets. A chip entry is either: + // - a plain string: one asset, flash size is irrelevant + // - a { flashSizeId: suffix } map: the chip ships multiple binaries + // for different flash sizes + // + // Chips that have no entry for a given variant are simply left out of the + // generated manifest for that variant (e.g. "audio" is ESP32-only). + + const VARIANTS = { + normal: { + 'ESP32': '_ESP32.bin', + 'ESP32-C3': '_ESP32-C3.bin', + 'ESP32-S2': '_ESP32-S2.bin', + 'ESP32-S3': { '4MB': '_ESP32-S3_4M_qspi.bin', '8MB': '_ESP32-S3_8MB_opi.bin', '16MB': '_ESP32-S3_16MB_opi.bin' }, + 'ESP8266': { '1MB': '_ESP01.bin', '2MB': '_ESP02.bin', '4MB': '_ESP8266.bin' } + }, + ethernet: { + 'ESP32': '_ESP32_Ethernet.bin' + }, + audio: { + 'ESP32': '_ESP32_audioreactive.bin' + }, + test: { + 'ESP8266': { '1MB': '_ESP01_160.bin', '2MB': '_ESP02_160.bin', '4MB': '_ESP8266_160.bin' } + }, + v4: { + 'ESP32': '_ESP32_V4.bin' + }, + debug: { + 'ESP32': '_ESP32_DEBUG.bin' + } + }; + + // Maps variant names to the DOM ids used for their radio inputs. "hub75" + // is handled outside the VARIANTS table (see HUB75_LAYOUTS above) but its + // radio button follows the same enable/disable convention as the rest. + const VARIANT_IDS = ['normal', 'ethernet', 'audio', 'test', 'v4', 'debug', 'hub75']; + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + /** Return true when a chip entry has a flash-size selection axis. */ + function hasFlashAxis(entry) { + return entry !== null && typeof entry === 'object'; + } + + /** Resolve the asset suffix for a chip, given the globally selected flash size. */ + function resolveSuffix(entry, chip, flashSizeId) { + if (!hasFlashAxis(entry)) return entry; + return entry[flashSizeId] || entry[CHIP_CONFIG[chip].defaultFlashSize]; + } + + /** Find a release asset whose name ends with `suffix` (ignore .gz files). */ + function findAsset(assets, suffix) { + if (!suffix) return null; + return assets.find(function (a) { + return a.name.endsWith(suffix) && !a.name.endsWith('.gz'); + }) || null; + } + + /** Extract the WLED version string from asset filenames (for nightly). */ + function extractVersionFromAssets(assets) { + for (let i = 0; i < assets.length; i++) { + const m = assets[i].name.match(/^WLED_(.+?)_(ESP\d|ESP8)/); + if (m) return m[1]; + } + return 'unknown'; + } + + /** Build the human-friendly release label shown in the version dropdown. */ + function getDisplayVersion(release) { + if (release.tag_name === 'nightly') { + return extractVersionFromAssets(release.assets) + ' Nightly'; + } + return release.tag_name.replace(/^v/, ''); + } + + /** Build the version string stored in generated manifests. */ + function getManifestVersion(release, variantName) { + let ver = release.tag_name === 'nightly' + ? extractVersionFromAssets(release.assets) + : release.tag_name.replace(/^v/, ''); + if (variantName !== 'normal') ver += ' ' + variantName; + return ver; + } + + /** Classify a release into release/beta/nightly buckets. */ + function categorize(release) { + if (release.tag_name === 'nightly') return 'nightly'; + if (release.prerelease) return 'beta'; + return 'release'; + } + + // --------------------------------------------------------------------- + // Manifest generation + // --------------------------------------------------------------------- + + /** + * Build an esp-web-tools manifest object for the given release + variant + + * flash size. Returns null if no matching assets are found at all. + */ + function generateManifest(release, variantName, flashSizeId) { + const chipEntries = VARIANTS[variantName]; + const version = getManifestVersion(release, variantName); + const builds = []; + + for (const chip in chipEntries) { + const suffix = resolveSuffix(chipEntries[chip], chip, flashSizeId); + const asset = findAsset(release.assets, suffix); + if (!asset) continue; + + const config = CHIP_CONFIG[chip]; + const parts = config.bootParts(flashSizeId); + parts.push({ + path: CORS_PROXY + asset.browser_download_url, + offset: config.firmwareOffset + }); + + builds.push({ chipFamily: config.chipFamily, parts: parts }); + } + + if (builds.length === 0) return null; + + return { + name: 'WLED', + version: version, + home_assistant_domain: 'wled', + new_install_prompt_erase: true, + builds: builds + }; + } + + /** + * Build a manifest for a single HUB75 layout (one specific board). Unlike + * generateManifest(), this always produces at most one build entry, since + * a layout already fully determines the chip. + */ + function generateHub75Manifest(release, layoutId) { + const layout = HUB75_LAYOUTS.find(function (l) { return l.id === layoutId; }); + if (!layout) return null; + + const asset = findAsset(release.assets, layout.suffix); + if (!asset) return null; + + const parts = layout.bootParts(); + parts.push({ + path: CORS_PROXY + asset.browser_download_url, + offset: layout.firmwareOffset + }); + + return { + name: 'WLED', + version: getManifestVersion(release, 'hub75'), + home_assistant_domain: 'wled', + new_install_prompt_erase: true, + builds: [{ chipFamily: layout.chip, parts: parts }] + }; + } + + /** Convert a manifest object into a blob URL consumable by esp-web-tools. */ + function createManifestUrl(manifest) { + const blob = new Blob([JSON.stringify(manifest)], { type: 'application/json' }); + return URL.createObjectURL(blob); + } + + /** + * For a given release + variant, figure out which chips are actually + * available at each flash size (i.e. a matching release asset exists). + * Chips with a single fixed flash size (ESP32, ESP32-C3, ESP32-S2) are + * listed too, alongside the chips that actually offer a size choice + * (ESP32-S3, ESP8266) - the row itself is only shown when at least one + * chip in this variant has a real choice to make. + */ + function getFlashSizeAvailability(release, variantName) { + const chipEntries = VARIANTS[variantName]; + const axisChips = Object.keys(chipEntries).filter(function (chip) { + return hasFlashAxis(chipEntries[chip]); + }); + + const chipsBySize = {}; + FLASH_SIZES.forEach(function (fs) { chipsBySize[fs.id] = []; }); + + Object.keys(chipEntries).forEach(function (chip) { + const entry = chipEntries[chip]; + if (hasFlashAxis(entry)) { + Object.keys(entry).forEach(function (sizeId) { + if (findAsset(release.assets, entry[sizeId])) chipsBySize[sizeId].push(chip); + }); + } else { + const fixedSize = CHIP_CONFIG[chip] && CHIP_CONFIG[chip].fixedFlashSize; + if (fixedSize && findAsset(release.assets, entry)) chipsBySize[fixedSize].push(chip); + } + }); + + return { hasAxis: axisChips.length > 0, chipsBySize: chipsBySize }; + } + + // --------------------------------------------------------------------- + // Dropdown population + // --------------------------------------------------------------------- + + /** + * Create a single