From acca773faf0284cc7dc2f7b70f3cc07acef41b22 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 13 Apr 2026 11:31:58 +0300 Subject: [PATCH] Add Product Finder page with filterable hardware catalog MkDocs build hook fetches hardware data from artifactory and generates a flat product catalog at build time. The page shows all officially supported devices grouped by manufacturer with filters for type, frequency, power, antenna, PWM outputs, and display. Homepage updated with links to the Product Finder from the hardware ecosystem feature card and the partners section. --- .gitignore | 3 + docs/assets/javascripts/product-finder.js | 487 +++++++++++++++++++++ docs/assets/stylesheets/home.css | 30 ++ docs/assets/stylesheets/product-finder.css | 401 +++++++++++++++++ docs/product-finder.md | 8 + mkdocs.yml | 3 + overrides/home.html | 3 +- overrides/hooks/enrichment.json | 37 ++ overrides/hooks/product_catalog.py | 250 +++++++++++ overrides/product-finder.html | 115 +++++ 10 files changed, 1336 insertions(+), 1 deletion(-) create mode 100644 docs/assets/javascripts/product-finder.js create mode 100644 docs/assets/stylesheets/product-finder.css create mode 100644 docs/product-finder.md create mode 100644 overrides/hooks/enrichment.json create mode 100644 overrides/hooks/product_catalog.py create mode 100644 overrides/product-finder.html diff --git a/.gitignore b/.gitignore index 80a5e20b0..666cc1632 100644 --- a/.gitignore +++ b/.gitignore @@ -144,3 +144,6 @@ cython_debug/ Dockerfile.insiders .vscode/settings.json + +# Generated product catalog +docs/assets/data/products.json diff --git a/docs/assets/javascripts/product-finder.js b/docs/assets/javascripts/product-finder.js new file mode 100644 index 000000000..5884eea6b --- /dev/null +++ b/docs/assets/javascripts/product-finder.js @@ -0,0 +1,487 @@ +/** + * ExpressLRS Product Finder + * Vanilla JS — loads products.json, renders grouped results, filters via URL state. + */ + +(function () { + "use strict"; + + // ----------------------------------------------------------------------- + // State + // ----------------------------------------------------------------------- + + var catalog = []; + var filters = { + search: "", + classification: "", + band: "", + vendor: "", + screen: "", + diversity: "", + minPower: "", + minPwm: "", + }; + + var DEFAULTS = Object.assign({}, filters); + + // ----------------------------------------------------------------------- + // DOM refs + // ----------------------------------------------------------------------- + + var els = {}; + + function cacheDom() { + els.search = document.getElementById("pf-search"); + els.vendor = document.getElementById("pf-vendor"); + els.classification = document.getElementById("pf-classification"); + els.band = document.getElementById("pf-band"); + els.minPower = document.getElementById("pf-min-power"); + els.diversity = document.getElementById("pf-diversity"); + els.minPwm = document.getElementById("pf-min-pwm"); + els.screen = document.getElementById("pf-screen"); + els.reset = document.getElementById("pf-reset"); + els.summary = document.getElementById("pf-summary"); + els.activeFilters = document.getElementById("pf-active-filters"); + els.container = document.getElementById("pf-results-container"); + els.stats = document.getElementById("pf-stats"); + } + + // ----------------------------------------------------------------------- + // URL query state + // ----------------------------------------------------------------------- + + var PARAM_MAP = { + search: "search", + classification: "classification", + band: "band", + vendor: "vendor", + screen: "screen", + diversity: "diversity", + minPower: "min-power", + minPwm: "min-pwm", + }; + + function readQuery() { + var params = new URLSearchParams(window.location.search); + for (var key in PARAM_MAP) { + filters[key] = params.get(PARAM_MAP[key]) || ""; + } + } + + function writeQuery() { + var params = new URLSearchParams(); + for (var key in PARAM_MAP) { + if (filters[key]) params.set(PARAM_MAP[key], filters[key]); + } + var query = params.toString(); + var next = window.location.pathname + (query ? "?" + query : ""); + window.history.replaceState({}, "", next); + } + + // ----------------------------------------------------------------------- + // Label formatters + // ----------------------------------------------------------------------- + + function titleCase(value) { + return String(value) + .split(/[_-]/g) + .map(function (p) { return p.charAt(0).toUpperCase() + p.slice(1); }) + .join(" "); + } + + function bandLabel(value) { + return { "2400": "2.4GHz", "900": "900MHz", dual: "Dual-band" }[value] || value; + } + + function categoryLabel(value) { + return { + receiver: "Receiver", + transmitter_module: "Transmitter Module", + radio_handset: "Radio Handset", + }[value] || titleCase(value); + } + + function badgeLabel(value) { + return { receiver: "Receiver", transmitter_module: "Module", radio_handset: "Radio" }[value] || titleCase(value); + } + + function diversityLabel(value) { + return value === "gemini" ? "Gemini (True Diversity)" : titleCase(value); + } + + function screenLabel(value) { + return { none: "None", oled: "OLED", tft: "TFT" }[value] || titleCase(value); + } + + function powerDisplay(product) { + if (product.min_power_value != null && product.max_power_value != null) { + return product.min_power_value === product.max_power_value + ? product.max_power_value + " mW" + : product.min_power_value + "-" + product.max_power_value + " mW"; + } + if (product.max_output_power_mw != null) return product.max_output_power_mw + " mW"; + return null; + } + + function cardSpecs(product) { + var specs = []; + var power = powerDisplay(product); + if (power) specs.push(["Power", power]); + if (product.diversity_type) specs.push(["Diversity", diversityLabel(product.diversity_type)]); + + if (product.category === "tx") { + specs.unshift(["Band", bandLabel(product.radio_band)]); + if (product.screen_type && product.screen_type !== "none") { + specs.push(["Screen", screenLabel(product.screen_type)]); + } + return specs; + } + + // rx + specs.unshift(["Band", bandLabel(product.radio_band)]); + if (product.pwm_outputs != null) specs.push(["PWM", String(product.pwm_outputs)]); + return specs; + } + + // ----------------------------------------------------------------------- + // Filtering + // ----------------------------------------------------------------------- + + function matchesThreshold(actual, threshold) { + if (!threshold) return true; + if (actual == null) return false; + return Number(actual) >= Number(threshold); + } + + function matchesProduct(product) { + if (filters.search) { + var text = [product.vendor_name, product.product_name, product.notes] + .concat(product.tags || []) + .filter(Boolean) + .join(" ") + .toLowerCase(); + if (text.indexOf(filters.search.toLowerCase()) === -1) return false; + } + if (filters.classification && product.device_class !== filters.classification) return false; + if (filters.band && product.radio_band !== filters.band) return false; + if (filters.vendor && product.vendor !== filters.vendor) return false; + if (filters.screen) { + if (product.device_class !== "transmitter_module") return false; + if (filters.screen === "none") { + if (product.screen_type && product.screen_type !== "none") return false; + } else if (product.screen_type !== filters.screen) { + return false; + } + } + if (filters.diversity && product.diversity_type !== filters.diversity) return false; + if (!matchesThreshold(product.max_power_value || product.max_output_power_mw, filters.minPower)) return false; + if (!matchesThreshold(product.pwm_outputs, filters.minPwm)) return false; + return true; + } + + // ----------------------------------------------------------------------- + // Grouping + // ----------------------------------------------------------------------- + + function groupByVendor(products) { + var groups = {}; + products.forEach(function (p) { + var key = p.vendor_name || p.vendor; + if (!groups[key]) groups[key] = []; + groups[key].push(p); + }); + return Object.keys(groups) + .sort(function (a, b) { return a.localeCompare(b); }) + .map(function (name) { return { name: name, products: groups[name] }; }); + } + + // ----------------------------------------------------------------------- + // Rendering + // ----------------------------------------------------------------------- + + function escapeHtml(str) { + var div = document.createElement("div"); + div.textContent = str; + return div.innerHTML; + } + + function renderCard(product) { + var isRx = product.device_class === "receiver"; + var typeClass = isRx ? "pf-card--rx" : "pf-card--tx"; + var specs = cardSpecs(product); + var pillsHtml = specs + .map(function (s) { + return '
' + + escapeHtml(s[0]) + + '' + + escapeHtml(s[1]) + + "
"; + }) + .join(""); + + return ( + '
' + + '
' + + '

' + escapeHtml(product.vendor_name) + "

" + + '

' + escapeHtml(product.product_name) + "

" + + '
' + escapeHtml(badgeLabel(product.device_class)) + "
" + + '
' + pillsHtml + "
" + + "
" + ); + } + + function renderResults() { + var filtered = catalog.filter(matchesProduct); + var groups = groupByVendor(filtered); + + // Summary + var hasFilters = Object.keys(filters).some(function (k) { return filters[k] !== ""; }); + if (hasFilters) { + els.summary.textContent = + filtered.length + " of " + catalog.length + " products match the current filters."; + els.summary.style.display = ""; + } else { + els.summary.textContent = ""; + els.summary.style.display = "none"; + } + + // Active filter chips + renderActiveFilters(); + + // Smart disable + var disablePwm = filters.classification === "transmitter_module" || filters.classification === "radio_handset"; + var disableScreen = filters.classification === "receiver" || filters.classification === "radio_handset"; + els.minPwm.disabled = disablePwm; + els.screen.disabled = disableScreen; + if (disablePwm && filters.minPwm) { filters.minPwm = ""; els.minPwm.value = ""; } + if (disableScreen && filters.screen) { filters.screen = ""; els.screen.value = ""; } + + // Cards + if (groups.length === 0) { + els.container.innerHTML = + '
' + + (hasFilters ? "No products matched the current filter set." : "No products available.") + + "
"; + return; + } + + var html = groups + .map(function (group) { + var cards = group.products.map(renderCard).join(""); + return ( + '
' + + '

' + + escapeHtml(group.name) + + ' ' + group.products.length + "" + + "

" + + '
' + cards + "
" + + "
" + ); + }) + .join(""); + + els.container.innerHTML = html; + } + + function renderActiveFilters() { + var labelMap = { + search: "Search", + classification: "Product Type", + band: "Frequency", + vendor: "Manufacturer", + screen: "Display", + diversity: "Antenna", + minPower: "Min TX Power", + minPwm: "Min PWM Outputs", + }; + + function displayValue(key) { + switch (key) { + case "classification": return categoryLabel(filters[key]); + case "band": return bandLabel(filters[key]); + case "vendor": + var match = catalog.find(function (p) { return p.vendor === filters[key]; }); + return match ? match.vendor_name : titleCase(filters[key]); + case "screen": return screenLabel(filters[key]); + case "diversity": return diversityLabel(filters[key]); + case "minPower": return filters[key] + " mW"; + default: return filters[key]; + } + } + + var chips = []; + for (var key in labelMap) { + if (filters[key]) { + chips.push( + '' + + escapeHtml(labelMap[key]) + ": " + escapeHtml(displayValue(key)) + + ' ' + + "" + ); + } + } + els.activeFilters.innerHTML = chips.join(""); + } + + // ----------------------------------------------------------------------- + // Dynamic filter options + // ----------------------------------------------------------------------- + + function populateSelect(selectEl, values, formatter) { + var first = selectEl.querySelector("option"); + var current = selectEl.value; + selectEl.innerHTML = ""; + selectEl.appendChild(first); + values.forEach(function (v) { + var opt = document.createElement("option"); + opt.value = v; + opt.textContent = formatter ? formatter(v) : v; + selectEl.appendChild(opt); + }); + selectEl.value = current; + } + + function populateDynamicFilters() { + // Vendor + var vendors = []; + var vendorNames = {}; + catalog.forEach(function (p) { + if (p.vendor && !vendorNames[p.vendor]) { + vendorNames[p.vendor] = p.vendor_name || p.vendor; + vendors.push(p.vendor); + } + }); + vendors.sort(function (a, b) { return vendorNames[a].localeCompare(vendorNames[b]); }); + populateSelect(els.vendor, vendors, function (v) { return vendorNames[v]; }); + + // Min power + var powers = []; + var seen = {}; + catalog.forEach(function (p) { + var v = p.max_power_value || p.max_output_power_mw; + if (v && !seen[v]) { seen[v] = true; powers.push(v); } + }); + powers.sort(function (a, b) { return a - b; }); + populateSelect(els.minPower, powers, function (v) { return v + " mW"; }); + + // Min PWM + var pwms = []; + var seenPwm = {}; + catalog.forEach(function (p) { + if (p.pwm_outputs && !seenPwm[p.pwm_outputs]) { + seenPwm[p.pwm_outputs] = true; + pwms.push(p.pwm_outputs); + } + }); + pwms.sort(function (a, b) { return a - b; }); + populateSelect(els.minPwm, pwms, String); + } + + // ----------------------------------------------------------------------- + // Event wiring + // ----------------------------------------------------------------------- + + function syncFiltersFromDom() { + filters.search = els.search.value; + filters.vendor = els.vendor.value; + filters.classification = els.classification.value; + filters.band = els.band.value; + filters.minPower = els.minPower.value; + filters.diversity = els.diversity.value; + filters.minPwm = els.minPwm.value; + filters.screen = els.screen.value; + } + + function syncDomFromFilters() { + els.search.value = filters.search; + els.vendor.value = filters.vendor; + els.classification.value = filters.classification; + els.band.value = filters.band; + els.minPower.value = filters.minPower; + els.diversity.value = filters.diversity; + els.minPwm.value = filters.minPwm; + els.screen.value = filters.screen; + } + + function onFilterChange() { + syncFiltersFromDom(); + writeQuery(); + renderResults(); + } + + var searchTimer = null; + function onSearchInput() { + clearTimeout(searchTimer); + searchTimer = setTimeout(onFilterChange, 200); + } + + function onReset() { + Object.assign(filters, DEFAULTS); + syncDomFromFilters(); + writeQuery(); + renderResults(); + } + + function onChipClose(e) { + var btn = e.target.closest("[data-filter]"); + if (!btn) return; + var key = btn.getAttribute("data-filter"); + filters[key] = ""; + syncDomFromFilters(); + writeQuery(); + renderResults(); + } + + function bindEvents() { + els.search.addEventListener("input", onSearchInput); + els.vendor.addEventListener("change", onFilterChange); + els.classification.addEventListener("change", onFilterChange); + els.band.addEventListener("change", onFilterChange); + els.minPower.addEventListener("change", onFilterChange); + els.diversity.addEventListener("change", onFilterChange); + els.minPwm.addEventListener("change", onFilterChange); + els.screen.addEventListener("change", onFilterChange); + els.reset.addEventListener("click", onReset); + els.activeFilters.addEventListener("click", onChipClose); + } + + // ----------------------------------------------------------------------- + // Init + // ----------------------------------------------------------------------- + + function init() { + cacheDom(); + if (!els.container) return; // not on product finder page + + readQuery(); + bindEvents(); + + fetch(document.querySelector('script[src*="product-finder"]').src.replace("javascripts/product-finder.js", "data/products.json")) + .then(function (res) { + if (!res.ok) throw new Error("Failed to load catalog"); + return res.json(); + }) + .then(function (items) { + catalog = items; + + // Stats + var vendorSet = {}; + catalog.forEach(function (p) { vendorSet[p.vendor] = true; }); + els.stats.textContent = catalog.length + " products from " + Object.keys(vendorSet).length + " manufacturers."; + + populateDynamicFilters(); + syncDomFromFilters(); + renderResults(); + }) + .catch(function () { + els.summary.textContent = "Unable to load product catalog."; + els.container.innerHTML = '
Catalog failed to load. Try refreshing the page.
'; + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/docs/assets/stylesheets/home.css b/docs/assets/stylesheets/home.css index d05374f12..bff82f774 100644 --- a/docs/assets/stylesheets/home.css +++ b/docs/assets/stylesheets/home.css @@ -115,6 +115,16 @@ font-size: 0.8rem; } +.mdx-features__item a.mdx-features__link { + color: var(--md-accent-fg-color) !important; + font-weight: 600; + text-decoration: none; +} + +.mdx-features__item a.mdx-features__link:hover { + text-decoration: underline; +} + /* ========================================================================== Section 2: Trusted by Leading Brands — Partner Logo Grid ========================================================================== */ @@ -197,6 +207,26 @@ font-weight: 600; font-size: 0.85rem; text-decoration: none; + margin: 0 0.75rem; +} + +.mdx-partners__btn { + display: inline-block !important; + padding: 0.5rem 1.25rem !important; + background: transparent !important; + color: var(--md-accent-fg-color) !important; + border: 1px solid var(--md-accent-fg-color); + border-radius: 0.25rem; + font-weight: 600 !important; + font-size: 0.85rem !important; + text-decoration: none !important; + transition: background-color 0.2s, color 0.2s; +} + +.mdx-partners__btn:hover { + background-color: var(--md-accent-fg-color) !important; + color: #1e1e1e !important; + text-decoration: none !important; } .mdx-partners__cta a:hover { diff --git a/docs/assets/stylesheets/product-finder.css b/docs/assets/stylesheets/product-finder.css new file mode 100644 index 000000000..b513b0945 --- /dev/null +++ b/docs/assets/stylesheets/product-finder.css @@ -0,0 +1,401 @@ +/* + * ExpressLRS Product Finder + * All classes prefixed with .pf- to avoid collisions + */ + +/* ========================================================================== + Page Layout + ========================================================================== */ + +.pf-main { + background-color: var(--md-default-bg-color); + padding: 1.5rem 0 4rem; +} + +.pf-main > .md-grid { + padding-left: 0.8rem; + padding-right: 0.8rem; +} + +/* ========================================================================== + Page Heading + ========================================================================== */ + +.pf-heading { + margin-bottom: 1.25rem; +} + +.pf-heading h1 { + margin-bottom: 0.25rem; +} + +.pf-heading p { + margin: 0; + color: var(--md-default-fg-color--light); +} + +#pf-stats { + font-weight: 600; + color: var(--md-default-fg-color); +} + +/* ========================================================================== + Two-Column Layout + ========================================================================== */ + +.pf-layout { + display: grid; + grid-template-columns: 13.5rem 1fr; + gap: 1.5rem; + align-items: start; +} + +@media screen and (max-width: 59.9375em) { + .pf-layout { + grid-template-columns: 1fr; + } +} + +/* ========================================================================== + Filters Sidebar + ========================================================================== */ + +.pf-filters { + padding: 1rem; + border: 1px solid var(--md-default-fg-color--lightest); + border-radius: 0.5rem; + position: sticky; + top: 1.5rem; +} + +.pf-filters__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.pf-filters__header h2 { + margin: 0; +} + +.pf-filters__grid { + display: grid; + gap: 0.6rem; +} + +/* Field styling */ + +.pf-field { + display: grid; + gap: 0.15rem; +} + +.pf-field label { + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--md-default-fg-color--light); +} + +.pf-field input, +.pf-field select { + width: 100%; + padding: 0.38rem 0.5rem; + font-size: 0.78rem; + font-family: inherit; + color: var(--md-default-fg-color); + background: var(--md-default-bg-color); + border: 1px solid var(--md-default-fg-color--lightest); + border-radius: 0.3rem; + outline: none; + transition: border-color 0.15s; + box-sizing: border-box; +} + +.pf-field input:focus, +.pf-field select:focus { + border-color: var(--md-primary-fg-color); +} + +.pf-field select:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +/* Reset button */ + +.pf-btn-reset { + padding: 0.3rem 0.7rem; + font-size: 0.72rem; + font-weight: 600; + font-family: inherit; + color: var(--md-primary-fg-color); + background: transparent; + border: 1px solid var(--md-primary-fg-color); + border-radius: 0.3rem; + cursor: pointer; + transition: background-color 0.15s, color 0.15s; + white-space: nowrap; +} + +.pf-btn-reset:hover { + background: var(--md-primary-fg-color); + color: #fff; +} + +/* ========================================================================== + Active Filter Chips + ========================================================================== */ + +.pf-active-filters { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-bottom: 0.75rem; +} + +.pf-active-filters:empty { + display: none; + margin: 0; +} + +.pf-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.2rem 0.55rem; + font-size: 0.7rem; + font-weight: 600; + border-radius: 1rem; + background: rgba(73, 107, 184, 0.1); + color: var(--md-primary-fg-color); + white-space: nowrap; +} + +.pf-chip__close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 0.9rem; + height: 0.9rem; + padding: 0; + margin: 0; + font-size: 0.85rem; + line-height: 1; + border: none; + background: none; + color: inherit; + cursor: pointer; + border-radius: 50%; + opacity: 0.6; +} + +.pf-chip__close:hover { + opacity: 1; +} + +/* ========================================================================== + Summary + ========================================================================== */ + +.pf-summary { + font-size: 0.8rem; + color: var(--md-default-fg-color--light); + margin: 0 0 0.75rem; +} + +.pf-summary:empty { + display: none; +} + +/* ========================================================================== + Results — Vendor Groups + ========================================================================== */ + +.pf-vendor-group { + margin-bottom: 1.75rem; +} + +.pf-vendor-heading { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 0 0.6rem !important; + padding-bottom: 0.4rem; + border-bottom: 1px solid var(--md-default-fg-color--lightest); +} + +.pf-vendor-count { + font-size: 0.68rem; + font-weight: 600; + padding: 0.1rem 0.45rem; + border-radius: 1rem; + background: var(--md-default-fg-color--lightest); + color: var(--md-default-fg-color--light); +} + +/* ========================================================================== + Product Cards + ========================================================================== */ + +.pf-card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 0.65rem; +} + +.pf-card { + display: grid; + gap: 0.6rem; + padding: 0.8rem 0.9rem; + border: 1px solid var(--md-default-fg-color--lightest); + border-radius: 0.5rem; + transition: transform 0.15s, box-shadow 0.15s; +} + + +.pf-card--rx { + border-left: 3px solid rgba(73, 107, 184, 0.5); +} + +.pf-card--tx { + border-left: 3px solid rgba(159, 199, 111, 0.6); +} + +.pf-card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.5rem; +} + +.pf-card__vendor { + margin: 0; + font-size: 0.68rem; + color: var(--md-default-fg-color--light); +} + +.pf-card__name { + margin: 0.1rem 0 0 !important; + font-size: 0.85rem !important; + font-weight: 600; + line-height: 1.25; +} + +.pf-card__badge { + flex-shrink: 0; + padding: 0.15rem 0.45rem; + font-size: 0.62rem; + font-weight: 600; + letter-spacing: 0.02em; + border-radius: 1rem; + white-space: nowrap; +} + +.pf-card--rx .pf-card__badge { + background: rgba(73, 107, 184, 0.12); + color: var(--md-primary-fg-color); +} + +.pf-card--tx .pf-card__badge { + background: rgba(159, 199, 111, 0.15); + color: #4c7c23; +} + +/* Spec pills */ + +.pf-card__specs { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.pf-pill { + display: inline-flex; + gap: 0.3rem; + align-items: baseline; + padding: 0.2rem 0.45rem; + border-radius: 0.3rem; + border: 1px solid var(--md-default-fg-color--lightest); + font-size: 0.65rem; + white-space: nowrap; +} + +.pf-pill__label { + color: var(--md-default-fg-color--light); +} + +.pf-pill__value { + font-weight: 700; + color: var(--md-default-fg-color); +} + +/* Empty state */ + +.pf-empty { + padding: 2rem; + border: 1px dashed var(--md-default-fg-color--lightest); + border-radius: 0.5rem; + text-align: center; + color: var(--md-default-fg-color--light); + font-size: 0.85rem; +} + +/* ========================================================================== + Light Theme Overrides + ========================================================================== */ + +[data-md-color-scheme="default"] .pf-filters { + background: #fff; +} + +[data-md-color-scheme="default"] .pf-card { + background: #fff; +} + +[data-md-color-scheme="default"] .pf-main { + background-color: #f8f8f6; +} + +/* ========================================================================== + Dark (Slate) Theme Overrides + ========================================================================== */ + +[data-md-color-scheme="slate"] .pf-card--rx .pf-card__badge { + background: rgba(95, 139, 243, 0.15); + color: #8ab4ff; +} + +[data-md-color-scheme="slate"] .pf-card--tx .pf-card__badge { + background: rgba(166, 207, 116, 0.15); + color: #b5db7e; +} + +[data-md-color-scheme="slate"] .pf-chip { + background: rgba(95, 139, 243, 0.12); + color: #8ab4ff; +} + +[data-md-color-scheme="slate"] .pf-card--rx { + border-left-color: rgba(95, 139, 243, 0.5); +} + +[data-md-color-scheme="slate"] .pf-card--tx { + border-left-color: rgba(166, 207, 116, 0.5); +} + +/* ========================================================================== + Responsive + ========================================================================== */ + +@media screen and (max-width: 59.9375em) { + .pf-filters { + position: static; + } + .pf-card-grid { + grid-template-columns: 1fr; + } +} diff --git a/docs/product-finder.md b/docs/product-finder.md new file mode 100644 index 000000000..c7371e858 --- /dev/null +++ b/docs/product-finder.md @@ -0,0 +1,8 @@ +--- +template: product-finder.html +title: Product Finder +description: Find ExpressLRS-compatible hardware by manufacturer, type, band, power, and more. +hide: + - navigation + - toc +--- diff --git a/mkdocs.yml b/mkdocs.yml index b82ff8fcc..b207ae9b8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -66,6 +66,7 @@ theme: hooks: - overrides/hooks/blog_posts.py - overrides/hooks/llms_txt.py + - overrides/hooks/product_catalog.py plugins: - blog: @@ -103,6 +104,7 @@ extra_css: - assets/stylesheets/main.css - assets/stylesheets/colors.css - assets/stylesheets/home.css + - assets/stylesheets/product-finder.css # Analytics extra: @@ -205,6 +207,7 @@ markdown_extensions: # Navigation nav: - Home: index.md + - Product Finder: product-finder.md - Quick Start: - Getting Started: quick-start/getting-started.md - Installing the Configurator: quick-start/installing-configurator.md diff --git a/overrides/home.html b/overrides/home.html index c2fd8f8ff..a0d93485a 100644 --- a/overrides/home.html +++ b/overrides/home.html @@ -119,7 +119,7 @@

Built for Everything

{% include ".icons/material/puzzle.svg" %}

Vast Hardware Ecosystem

-

From 0.46g nano receivers to dual-band TX modules. Over 50 manufacturers and hundreds of devices. Choose the gear that fits your build.

+

From 0.46g nano receivers to dual-band TX modules. Over 50 manufacturers and hundreds of devices. Browse all officially supported devices.

@@ -175,6 +175,7 @@

You're in Good Company

...and many more

+ Browse All Officially Supported Devices → Become a Partner →
diff --git a/overrides/hooks/enrichment.json b/overrides/hooks/enrichment.json new file mode 100644 index 000000000..a38b53aa5 --- /dev/null +++ b/overrides/hooks/enrichment.json @@ -0,0 +1,37 @@ +{ + "defaults": { + "status": "active" + }, + "products": { + "axis:tx_2400:thor": { + "notes": "External 2.4GHz module with a built-in display profile." + }, + "betafpv:tx_900:micro900": { + "notes": "900MHz external micro module." + }, + "betafpv:tx_2400:nano2g4": { + "notes": "Compact external 2.4GHz nano module." + }, + "betafpv:tx_2400:nanov2": { + "notes": "Updated external 2.4GHz nano module." + }, + "betafpv:tx_2400:micro2g4": { + "notes": "External micro module with a small display." + }, + "betafpv:tx_2400:micro1w": { + "notes": "1W external micro module." + }, + "betafpv:rx_900:superp": { + "notes": "High-channel-count PWM receiver." + }, + "betafpv:rx_2400:pwmp": { + "notes": "PWM receiver for fixed-wing and surface use." + }, + "happymodel:rx_2400:epw6": { + "notes": "6-channel PWM receiver." + }, + "jumper:tx_2400:nano": { + "notes": "External nano-sized module." + } + } +} diff --git a/overrides/hooks/product_catalog.py b/overrides/hooks/product_catalog.py new file mode 100644 index 000000000..f4fae90cf --- /dev/null +++ b/overrides/hooks/product_catalog.py @@ -0,0 +1,250 @@ +"""Hook to generate the product catalog at build time. + +Downloads hardware artifacts from the ExpressLRS artifactory, processes +targets.json and layout files, and writes a flat products.json used by +the Product Finder page. + +Skips the build if products.json already exists. Delete the file to +force a refresh. +""" + +import json +import os +import re +import tempfile +import urllib.request +import zipfile + +HARDWARE_URL = "https://artifactory.expresslrs.org/ExpressLRS/hardware.zip" +POWER_LEVELS_MW = [10, 25, 50, 100, 250, 500, 1000, 2000] +ALLOWED_ENRICHMENT_FIELDS = {"notes", "product_url", "image_url", "form_factor"} +COMPACT_FIELDS = [ + "id", "vendor", "vendor_name", "product_name", "category", "device_class", + "radio_band", "firmware_target", "platform", "tx_type", "screen_type", + "min_power_value", "max_power_value", "max_output_power_mw", "pwm_outputs", + "diversity_type", "notes", "product_url", "image_url", "form_factor", +] + + +def on_pre_build(config, **kwargs): + docs_dir = config["docs_dir"] + output_path = os.path.join(docs_dir, "assets", "data", "products.json") + if os.path.exists(output_path): + return + + hook_dir = os.path.dirname(__file__) + enrichment_path = os.path.join(hook_dir, "enrichment.json") + + with tempfile.TemporaryDirectory() as tmp: + zip_path = os.path.join(tmp, "hardware.zip") + hardware_dir = os.path.join(tmp, "hardware") + + print("product_catalog: downloading hardware artifacts...") + req = urllib.request.Request(HARDWARE_URL, headers={"User-Agent": "ExpressLRS-Docs/1.0"}) + with urllib.request.urlopen(req) as resp, open(zip_path, "wb") as out: + out.write(resp.read()) + + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(hardware_dir) + + targets_path = os.path.join(hardware_dir, "targets.json") + if not os.path.exists(targets_path): + print("product_catalog: targets.json not found in archive, skipping") + return + + with open(targets_path, encoding="utf-8") as f: + targets = json.load(f) + + enrichment = _read_json(enrichment_path, {"defaults": {}, "products": {}}) + defaults = _sanitize_enrichment(enrichment.get("defaults", {})) + enriched_products = { + pid: _sanitize_enrichment(entry) + for pid, entry in enrichment.get("products", {}).items() + } + + products = [] + for vendor, vendor_entry in targets.items(): + if vendor in ("diy", "generic"): + continue + vendor_name = vendor_entry.get("name", vendor) + for radio_key, target_map in vendor_entry.items(): + if radio_key == "name": + continue + for target, config in target_map.items(): + product_name = config.get("product_name", "") + if re.match(r"^(generic|diy)\b", product_name, re.IGNORECASE): + continue + + pid = f"{vendor}:{radio_key}:{target}" + category = _category_from_radio_key(radio_key) + band = _radio_band(radio_key) + layout = _read_layout( + hardware_dir, config.get("layout_file"), category, + ) + merged_hw = {**layout, **(config.get("overlay") or {})} + power_range = _numeric_power_range(merged_hw) + tx_type = _detect_tx_type(category, merged_hw) + classification = _device_class(category, tx_type) + hw_screen = _screen_type_label(merged_hw.get("screen_type")) + inferred_screen = _infer_screen_type(config, product_name) + screen = _normalized_screen_type( + category, tx_type, hw_screen, inferred_screen, + ) + + record = { + "id": pid, + "vendor": vendor, + "vendor_name": vendor_name, + "product_name": product_name, + "category": category, + "device_class": classification, + "radio_band": band, + "firmware_target": config.get("firmware"), + "platform": config.get("platform"), + "tx_type": tx_type, + "screen_type": screen, + "min_power_value": power_range[0], + "max_power_value": power_range[1], + "max_output_power_mw": _output_power_from_name(product_name), + "pwm_outputs": _pwm_count(merged_hw, product_name), + "diversity_type": _infer_diversity(merged_hw), + "notes": "", + } + merged = {**record, **defaults, **enriched_products.get(pid, {})} + products.append(_compact(merged)) + + products.sort(key=lambda p: (p.get("vendor_name", ""), p.get("product_name", ""))) + + os.makedirs(os.path.dirname(output_path), exist_ok=True) + with open(output_path, "w", encoding="utf-8") as f: + json.dump(products, f, indent=2) + f.write("\n") + + print(f"product_catalog: wrote {len(products)} products to {output_path}") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _read_json(path, fallback=None): + if not os.path.exists(path): + return fallback if fallback is not None else {} + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def _sanitize_enrichment(entry): + return {k: v for k, v in (entry or {}).items() if k in ALLOWED_ENRICHMENT_FIELDS} + + +def _radio_band(radio_key): + if radio_key.endswith("_2400"): + return "2400" + if radio_key.endswith("_900"): + return "900" + if radio_key.endswith("_dual"): + return "dual" + return "unknown" + + +def _category_from_radio_key(radio_key): + return "tx" if radio_key.startswith("tx") else "rx" + + +def _detect_tx_type(category, hardware): + if category != "tx": + return None + serial_rx = hardware.get("serial_rx") + serial_tx = hardware.get("serial_tx") + if not isinstance(serial_rx, int) or not isinstance(serial_tx, int): + return None + return "external" if serial_rx == serial_tx else "internal" + + +def _device_class(category, tx_type): + if category == "rx": + return "receiver" + if tx_type == "internal": + return "radio_handset" + return "transmitter_module" + + +def _screen_type_label(value): + if value == 1: + return "oled" + if value == 4: + return "tft" + return None + + +def _infer_screen_type(config, product_name): + if re.search(r"oled", product_name, re.IGNORECASE): + return "oled" + logo = config.get("logo_file") or "" + if re.search(r"tft", product_name, re.IGNORECASE) or re.search(r"_tft\.", logo, re.IGNORECASE): + return "tft" + return None + + +def _normalized_screen_type(category, tx_type, hw_screen, inferred_screen): + detected = hw_screen or inferred_screen + if detected: + return detected + if category == "tx" and tx_type == "external": + return "none" + return None + + +def _numeric_power_range(hardware): + power_min = hardware.get("power_min") + power_max = hardware.get("power_max") + if not isinstance(power_min, int) and not isinstance(power_max, int): + return (None, None) + min_idx = power_min if isinstance(power_min, int) else 0 + max_idx = power_max if isinstance(power_max, int) else len(POWER_LEVELS_MW) - 1 + min_idx = max(0, min(min_idx, len(POWER_LEVELS_MW) - 1)) + max_idx = max(0, min(max_idx, len(POWER_LEVELS_MW) - 1)) + return (POWER_LEVELS_MW[min_idx], POWER_LEVELS_MW[max_idx]) + + +def _output_power_from_name(product_name): + watts = re.search(r"(\d+(?:\.\d+)?)\s*w", product_name, re.IGNORECASE) + if watts: + return round(float(watts.group(1)) * 1000) + milliwatts = re.search(r"(\d+)\s*mw", product_name, re.IGNORECASE) + if milliwatts: + return int(milliwatts.group(1)) + return None + + +def _pwm_count(hardware, product_name): + pwm = hardware.get("pwm_outputs") + if isinstance(pwm, list): + return len(pwm) + channels = re.search(r"(\d+)\s*ch", product_name, re.IGNORECASE) + if channels: + return int(channels.group(1)) + return None + + +def _infer_diversity(hardware): + if "radio_nss_2" in hardware: + return "gemini" + if "ant_ctrl" in hardware: + return "antenna" + return "single" + + +def _read_layout(hardware_dir, layout_file, category): + if not layout_file: + return {} + folder = "TX" if category == "tx" else "RX" + path = os.path.join(hardware_dir, folder, layout_file) + if not os.path.exists(path): + return {} + return _read_json(path, {}) + + +def _compact(record): + return {k: record[k] for k in COMPACT_FIELDS if record.get(k) is not None and record.get(k) != ""} diff --git a/overrides/product-finder.html b/overrides/product-finder.html new file mode 100644 index 000000000..df71e09fa --- /dev/null +++ b/overrides/product-finder.html @@ -0,0 +1,115 @@ +{% extends "main.html" %} + +{% block tabs %} {{ super() }} + + + +
+
+ + +
+

Product Finder

+

Every device listed here is officially supported - tested and approved by the ExpressLRS development team.

+
+ +
+ + +
+
+

Filters

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

Loading catalog...

+
+
+
+ +
+
+
+ + + +{% endblock %} + +{% block content %}{% endblock %}