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 (
+ '' +
+ '" +
+ '
' + 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
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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading catalog...
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block content %}{% endblock %}