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 %}