diff --git a/scripts/generate-architecture-hierarchy.js b/scripts/generate-architecture-hierarchy.js new file mode 100644 index 0000000..f6078a7 --- /dev/null +++ b/scripts/generate-architecture-hierarchy.js @@ -0,0 +1,437 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const FIRMWARE_DIR = path.resolve(__dirname, '../vendor/firmware'); +const VARIANTS_DIR = path.join(FIRMWARE_DIR, 'variants'); +const OUTPUT_FILE = path.resolve(__dirname, '../src/constants/architecture-hierarchy.json'); + +/** + * Normalize architecture/target name (remove hyphens and underscores) + * This ensures consistent format matching PlatformIO architecture names + */ +function normalizeName(name) { + return name.replace(/[-_]/g, ''); +} + +/** + * Parse PlatformIO ini file to extract sections and their properties + */ +function parseIniFile(filePath) { + if (!fs.existsSync(filePath)) { + return null; + } + + const content = fs.readFileSync(filePath, 'utf-8'); + const lines = content.split('\n'); + + let currentSection = null; + const sections = {}; + + for (const line of lines) { + const trimmed = line.trim(); + + // Skip comments and empty lines + if (!trimmed || trimmed.startsWith(';')) { + continue; + } + + // Section header: [section_name] + const sectionMatch = trimmed.match(/^\[(.+)\]$/); + if (sectionMatch) { + currentSection = sectionMatch[1]; + sections[currentSection] = {}; + continue; + } + + // Key-value pairs + if (currentSection && trimmed.includes('=')) { + const [key, ...valueParts] = trimmed.split('='); + const value = valueParts.join('=').trim(); + sections[currentSection][key.trim()] = value; + } + } + + return sections; +} + +/** + * Find all ini files recursively + */ +function findAllIniFiles() { + const iniFiles = []; + + if (!fs.existsSync(VARIANTS_DIR)) { + console.error(`Variants directory not found: ${VARIANTS_DIR}`); + process.exit(1); + } + + function scanDirectory(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.name.startsWith('.')) { + continue; + } + + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + scanDirectory(fullPath); + } else if (entry.isFile() && entry.name.endsWith('.ini')) { + iniFiles.push(fullPath); + } + } + } + + scanDirectory(VARIANTS_DIR); + // Also include the main platformio.ini + const mainIni = path.join(FIRMWARE_DIR, 'platformio.ini'); + if (fs.existsSync(mainIni)) { + iniFiles.push(mainIni); + } + + return iniFiles; +} + +/** + * Extract architecture name from base section name + */ +function extractArchFromBaseSection(sectionName) { + const match = sectionName.match(/^([a-z0-9]+)_base$/); + if (!match) { + return null; + } + + const arch = match[1]; + + // Filter out non-architecture bases (library/feature bases) + const nonArchitectureBases = [ + 'arduino', + 'networking', + 'radiolib', + 'environmental', + 'device-ui', + 'native', // Ignore native architecture + ]; + + if (nonArchitectureBases.includes(arch)) { + return null; + } + + // Filter out variant bases that extend other bases (not root architectures) + // These are board-specific or variant-specific bases, not architecture bases + // We'll detect this by checking if they extend another _base (handled in buildParentMapping) + + // Filter out board-specific bases + const boardSpecificPatterns = [ + /heltec/i, + /crowpanel/i, + /mesh_tab/i, + /muzi/i, + ]; + + for (const pattern of boardSpecificPatterns) { + if (pattern.test(arch)) { + return null; + } + } + + // Architecture names typically don't have underscores (except for numbers) + if (arch.includes('_') && !arch.match(/^[a-z]+\d+$/)) { + return null; + } + + return arch; +} + +/** + * Extract parent from extends directive + * PlatformIO allows multiple extends separated by commas - take the first one + */ +function getParentFromExtends(extendsValue) { + if (!extendsValue) { + return null; + } + + // Handle comma-separated extends (take the first one) + const firstExtends = extendsValue.split(',')[0].trim(); + + // Remove env: prefix if present + const cleaned = firstExtends.replace(/^env:/, ''); + + // Check if it's an architecture base (e.g., "esp32_base") + const archMatch = cleaned.match(/^([a-z0-9]+)_base$/); + if (archMatch) { + const parent = archMatch[1]; + if (parent === 'arduino') { + return null; + } + return parent; + } + + // Otherwise return as-is (could be a variant base or another target) + return cleaned; +} + +/** + * Build flat parent mapping for all targets, variant bases, and architectures + */ +function buildParentMapping() { + const allIniFiles = findAllIniFiles(); + + // Track all parent relationships: child -> parent + const parentMap = {}; + + // Track all entities we've seen (targets, variant bases, architectures) + const allEntities = new Set(); + + // First pass: collect all [env:target] sections first (they take precedence) + const targetNames = new Set(); + for (const iniFile of allIniFiles) { + const sections = parseIniFile(iniFile); + if (!sections) { + continue; + } + + for (const [sectionName] of Object.entries(sections)) { + const envMatch = sectionName.match(/^env:(.+)$/); + if (envMatch) { + targetNames.add(envMatch[1]); + } + } + } + + // Second pass: collect all relationships + for (const iniFile of allIniFiles) { + const sections = parseIniFile(iniFile); + if (!sections) { + continue; + } + + for (const [sectionName, sectionData] of Object.entries(sections)) { + const extendsValue = sectionData.extends; + if (!extendsValue) { + continue; + } + + // Handle [env:target] sections + const envMatch = sectionName.match(/^env:(.+)$/); + if (envMatch) { + const targetName = envMatch[1]; + allEntities.add(targetName); + const parent = getParentFromExtends(extendsValue); + if (parent) { + parentMap[targetName] = parent; + allEntities.add(parent); + } + continue; + } + + // Handle [variant_base] sections (variant-specific bases like heltec_v4_base) + const variantBaseMatch = sectionName.match(/^(.+)_base$/); + if (variantBaseMatch) { + const variantBaseName = sectionName; // Keep full name like "heltec_v4_base" + allEntities.add(variantBaseName); + const parent = getParentFromExtends(extendsValue); + if (parent) { + parentMap[variantBaseName] = parent; + allEntities.add(parent); + } + continue; + } + + // Handle [arch_base] sections (architecture bases) + // Only treat as architecture base if it doesn't extend another _base (those are variant bases) + const arch = extractArchFromBaseSection(sectionName); + if (arch) { + // Check if this extends another _base - if so, it's a variant base, not an architecture base + const parentMatch = extendsValue.match(/([a-z0-9]+)_base/); + if (parentMatch && parentMatch[1] !== 'arduino') { + // This extends another _base, so it's a variant base, not an architecture base + // Treat it as a variant base instead + const variantBaseName = sectionName; + allEntities.add(variantBaseName); + const parent = getParentFromExtends(extendsValue); + if (parent) { + parentMap[variantBaseName] = parent; + allEntities.add(parent); + } + continue; + } + + // This is a true architecture base + const archBaseName = `${arch}_base`; + allEntities.add(archBaseName); + const parent = getParentFromExtends(extendsValue); + if (parent) { + parentMap[archBaseName] = parent; + allEntities.add(parent); + } + // Also add the architecture itself (without _base suffix) + // Only if it doesn't conflict with an existing target name + if (!targetNames.has(arch)) { + allEntities.add(arch); + parentMap[arch] = archBaseName; + } + continue; + } + } + } + + // Second pass: resolve architecture base names to architecture names + // Build a mapping of architecture bases to their parent architectures + const archBaseToArch = {}; + const archToParentArch = {}; + + // First, map architecture bases to their parent architectures + for (const [child, parent] of Object.entries(parentMap)) { + if (child.endsWith('_base')) { + const archMatch = child.match(/^([a-z0-9]+)_base$/); + if (archMatch) { + const arch = archMatch[1]; + const isArchitecture = extractArchFromBaseSection(child) !== null; + if (isArchitecture) { + archBaseToArch[child] = arch; + // The parent of an architecture base is the parent architecture + if (parent) { + archToParentArch[arch] = parent; + } + } + } + } + } + + // Third pass: resolve all parent references + // Replace architecture base references with architecture references + const resolvedParentMap = {}; + + for (const [child, parent] of Object.entries(parentMap)) { + if (!parent) { + resolvedParentMap[child] = null; + continue; + } + + // If parent is an architecture base, resolve to architecture + if (archBaseToArch[parent]) { + resolvedParentMap[child] = archBaseToArch[parent]; + } else { + resolvedParentMap[child] = parent; + } + } + + // Add architecture entries themselves (pointing to their parent architecture) + for (const [arch, parentArch] of Object.entries(archToParentArch)) { + if (!resolvedParentMap[arch]) { + resolvedParentMap[arch] = parentArch; + } + } + + // Mark base architectures (those with no parent) as null + const baseArchitectures = ['esp32', 'nrf52', 'rp2040', 'rp2350', 'stm32', 'portduino']; + for (const baseArch of baseArchitectures) { + if (allEntities.has(baseArch)) { + // If it doesn't have a parent or parent resolves to null, it's a base + if (!resolvedParentMap[baseArch]) { + resolvedParentMap[baseArch] = null; + } + } + } + + // Remove native entries (ignore native architecture) + const keysToRemove = Object.keys(resolvedParentMap).filter(k => k === 'native' || k.startsWith('native')); + for (const key of keysToRemove) { + delete resolvedParentMap[key]; + } + + // Normalize all keys and values (strip hyphens and underscores) + const normalizedMap = {}; + for (const [key, value] of Object.entries(resolvedParentMap)) { + const normalizedKey = normalizeName(key); + const normalizedValue = value !== null ? normalizeName(value) : null; + normalizedMap[normalizedKey] = normalizedValue; + } + + return normalizedMap; +} + +/** + * Validate parent mapping for conflicts and issues + */ +function validateParentMapping(parentMap) { + const errors = []; + const warnings = []; + + // Check for duplicate keys (shouldn't happen, but verify) + const keys = Object.keys(parentMap); + const keySet = new Set(keys); + if (keys.length !== keySet.size) { + errors.push('Duplicate keys found in mapping'); + } + + // Check for self-references (child pointing to itself) + for (const [child, parent] of Object.entries(parentMap)) { + if (parent === child) { + errors.push(`Self-reference detected: "${child}" points to itself`); + } + } + + // Check for circular references + for (const key of keys) { + const visited = new Set(); + let current = key; + let depth = 0; + const maxDepth = 100; // Safety limit + + while (current && parentMap[current] !== null && parentMap[current] !== undefined) { + if (visited.has(current)) { + errors.push(`Circular reference detected involving "${current}"`); + break; + } + visited.add(current); + current = parentMap[current]; + depth++; + if (depth > maxDepth) { + errors.push(`Infinite loop detected starting from "${key}"`); + break; + } + } + } + + // Check for missing parent references + for (const [child, parent] of Object.entries(parentMap)) { + if (parent !== null && parent !== undefined && !parentMap.hasOwnProperty(parent)) { + warnings.push(`"${child}" references missing parent "${parent}"`); + } + } + + if (errors.length > 0) { + console.error('\nVALIDATION ERRORS:'); + errors.forEach(err => console.error(` ERROR: ${err}`)); + process.exit(1); + } + + if (warnings.length > 0) { + console.warn('\nVALIDATION WARNINGS:'); + warnings.forEach(warn => console.warn(` WARNING: ${warn}`)); + } + + console.log(`✓ Validation passed: ${keys.length} entries, no conflicts`); +} + +/** + * Generate JSON file + */ +function generateFile(parentMap) { + validateParentMapping(parentMap); + const content = JSON.stringify(parentMap, null, 2); + fs.writeFileSync(OUTPUT_FILE, content); + const count = Object.keys(parentMap).length; + console.log(`Generated ${OUTPUT_FILE} with ${count} entries`); +} + +const parentMap = buildParentMapping(); +generateFile(parentMap); diff --git a/src/components/PluginToggle.tsx b/src/components/PluginToggle.tsx index 994fa7f..5508c30 100644 --- a/src/components/PluginToggle.tsx +++ b/src/components/PluginToggle.tsx @@ -13,6 +13,7 @@ interface PluginToggleProps { version?: string disabled?: boolean enabledLabel?: string + incompatibleReason?: string } export function PluginToggle({ @@ -26,9 +27,18 @@ export function PluginToggle({ version, disabled = false, enabledLabel = 'Add', + incompatibleReason, }: PluginToggleProps) { + const isIncompatible = !!incompatibleReason + return ( -
+
{/* Flash count and homepage links in lower right */}
{version && v{version}} @@ -61,12 +71,21 @@ export function PluginToggle({
-

{name}

+

+ {name} +

{featured && ( )}
-

{description}

+

+ {description} +

+ {isIncompatible && incompatibleReason && ( +

+ {incompatibleReason} +

+ )}
+ + const visited = new Set() + let current = normalized + + while (current && !visited.has(current)) { + visited.add(current) + const parent = parentMap[current] + + // If parent is null, we've reached a base architecture + if (parent === null) { + return current + } + + // If no parent found, return current (might be unknown) + if (parent === undefined) { + return current + } + + current = normalizeArchitecture(parent) + } + + // Circular reference or unknown, return the last known + return current || normalized +} + +/** + * Get all compatible architectures for a given architecture + * (including itself and all parent architectures up to base) + */ +export function getCompatibleArchitectures(arch: string): string[] { + const normalized = normalizeArchitecture(arch) + const parentMap = PARENT_MAP as Record + + const compatible = [normalized] + const visited = new Set() + let current = normalized + + // Follow parent chain up to base architecture + while (current && !visited.has(current)) { + visited.add(current) + const parent = parentMap[current] + + if (parent === null) { + // Reached base architecture + break + } + + if (parent === undefined) { + // Unknown, stop here + break + } + + const normalizedParent = normalizeArchitecture(parent) + if (!compatible.includes(normalizedParent)) { + compatible.push(normalizedParent) + } + + current = normalizedParent + } + + return compatible +} + +/** + * Check if a plugin is compatible with a target + * Plugin can specify includes/excludes arrays with targets, variant bases, or architectures + * + * @param pluginIncludes - Array of architectures/targets the plugin explicitly supports + * @param pluginExcludes - Array of architectures/targets the plugin explicitly doesn't support + * @param targetName - The target name to check compatibility against + */ +export function isPluginCompatibleWithTarget( + pluginIncludes: string[] | undefined, + pluginExcludes: string[] | undefined, + targetName: string | undefined +): boolean { + // If target not specified, can't determine compatibility + if (!targetName) { + return true // Default to compatible if unknown + } + + const parentMap = PARENT_MAP as Record + + // Normalize target name first (all keys in parentMap are normalized) + const normalizedTarget = normalizeArchitecture(targetName) + + // Get all compatible names for the target (target itself + all parents up to base architecture) + const compatibleNames = new Set([normalizedTarget]) + const visited = new Set() + let current = normalizedTarget + + // Follow parent chain (all keys and values in parentMap are already normalized) + while (current && !visited.has(current)) { + visited.add(current) + const parent = parentMap[current] + + if (parent === null) { + // Reached base architecture + compatibleNames.add(current) // Add the base architecture itself + break + } + + if (parent === undefined) { + // Unknown, stop here + break + } + + // Parent is already normalized (from JSON) + compatibleNames.add(parent) + current = parent + } + + // Check excludes first - if target matches any exclude, it's incompatible + // compatibleNames are already normalized, normalize excludes for comparison + if (pluginExcludes && pluginExcludes.length > 0) { + const isExcluded = pluginExcludes.some((exclude) => { + const normalizedExclude = normalizeArchitecture(exclude) + return compatibleNames.has(normalizedExclude) + }) + if (isExcluded) { + return false + } + } + + // If includes are specified, target must match at least one include + // compatibleNames are already normalized, normalize includes for comparison + if (pluginIncludes && pluginIncludes.length > 0) { + return pluginIncludes.some((include) => { + const normalizedInclude = normalizeArchitecture(include) + return compatibleNames.has(normalizedInclude) + }) + } + + // If no includes/excludes specified, assume compatible with all (backward compatible) + return true +} + +/** + * Check if a plugin is compatible with a target architecture + * @deprecated Use isPluginCompatibleWithTarget instead + */ +export function isPluginCompatibleWithArchitecture( + pluginArchitectures: string[] | undefined, + targetArchitecture: string | undefined +): boolean { + // Legacy support: treat architectures array as includes + return isPluginCompatibleWithTarget(pluginArchitectures, undefined, targetArchitecture) +} diff --git a/src/pages/BuildNew.tsx b/src/pages/BuildNew.tsx index 66d3004..5825040 100644 --- a/src/pages/BuildNew.tsx +++ b/src/pages/BuildNew.tsx @@ -10,6 +10,7 @@ import { getDependedPlugins, getImplicitDependencies, isRequiredByOther, + isPluginCompatibleWithTarget, } from '@/lib/utils' import { api } from '../../convex/_generated/api' import modulesData from '../../convex/modules.json' @@ -554,7 +555,7 @@ export default function BuildNew() { Reset plugins
-
+
{(() => { // Get explicitly selected plugins (user-selected) const explicitPlugins = Object.keys(pluginConfig).filter( @@ -606,9 +607,29 @@ export default function BuildNew() { const isImplicit = implicitDeps.has(slug) || (explicitPlugins.includes(slug) && isRequired) + + // Check plugin compatibility with selected target + const pluginIncludes = (plugin as { includes?: string[] }).includes + const pluginExcludes = (plugin as { excludes?: string[] }).excludes + // Legacy support: check for old "architectures" field + const legacyArchitectures = (plugin as { architectures?: string[] }).architectures + const hasCompatibilityConstraints = + (pluginIncludes && pluginIncludes.length > 0) || + (pluginExcludes && pluginExcludes.length > 0) || + (legacyArchitectures && legacyArchitectures.length > 0) + const isCompatible = hasCompatibilityConstraints && selectedTarget + ? isPluginCompatibleWithTarget( + pluginIncludes || legacyArchitectures, + pluginExcludes, + selectedTarget + ) + : true // If no constraints or no target selected, assume compatible + // Mark as incompatible if plugin has compatibility constraints and target is not compatible + const isIncompatible = !isCompatible && hasCompatibilityConstraints && !!selectedTarget + return ( handleTogglePlugin(slug, enabled) } - disabled={isImplicit} + disabled={isImplicit || isIncompatible} enabledLabel={isImplicit ? 'Required' : 'Add'} + incompatibleReason={ + isIncompatible + ? 'Not compatible with this target' + : undefined + } featured={plugin.featured ?? false} flashCount={pluginFlashCounts[slug] ?? 0} homepage={plugin.homepage}