forked from iarv/mesh-forge
feat: implement architecture hierarchy generation and enhance plugin compatibility checks
This commit is contained in:
437
scripts/generate-architecture-hierarchy.js
Normal file
437
scripts/generate-architecture-hierarchy.js
Normal file
@@ -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);
|
||||
@@ -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 (
|
||||
<div className="relative flex items-start gap-4 p-4 rounded-lg border-2 border-slate-700 bg-slate-900/50 hover:border-slate-600 transition-colors">
|
||||
<div
|
||||
className={`relative flex items-start gap-4 p-4 rounded-lg border-2 transition-colors ${
|
||||
isIncompatible
|
||||
? 'border-slate-800 bg-slate-900/30 opacity-60 cursor-not-allowed'
|
||||
: 'border-slate-700 bg-slate-900/50 hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
{/* Flash count and homepage links in lower right */}
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-3 text-xs text-slate-400 z-10">
|
||||
{version && <span className="text-slate-500">v{version}</span>}
|
||||
@@ -61,12 +71,21 @@ export function PluginToggle({
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<h4 className="font-semibold text-sm">{name}</h4>
|
||||
<h4 className={`font-semibold text-sm ${isIncompatible ? 'text-slate-500' : ''}`}>
|
||||
{name}
|
||||
</h4>
|
||||
{featured && (
|
||||
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 leading-relaxed">{description}</p>
|
||||
<p className={`text-xs leading-relaxed ${isIncompatible ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
{description}
|
||||
</p>
|
||||
{isIncompatible && incompatibleReason && (
|
||||
<p className="text-xs text-red-400 mt-1 font-medium">
|
||||
{incompatibleReason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
<Switch
|
||||
|
||||
220
src/constants/architecture-hierarchy.json
Normal file
220
src/constants/architecture-hierarchy.json
Normal file
@@ -0,0 +1,220 @@
|
||||
{
|
||||
"betafpv2400txmicro": "esp32",
|
||||
"betafpv900txnano": "esp32",
|
||||
"chatter2": "esp32",
|
||||
"9m2ibraprsloratracker": "esp32",
|
||||
"meshtasticdrdev": "esp32",
|
||||
"hydra": "esp32",
|
||||
"meshtasticdiyv1": "esp32",
|
||||
"meshtasticdiyv11": "esp32",
|
||||
"hackerboxesesp32io": "esp32",
|
||||
"heltecv1": "esp32",
|
||||
"heltecv20": "esp32",
|
||||
"heltecv21": "esp32",
|
||||
"heltecwirelessbridge": "esp32",
|
||||
"heltecwslv21": "esp32",
|
||||
"m5stackcore": "esp32",
|
||||
"m5stackcoreink": "esp32",
|
||||
"nanog1": "esp32",
|
||||
"nanog1explorer": "esp32",
|
||||
"radiomaster900bandit": "esp32",
|
||||
"radiomaster900banditmicro": "esp32",
|
||||
"radiomaster900banditnano": "esp32",
|
||||
"rak11200": "esp32",
|
||||
"stationg1": "esp32",
|
||||
"tbeam": "esp32",
|
||||
"tbeamdisplayshield": "tbeam",
|
||||
"tbeam07": "esp32",
|
||||
"tlorav1": "esp32",
|
||||
"tlorav13": "esp32",
|
||||
"tlorav2": "esp32",
|
||||
"tlorav2116": "esp32",
|
||||
"sugarcube": "tlorav2116",
|
||||
"tlorav2116tcxo": "esp32",
|
||||
"tlorav2118": "esp32",
|
||||
"tlorav330tcxo": "esp32",
|
||||
"trackerd": "esp32",
|
||||
"wiphone": "esp32",
|
||||
"aic3": "esp32c3",
|
||||
"esp32c3supermini": "esp32c3",
|
||||
"esp32c3base": "esp32",
|
||||
"hackerboxesesp32c3oled": "esp32c3",
|
||||
"heltecht62esp32c3sx1262": "esp32c3",
|
||||
"heltechru3601": "esp32c3",
|
||||
"m5stackstampc3": "esp32c3",
|
||||
"esp32c6base": "esp32",
|
||||
"m5stackunitc6l": "esp32c6",
|
||||
"tlorac6": "esp32c6",
|
||||
"esp32s2base": "esp32",
|
||||
"nuggets2lora": "esp32s2",
|
||||
"CDEBYTEEoRaS3": "esp32s3",
|
||||
"EBYTEESP32S3": "esp32s3",
|
||||
"thinknodem2": "esp32s3",
|
||||
"thinknodem5": "esp32s3",
|
||||
"bpipicowesp32s3": "esp32s3",
|
||||
"crowpanelesp32s35epaper": "esp32s3",
|
||||
"crowpanelesp32s34epaper": "esp32s3",
|
||||
"crowpanelesp32s32epaper": "esp32s3",
|
||||
"myesp32s3diyeink": "esp32s3",
|
||||
"myesp32s3diyoled": "esp32s3",
|
||||
"tenergys3e22": "esp32s3",
|
||||
"dreamcatcher2206": "esp32s3",
|
||||
"crowpanelbase": "crowpanel",
|
||||
"elecrowadv2428tft": "crowpanelsmallesp32s3base",
|
||||
"elecrowadv35tft": "crowpanelsmallesp32s3base",
|
||||
"elecrowadv1435070tft": "crowpanellargeesp32s3base",
|
||||
"ESP32S3Pico": "esp32s3",
|
||||
"esp32s3base": "esp32",
|
||||
"hackadaycommunicator": "esp32s3",
|
||||
"helteccapsulesensorv3": "esp32s3",
|
||||
"heltecsensorhub": "esp32s3",
|
||||
"heltecv3": "esp32s3",
|
||||
"heltecv4base": "esp32s3",
|
||||
"heltecv4": "heltecv4base",
|
||||
"heltecv4tft": "heltecv4base",
|
||||
"heltecvisionmastere213": "esp32s3",
|
||||
"heltecvisionmastere213inkhud": "esp32s3",
|
||||
"heltecvisionmastere290": "esp32s3",
|
||||
"heltecvisionmastere290inkhud": "esp32s3",
|
||||
"heltecvisionmastert190": "esp32s3",
|
||||
"heltecwirelesspaper": "esp32s3",
|
||||
"heltecwirelesspaperinkhud": "esp32s3",
|
||||
"heltecwirelesspaperv10": "esp32s3",
|
||||
"heltecwirelesstracker": "esp32s3",
|
||||
"heltecwirelesstrackerV10": "esp32s3",
|
||||
"heltecwirelesstrackerv2": "esp32s3",
|
||||
"heltecwslv3": "esp32s3",
|
||||
"icarus": "esp32s3",
|
||||
"link32s3v1": "esp32s3",
|
||||
"m5stackcores3": "esp32s3",
|
||||
"meshtabbase": "esp32s3",
|
||||
"meshtab32TNresistive": "meshtabbase",
|
||||
"meshtab32IPSresistive": "meshtabbase",
|
||||
"meshtab35IPSresistive": "meshtabbase",
|
||||
"meshtab35TNresistive": "meshtabbase",
|
||||
"meshtab32IPScapacitive": "meshtabbase",
|
||||
"meshtab35IPScapacitive": "meshtabbase",
|
||||
"meshtab40IPScapacitive": "meshtabbase",
|
||||
"nibbleesp32": "esp32s3",
|
||||
"nuggets3lora": "esp32s3",
|
||||
"picomputers3": "esp32s3",
|
||||
"picomputers3tft": "picomputers3",
|
||||
"rak3312": "esp32s3",
|
||||
"rakwismeshtapv2tft": "rakwismeshtaps3",
|
||||
"seeedsensecapindicator": "esp32s3",
|
||||
"seeedsensecapindicatortft": "seeedsensecapindicator",
|
||||
"seeedxiaos3": "esp32s3",
|
||||
"stationg2": "esp32s3",
|
||||
"tdeck": "esp32s3",
|
||||
"tdecktft": "tdeck",
|
||||
"tdeckpro": "esp32s3",
|
||||
"tethelite": "esp32s3",
|
||||
"twatchs3": "esp32s3",
|
||||
"tbeams3core": "esp32s3",
|
||||
"tlorapager": "esp32s3",
|
||||
"tlorapagertft": "tlorapager",
|
||||
"tlorat3s3epaper": "esp32s3",
|
||||
"tlorat3s3epaperinkhud": "esp32s3",
|
||||
"tlorat3s3v1": "esp32s3",
|
||||
"tracksenger": "esp32s3",
|
||||
"tracksengerlcd": "esp32s3",
|
||||
"tracksengeroled": "esp32s3",
|
||||
"unphone": "esp32s3",
|
||||
"unphonetft": "unphone",
|
||||
"coverage": "native",
|
||||
"buildroot": "portduino",
|
||||
"pca10059diyeink": "nrf52840",
|
||||
"thinknodem1": "nrf52840",
|
||||
"thinknodem1inkhud": "nrf52840",
|
||||
"thinknodem3": "nrf52840",
|
||||
"thinknodem6": "nrf52840",
|
||||
"ME25LS014Y10TD": "nrf52840",
|
||||
"ME25LS014Y10TDeink": "nrf52840",
|
||||
"ms24sf1": "nrf52840",
|
||||
"makerpythonnrf52840sx1280eink": "nrf52840",
|
||||
"makerpythonnrf52840sx1280oled": "nrf52840",
|
||||
"TWCmeshv4": "nrf52840",
|
||||
"canaryone": "nrf52840",
|
||||
"WashTastic": "nrf52840",
|
||||
"nrf52promicrodiytcxo": "nrf52840",
|
||||
"nrf52promicrodiyinkhud": "nrf52840",
|
||||
"seeedxiaonrf52840wiosx1262": "nrf52840",
|
||||
"seeedxiaonrf52840e22900m30s": "seeedxiaonrf52840kit",
|
||||
"seeedxiaonrf52840e22900m33s": "seeedxiaonrf52840kit",
|
||||
"xiaoble": "seeedxiaonrf52840kit",
|
||||
"featherdiy": "nrf52840",
|
||||
"gat562meshtrialtracker": "nrf52840",
|
||||
"heltecmeshnodet114": "nrf52840",
|
||||
"heltecmeshnodet114inkhud": "nrf52840",
|
||||
"heltecmeshpocket5000": "nrf52840",
|
||||
"heltecmeshpocket5000inkhud": "nrf52840",
|
||||
"heltecmeshpocket10000": "nrf52840",
|
||||
"heltecmeshpocket10000inkhud": "nrf52840",
|
||||
"heltecmeshsolarbase": "nrf52840",
|
||||
"heltecmeshsolar": "heltecmeshsolarbase",
|
||||
"heltecmeshsolareink": "heltecmeshsolarbase",
|
||||
"heltecmeshsolarinkhud": "heltecmeshsolarbase",
|
||||
"heltecmeshsolaroled": "heltecmeshsolarbase",
|
||||
"heltecmeshsolartft": "heltecmeshsolarbase",
|
||||
"meshlink": "nrf52840",
|
||||
"meshlinkeink": "nrf52840",
|
||||
"meshtiny": "nrf52840",
|
||||
"monteopshw1": "nrf52840",
|
||||
"muzibase": "nrf52840",
|
||||
"nanog2ultra": "nrf52840",
|
||||
"nrf52832base": "nrf52",
|
||||
"nrf52840base": "nrf52",
|
||||
"r1neo": "nrf52840",
|
||||
"rak2560": "nrf52840",
|
||||
"rak34011watt": "nrf52840",
|
||||
"rak4631": "nrf52840",
|
||||
"rak4631dbg": "rak4631",
|
||||
"rak4631eink": "nrf52840",
|
||||
"rak4631einkonrxtx": "nrf52840",
|
||||
"rak4631ethgw": "nrf52840",
|
||||
"rak4631ethgwdbg": "rak4631",
|
||||
"rak4631nomadstarmeteorpro": "nrf52840",
|
||||
"rak4631nomadstarmeteorprodbg": "rak4631nomadstarmeteorpro",
|
||||
"rakwismeshtag": "nrf52840",
|
||||
"rakwismeshtap": "nrf52840",
|
||||
"seeedsolarnode": "nrf52840",
|
||||
"seeedwiotrackerL1": "nrf52840",
|
||||
"seeedwiotrackerL1eink": "nrf52840",
|
||||
"seeedwiotrackerL1einkinkhud": "nrf52840",
|
||||
"seeedxiaonrf52840kit": "nrf52840",
|
||||
"seeedxiaonrf52840kiti2c": "seeedxiaonrf52840kit",
|
||||
"techo": "nrf52840",
|
||||
"techoinkhud": "nrf52840",
|
||||
"techolite": "nrf52840",
|
||||
"trackert1000e": "nrf52840",
|
||||
"wiosdkwm1110": "nrf52840",
|
||||
"wiot1000s": "nrf52840",
|
||||
"wiotrackerwm1110": "nrf52840",
|
||||
"challenger2040lora": "rp2040",
|
||||
"catsniffer": "rp2040",
|
||||
"featherrp2040rfm95": "rp2040",
|
||||
"nibblerp2040": "rp2040",
|
||||
"rak11310": "rp2040",
|
||||
"rp2040lora": "rp2040",
|
||||
"pico": "rp2040",
|
||||
"picoslowclock": "rp2040",
|
||||
"picow": "rp2040",
|
||||
"senselorarp2040": "rp2040",
|
||||
"pico2": "rp2350",
|
||||
"pico2w": "rp2350",
|
||||
"CDEBYTEE77MBL": "stm32",
|
||||
"rak3172": "stm32",
|
||||
"wioe5": "stm32",
|
||||
"esp32c3": "esp32",
|
||||
"esp32c6": "esp32",
|
||||
"esp32s2": "esp32",
|
||||
"esp32s3": "esp32",
|
||||
"nrf52832": "nrf52",
|
||||
"nrf52840": "nrf52",
|
||||
"esp32": null,
|
||||
"nrf52": null,
|
||||
"rp2040": null,
|
||||
"rp2350": null,
|
||||
"stm32": null,
|
||||
"portduino": null
|
||||
}
|
||||
167
src/lib/utils.ts
167
src/lib/utils.ts
@@ -1,5 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import PARENT_MAP from '../constants/architecture-hierarchy.json'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
@@ -125,3 +126,169 @@ export function isRequiredByOther(
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize architecture name (remove hyphens and underscores to match PlatformIO format)
|
||||
* PlatformIO uses "esp32s3", "nrf52840" (no hyphens, no underscores)
|
||||
* Hardware list uses "esp32-s3" (with hyphens)
|
||||
* Some sources might use "esp32_s3" (with underscores)
|
||||
*/
|
||||
function normalizeArchitecture(arch: string): string {
|
||||
return arch.replace(/[-_]/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace a target/variant/architecture back to its base architecture
|
||||
* Follows the parent chain until it reaches a base architecture (null parent)
|
||||
*/
|
||||
export function getBaseArchitecture(name: string): string | null {
|
||||
const normalized = normalizeArchitecture(name)
|
||||
const parentMap = PARENT_MAP as Record<string, string | null>
|
||||
|
||||
const visited = new Set<string>()
|
||||
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<string, string | null>
|
||||
|
||||
const compatible = [normalized]
|
||||
const visited = new Set<string>()
|
||||
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<string, string | null>
|
||||
|
||||
// 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<string>([normalizedTarget])
|
||||
const visited = new Set<string>()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div className="grid gap-2 md:grid-cols-2" key={`plugins-${selectedTarget}`}>
|
||||
{(() => {
|
||||
// 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 (
|
||||
<PluginToggle
|
||||
key={slug}
|
||||
key={`${slug}-${selectedTarget}`}
|
||||
id={slug}
|
||||
name={plugin.name}
|
||||
description={plugin.description}
|
||||
@@ -616,8 +637,13 @@ export default function BuildNew() {
|
||||
onToggle={(enabled) =>
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user