5 Commits

32 changed files with 1646 additions and 1218 deletions

13
.gitmodules vendored
View File

@@ -1,13 +1,3 @@
[submodule "vendor/web-flasher"]
path = vendor/web-flasher
url = https://github.com/meshtastic/web-flasher
[submodule "vendor/firmware"]
path = vendor/firmware
url = https://github.com/MeshEnvy/firmware.git
branch = meshenvy/module-registry
[submodule "vendor/api"]
path = vendor/api
url = https://github.com/meshtastic/api.git
[submodule "vendor/mpm"]
path = vendor/mpm
url = git@github.com:MeshEnvy/mpm.git
@@ -17,9 +7,6 @@
[submodule "vendor/lodb"]
path = vendor/lodb
url = git@github.com:MeshEnvy/lodb.git
[submodule "vendor/meshcore"]
path = vendor/meshcore
url = git@github.com:MeshEnvy/MeshCore.git
[submodule "vendor/meshscript"]
path = vendor/meshscript
url = git@github.com:MeshEnvy/meshscript.git

View File

@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.4.0] - 2025-12-10
### Minor
- Added vendors.json mapping vendors to models and platformio targets
- Refactored targets.ts to use vendors.json and architecture-hierarchy.json instead of hardware-list.json
- Updated architecture-hierarchy.json generation to use actual PlatformIO environment names (removed normalization)
- Removed normalization from lib/utils.ts since all inputs now use standardized PlatformIO names
- Refactored build routes from dynamic parameterized routes to query string parameters for Vike SSG compatibility
- Refactored Builder component into smaller reusable components (BuilderHeader, TargetSelector, VersionSelector, ModuleConfig, PluginConfig, BuildActions)
- Extracted target selection and plugin compatibility logic into reusable hooks (useTargetSelection, usePluginCompatibility)
### Patch
- Fix Convex server functions being imported in browser by moving ArtifactType enum to client-safe location
- Fix nested anchor tag hydration error in PluginCard component by converting nested links to buttons when parent is a link
## [0.3.0] - 2025-12-10
### Minor
@@ -35,7 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Initial release
[Unreleased]: https://github.com/MeshEnvy/mesh-forge/compare/v0.3.0...HEAD
[Unreleased]: https://github.com/MeshEnvy/mesh-forge/compare/v0.4.0...HEAD
[0.4.0]: https://github.com/MeshEnvy/mesh-forge/releases/tag/v0.4.0
[0.3.0]: https://github.com/MeshEnvy/mesh-forge/releases/tag/v0.3.0
[0.2.0]: https://github.com/MeshEnvy/mesh-forge/releases/tag/v0.2.0
[0.1.0]: https://github.com/MeshEnvy/mesh-forge/releases/tag/v0.1.0

View File

@@ -0,0 +1,34 @@
import { Button } from "@/components/ui/button"
import { Loader2 } from "lucide-react"
interface BuildActionsProps {
selectedTargetLabel: string
isFlashing: boolean
isFlashDisabled: boolean
errorMessage: string | null
onFlash: () => void
}
export function BuildActions({
selectedTargetLabel,
isFlashing,
isFlashDisabled,
errorMessage,
onFlash,
}: BuildActionsProps) {
return (
<div className="space-y-2">
<Button onClick={onFlash} disabled={isFlashDisabled} className="w-full bg-cyan-600 hover:bg-cyan-700">
{isFlashing ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Queuing build...
</span>
) : (
`Flash ${selectedTargetLabel || ""}`.trim() || "Flash"
)}
</Button>
{errorMessage && <p className="text-sm text-red-400">{errorMessage}</p>}
</div>
)
}

View File

@@ -2,7 +2,7 @@ import { SourceAvailable } from "@/components/SourceAvailable"
import { Button } from "@/components/ui/button"
import { api } from "@/convex/_generated/api"
import type { Doc } from "@/convex/_generated/dataModel"
import { ArtifactType } from "@/convex/builds"
import { ArtifactType } from "@/convex/lib/filename"
import { useMutation } from "convex/react"
import { useState } from "react"
import { toast } from "sonner"

View File

@@ -2,8 +2,7 @@ import { BuildDownloadButton } from "@/components/BuildDownloadButton"
import { Button } from "@/components/ui/button"
import { TARGETS } from "@/constants/targets"
import type { Doc } from "@/convex/_generated/dataModel"
import { ArtifactType } from "@/convex/builds"
import { getArtifactFilenameBase } from "@/convex/lib/filename"
import { ArtifactType, getArtifactFilenameBase } from "@/convex/lib/filename"
import modulesData from "@/convex/modules.json"
import { getImplicitDependencies, humanizeStatus } from "@/lib/utils"
import registryData from "@/public/registry.json"
@@ -49,7 +48,7 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
? `https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}`
: null
const shareUrl = `${window.location.origin}/builds/new/${build.buildHash}`
const shareUrl = `${window.location.origin}/builds?clone=${build.buildHash}`
const handleShare = async () => {
try {
@@ -217,10 +216,10 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
<h2 className="text-2xl font-semibold mb-2 flex items-center gap-2">
{getStatusIcon()}
<a
href={`/builds/${build.buildHash}`}
href={`/builds?id=${build.buildHash}`}
onClick={e => {
e.preventDefault()
navigate(`/builds/${build.buildHash}`)
navigate(`/builds?id=${build.buildHash}`)
}}
className="hover:text-cyan-400 transition-colors"
>
@@ -258,7 +257,7 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
{showActions && (
<div className="flex gap-2">
<Button
onClick={() => navigate(`/builds/new/${build.buildHash}`)}
onClick={() => navigate(`/builds?clone=${build.buildHash}`)}
variant="outline"
className="border-slate-600 hover:bg-slate-800"
aria-label="Clone"

296
components/Builder.tsx Normal file
View File

@@ -0,0 +1,296 @@
import { BuildActions } from "@/components/BuildActions"
import { BuilderHeader } from "@/components/BuilderHeader"
import { ModuleConfig } from "@/components/ModuleConfig"
import { PluginConfig } from "@/components/PluginConfig"
import { TargetSelector } from "@/components/TargetSelector"
import { VersionSelector } from "@/components/VersionSelector"
import { TARGETS } from "@/constants/targets"
import { VERSIONS } from "@/constants/versions"
import { api } from "@/convex/_generated/api"
import { usePluginCompatibility } from "@/hooks/usePluginCompatibility"
import { useTargetSelection } from "@/hooks/useTargetSelection"
import { getDependedPlugins, getImplicitDependencies, isRequiredByOther } from "@/lib/utils"
import registryData from "@/public/registry.json"
import { useMutation, useQuery } from "convex/react"
import { Loader2 } from "lucide-react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { navigate } from "vike/client/router"
interface BuilderProps {
cloneHash?: string
pluginParam?: string
}
export default function Builder({ cloneHash, pluginParam }: BuilderProps) {
const ensureBuildFromConfig = useMutation(api.builds.ensureBuildFromConfig)
const pluginFlashCounts = useQuery(api.plugins.getAll) ?? {}
const sharedBuild = useQuery(api.builds.getByHash, cloneHash ? { buildHash: cloneHash } : "skip")
const preselectedPlugin =
pluginParam && pluginParam in registryData
? (
registryData as Record<
string,
{ includes?: string[]; name: string; description: string; imageUrl?: string; featured?: boolean }
>
)[pluginParam]
: null
const [selectedVersion, setSelectedVersion] = useState<string>(VERSIONS[0])
const [moduleConfig, setModuleConfig] = useState<Record<string, boolean>>({})
const [pluginConfig, setPluginConfig] = useState<Record<string, boolean>>({})
const [isFlashing, setIsFlashing] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [showModuleOverrides, setShowModuleOverrides] = useState(false)
const [showPlugins, setShowPlugins] = useState(true)
const [isLoadingSharedBuild, setIsLoadingSharedBuild] = useState(false)
// Get all enabled plugins
const enabledPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
// Calculate plugin compatibility
const { compatibleTargets, filteredGroupedTargets, filteredTargetCategories } = usePluginCompatibility(
enabledPlugins,
preselectedPlugin
)
// Target selection logic
const { activeCategory, selectedTarget, setActiveCategory, handleSelectTarget, TARGET_CATEGORIES, GROUPED_TARGETS } =
useTargetSelection(compatibleTargets, filteredGroupedTargets, filteredTargetCategories)
// Preselect plugin from URL parameter
useEffect(() => {
if (pluginParam && preselectedPlugin && !cloneHash) {
setPluginConfig({ [pluginParam]: true })
setShowPlugins(true)
}
}, [pluginParam, preselectedPlugin, cloneHash])
// Pre-populate form from shared build
useEffect(() => {
if (!cloneHash) return
if (sharedBuild === undefined) {
setIsLoadingSharedBuild(true)
return
}
setIsLoadingSharedBuild(false)
if (!sharedBuild) {
setErrorMessage("Build not found. The shared build may have been deleted.")
toast.error("Build not found", {
description: "The shared build could not be loaded.",
})
return
}
const config = sharedBuild.config
if (config.target && TARGETS[config.target]) {
handleSelectTarget(config.target)
const category = TARGETS[config.target].category || "Other"
if (TARGET_CATEGORIES.includes(category)) {
setActiveCategory(category)
}
}
if (config.version && (VERSIONS as readonly string[]).includes(config.version)) {
setSelectedVersion(config.version as (typeof VERSIONS)[number])
}
if (config.modulesExcluded) {
setModuleConfig(config.modulesExcluded)
if (Object.keys(config.modulesExcluded).length > 0) {
setShowModuleOverrides(true)
}
}
if (config.pluginsEnabled && config.pluginsEnabled.length > 0) {
const allPluginSlugs = config.pluginsEnabled.map(pluginId => {
return pluginId.includes("@") ? pluginId.split("@")[0] : pluginId
})
const requiredByOthers = new Set<string>()
for (const pluginSlug of allPluginSlugs) {
if (
isRequiredByOther(
pluginSlug,
allPluginSlugs,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
) {
requiredByOthers.add(pluginSlug)
}
}
const pluginObj: Record<string, boolean> = {}
allPluginSlugs.forEach(slug => {
if (slug in registryData && !requiredByOthers.has(slug)) {
pluginObj[slug] = true
}
})
setPluginConfig(pluginObj)
setShowPlugins(true)
}
}, [cloneHash, sharedBuild, handleSelectTarget, setActiveCategory, TARGET_CATEGORIES])
const selectedTargetLabel = (selectedTarget && TARGETS[selectedTarget]?.name) || selectedTarget
const handleToggleModule = (id: string, excluded: boolean) => {
setModuleConfig(prev => {
const next = { ...prev }
if (excluded) {
next[id] = true
} else {
delete next[id]
}
return next
})
}
const handleTogglePlugin = (id: string, enabled: boolean) => {
const explicitPlugins = Object.keys(pluginConfig).filter(pluginId => pluginConfig[pluginId] === true)
const implicitDeps = getImplicitDependencies(
explicitPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
const isRequired = isRequiredByOther(
id,
explicitPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
if (implicitDeps.has(id)) {
return
}
if (!enabled && isRequired) {
return
}
setPluginConfig(prev => {
const next = { ...prev }
if (enabled) {
next[id] = true
} else {
delete next[id]
const remainingExplicit = Object.keys(next).filter(pluginId => next[pluginId] === true)
const allStillNeeded = getDependedPlugins(
remainingExplicit,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
for (const pluginId of Object.keys(next)) {
if (next[pluginId] === true && !allStillNeeded.includes(pluginId) && !remainingExplicit.includes(pluginId)) {
delete next[pluginId]
}
}
for (const pluginId of remainingExplicit) {
next[pluginId] = true
}
}
return next
})
}
const handleFlash = async () => {
if (!selectedTarget) return
setIsFlashing(true)
setErrorMessage(null)
try {
const enabledSlugs = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
const implicitDeps = getImplicitDependencies(
enabledSlugs,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
const explicitOnlySlugs = enabledSlugs.filter(slug => !implicitDeps.has(slug))
const pluginsEnabled = explicitOnlySlugs.map(slug => {
const plugin = (registryData as Record<string, { version: string }>)[slug]
return `${slug}@${plugin.version}`
})
const result = await ensureBuildFromConfig({
target: selectedTarget,
version: selectedVersion,
modulesExcluded: moduleConfig,
pluginsEnabled: pluginsEnabled.length > 0 ? pluginsEnabled : undefined,
})
navigate(`/builds?id=${result.buildHash}`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
setErrorMessage("Failed to start build. Please try again.")
toast.error("Failed to start build", {
description: message,
})
} finally {
setIsFlashing(false)
}
}
if (isLoadingSharedBuild) {
return (
<div className="min-h-screen bg-slate-950 text-white p-6 md:p-10 flex items-center justify-center">
<div className="text-center space-y-4">
<Loader2 className="w-8 h-8 animate-spin text-cyan-500 mx-auto" />
<p className="text-slate-400">Loading shared build configuration...</p>
</div>
</div>
)
}
const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES
return (
<div className="min-h-screen bg-slate-950 text-white p-6 md:p-10">
<div className="max-w-6xl mx-auto space-y-8">
<BuilderHeader preselectedPlugin={preselectedPlugin} />
<div className="space-y-6 bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
<TargetSelector
activeCategory={activeCategory}
categories={categories}
groupedTargets={compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS}
selectedTarget={selectedTarget}
compatibleTargets={compatibleTargets}
onCategoryChange={setActiveCategory}
onTargetSelect={handleSelectTarget}
/>
<VersionSelector selectedVersion={selectedVersion} onVersionChange={setSelectedVersion} />
<ModuleConfig
moduleConfig={moduleConfig}
showModuleOverrides={showModuleOverrides}
onToggleShow={() => setShowModuleOverrides(prev => !prev)}
onToggleModule={handleToggleModule}
onReset={() => setModuleConfig({})}
/>
<PluginConfig
pluginConfig={pluginConfig}
selectedTarget={selectedTarget}
pluginParam={pluginParam}
pluginFlashCounts={pluginFlashCounts}
showPlugins={showPlugins}
onToggleShow={() => setShowPlugins(prev => !prev)}
onTogglePlugin={handleTogglePlugin}
onReset={() => setPluginConfig({})}
/>
<BuildActions
selectedTargetLabel={selectedTargetLabel}
isFlashing={isFlashing}
isFlashDisabled={!selectedTarget || isFlashing}
errorMessage={errorMessage}
onFlash={handleFlash}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,64 @@
import { CheckCircle2 } from "lucide-react"
interface BuilderHeaderProps {
preselectedPlugin?: {
name: string
description: string
imageUrl?: string
featured?: boolean
includes?: string[]
} | null
}
export function BuilderHeader({ preselectedPlugin }: BuilderHeaderProps) {
return (
<>
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<p className="text-sm uppercase tracking-wider text-slate-500">
{preselectedPlugin ? "Plugin build" : "Quick build"}
</p>
<h1 className="text-4xl font-bold mt-1">
{preselectedPlugin ? `Build firmware for ${preselectedPlugin.name}` : "Flash a custom firmware version"}
</h1>
<p className="text-slate-400 mt-2 max-w-2xl">
{preselectedPlugin
? `Select a compatible Meshtastic target and configure your build for ${preselectedPlugin.name}. We'll send you to the build status page as soon as it starts.`
: "Choose your Meshtastic target, adjust optional modules, and queue a new build instantly. We'll send you to the build status page as soon as it starts."}
</p>
</div>
</div>
{preselectedPlugin && (
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
<div className="flex items-start gap-4">
<CheckCircle2 className="w-8 h-8 text-green-400 shrink-0 mt-1" />
<div className="flex items-start gap-4 flex-1">
{preselectedPlugin.imageUrl && (
<img
src={preselectedPlugin.imageUrl}
alt={`${preselectedPlugin.name} logo`}
className="w-16 h-16 rounded-lg object-contain shrink-0"
/>
)}
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h2 className="text-2xl font-bold">{preselectedPlugin.name}</h2>
{preselectedPlugin.featured && (
<span className="px-2 py-1 text-xs font-medium text-green-400 bg-green-400/10 border border-green-400/20 rounded">
Featured
</span>
)}
</div>
<p className="text-slate-400 mb-3">{preselectedPlugin.description}</p>
{preselectedPlugin.includes && preselectedPlugin.includes.length > 0 && (
<p className="text-sm text-slate-500">Compatible with: {preselectedPlugin.includes.join(", ")}</p>
)}
</div>
</div>
</div>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,74 @@
import { ModuleToggle } from "@/components/ModuleToggle"
import modulesData from "@/convex/modules.json"
import { ChevronDown, ChevronRight } from "lucide-react"
interface ModuleConfigProps {
moduleConfig: Record<string, boolean>
showModuleOverrides: boolean
onToggleShow: () => void
onToggleModule: (id: string, excluded: boolean) => void
onReset: () => void
}
export function ModuleConfig({
moduleConfig,
showModuleOverrides,
onToggleShow,
onToggleModule,
onReset,
}: ModuleConfigProps) {
const moduleCount = Object.keys(moduleConfig).length
return (
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-950/70 p-6">
<button type="button" onClick={onToggleShow} className="w-full flex items-center justify-between text-left">
<div>
<p className="text-sm font-medium">Core Modules</p>
<p className="text-xs text-slate-400">
{moduleCount === 0
? "Using default modules for this target."
: `${moduleCount} module${moduleCount === 1 ? "" : "s"} excluded.`}
</p>
</div>
{showModuleOverrides ? (
<ChevronDown className="w-4 h-4 text-slate-400" />
) : (
<ChevronRight className="w-4 h-4 text-slate-400" />
)}
</button>
{showModuleOverrides && (
<div className="space-y-2 pr-1">
<div className="rounded-lg bg-slate-800/50 border border-slate-700 p-3">
<p className="text-xs text-slate-400 leading-relaxed">
Core Modules are officially maintained modules by Meshtastic. They are selectively included or excluded by
default depending on the target device. You can explicitly exclude modules you know you don't want.
</p>
</div>
<div className="flex justify-end">
<button
type="button"
className="text-xs text-slate-400 hover:text-white underline"
onClick={onReset}
disabled={moduleCount === 0}
>
Reset overrides
</button>
</div>
<div className="grid gap-2 md:grid-cols-2">
{modulesData.modules.map(module => (
<ModuleToggle
key={module.id}
id={module.id}
name={module.name}
description={module.description}
isExcluded={moduleConfig[module.id] === true}
onToggle={excluded => onToggleModule(module.id, excluded)}
/>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -205,42 +205,87 @@ export function PluginCard(props: PluginCardProps) {
<span>{downloads.toLocaleString()}</span>
</div>
)}
{homepage && homepage !== repo && (isLink || isLinkToggle) && (
<a
href={homepage}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="hover:opacity-80 transition-opacity"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
className="text-slate-400"
fill="currentColor"
role="img"
{homepage &&
homepage !== repo &&
(isLink || isLinkToggle) &&
(isLink ? (
<button
type="button"
onClick={e => {
e.preventDefault()
e.stopPropagation()
window.open(homepage, "_blank", "noopener,noreferrer")
}}
className="hover:opacity-80 transition-opacity"
aria-label="Homepage"
>
<path
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
className="text-slate-400"
fill="currentColor"
d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825"
/>
</svg>
</a>
)}
{starsBadgeUrl && repo && (
<a
href={repo}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="hover:opacity-80 transition-opacity"
>
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
</a>
)}
role="img"
aria-label="Homepage"
>
<path
fill="currentColor"
d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825"
/>
</svg>
</button>
) : (
<a
href={homepage}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="hover:opacity-80 transition-opacity"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
className="text-slate-400"
fill="currentColor"
role="img"
aria-label="Homepage"
>
<path
fill="currentColor"
d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825"
/>
</svg>
</a>
))}
{starsBadgeUrl &&
repo &&
(isLink ? (
<button
type="button"
onClick={e => {
e.preventDefault()
e.stopPropagation()
window.open(repo, "_blank", "noopener,noreferrer")
}}
className="hover:opacity-80 transition-opacity"
aria-label="GitHub repository"
>
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
</button>
) : (
<a
href={repo}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="hover:opacity-80 transition-opacity"
>
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
</a>
))}
</div>
{/* Build Now button - absolutely positioned in lower right */}
{isLink && (
@@ -249,7 +294,7 @@ export function PluginCard(props: PluginCardProps) {
onClick={e => {
e.preventDefault()
e.stopPropagation()
navigate(`/builds/new?plugin=${id}`)
navigate(`/builds?plugin=${id}`)
}}
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 rounded hover:bg-cyan-400/20 transition-colors cursor-pointer"
>
@@ -287,7 +332,7 @@ export function PluginCard(props: PluginCardProps) {
} ${isLink ? "group" : ""}`
if (isLink) {
const href = props.href || `/builds/new?plugin=${id}`
const href = props.href || `/builds?plugin=${id}`
return (
<a href={href} className={baseClassName}>
{cardContent}

158
components/PluginConfig.tsx Normal file
View File

@@ -0,0 +1,158 @@
import { PluginCard } from "@/components/PluginCard"
import {
getDependedPlugins,
getImplicitDependencies,
isPluginCompatibleWithTarget,
isRequiredByOther,
} from "@/lib/utils"
import registryData from "@/public/registry.json"
import { ChevronDown, ChevronRight } from "lucide-react"
interface PluginConfigProps {
pluginConfig: Record<string, boolean>
selectedTarget: string
pluginParam?: string
pluginFlashCounts: Record<string, number>
showPlugins: boolean
onToggleShow: () => void
onTogglePlugin: (id: string, enabled: boolean) => void
onReset: () => void
}
export function PluginConfig({
pluginConfig,
selectedTarget,
pluginParam,
pluginFlashCounts,
showPlugins,
onToggleShow,
onTogglePlugin,
onReset,
}: PluginConfigProps) {
const pluginCount = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true).length
// Get explicitly selected plugins (user-selected)
const explicitPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
// Compute implicit dependencies (dependencies that are not explicitly selected)
const implicitDeps = getImplicitDependencies(
explicitPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Compute all enabled plugins (explicit + implicit)
const allEnabledPlugins = getDependedPlugins(
explicitPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
return (
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-950/70 p-6">
<button type="button" onClick={onToggleShow} className="w-full flex items-center justify-between text-left">
<div>
<p className="text-sm font-medium">Plugins</p>
<p className="text-xs text-slate-400">
{pluginCount === 0
? "No plugins enabled."
: `${pluginCount} plugin${pluginCount === 1 ? "" : "s"} enabled.`}
</p>
</div>
{showPlugins ? (
<ChevronDown className="w-4 h-4 text-slate-400" />
) : (
<ChevronRight className="w-4 h-4 text-slate-400" />
)}
</button>
{showPlugins && (
<div className="space-y-2 pr-1">
<div className="rounded-lg bg-slate-800/50 border border-slate-700 p-3">
<p className="text-xs text-slate-400 leading-relaxed">
Plugins are 3rd party add-ons. They are not maintained, endorsed, or supported by Meshtastic. Use at your
own risk.
</p>
</div>
<div className="flex justify-end">
<button
type="button"
className="text-xs text-slate-400 hover:text-white underline"
onClick={onReset}
disabled={pluginCount === 0}
>
Reset plugins
</button>
</div>
<div className="grid gap-2 md:grid-cols-2" key={`plugins-${selectedTarget}`}>
{Object.entries(registryData)
.sort(([, pluginA], [, pluginB]) => {
// Featured plugins first
const featuredA = pluginA.featured ?? false
const featuredB = pluginB.featured ?? false
if (featuredA !== featuredB) {
return featuredA ? -1 : 1
}
// Then alphabetical by name
return pluginA.name.localeCompare(pluginB.name)
})
.map(([slug, plugin]) => {
// Check if plugin is required by another explicitly selected plugin
const isRequired = isRequiredByOther(
slug,
explicitPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Plugin is implicit if it's either:
// 1. Not explicitly selected but is a dependency, OR
// 2. Explicitly selected but required by another explicitly selected plugin
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
// Check if this is the preselected plugin from URL
const isPreselected = pluginParam === slug
return (
<PluginCard
key={`${slug}-${selectedTarget}`}
variant="link-toggle"
id={slug}
name={plugin.name}
description={plugin.description}
imageUrl={plugin.imageUrl}
isEnabled={allEnabledPlugins.includes(slug)}
onToggle={enabled => onTogglePlugin(slug, enabled)}
disabled={isImplicit || isIncompatible || isPreselected}
enabledLabel={isPreselected ? "Locked" : isImplicit ? "Required" : "Add"}
incompatibleReason={isIncompatible ? "Not compatible with this target" : undefined}
featured={plugin.featured ?? false}
flashCount={pluginFlashCounts[slug] ?? 0}
homepage={plugin.homepage}
version={plugin.version}
repo={plugin.repo}
/>
)
})}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,76 @@
import { TARGETS } from "@/constants/targets"
type TargetGroup = (typeof TARGETS)[string] & { id: string }
interface TargetSelectorProps {
activeCategory: string
categories: string[]
groupedTargets: Record<string, TargetGroup[]>
selectedTarget: string
compatibleTargets: Set<string> | null
onCategoryChange: (category: string) => void
onTargetSelect: (targetId: string) => void
}
export function TargetSelector({
activeCategory,
categories,
groupedTargets,
selectedTarget,
compatibleTargets,
onCategoryChange,
onTargetSelect,
}: TargetSelectorProps) {
const targets = activeCategory ? groupedTargets[activeCategory] : []
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{categories.map(category => {
const isActive = activeCategory === category
return (
<button
key={category}
type="button"
onClick={() => onCategoryChange(category)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
isActive ? "bg-blue-600 text-white" : "bg-slate-800 text-slate-300 hover:bg-slate-700"
}`}
>
{category}
</button>
)
})}
</div>
<div className="bg-slate-950/60 p-4 rounded-lg border border-slate-800/60">
<div className="flex flex-wrap gap-2">
{targets?.map(target => {
const isSelected = selectedTarget === target.id
const normalizedId = target.id.replace(/[-_]/g, "")
const isCompatible =
!compatibleTargets || compatibleTargets.has(target.id) || compatibleTargets.has(normalizedId)
return (
<button
key={target.id}
type="button"
onClick={() => isCompatible && onTargetSelect(target.id)}
disabled={!isCompatible}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
isSelected
? "bg-cyan-600 text-white"
: isCompatible
? "bg-slate-800 text-slate-300 hover:bg-slate-700"
: "bg-slate-800/50 text-slate-500 cursor-not-allowed opacity-50"
}`}
>
{target.name}
</button>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import { VERSIONS } from "@/constants/versions"
interface VersionSelectorProps {
selectedVersion: string
onVersionChange: (version: string) => void
}
export function VersionSelector({ selectedVersion, onVersionChange }: VersionSelectorProps) {
return (
<div>
<label htmlFor="build-version" className="block text-sm font-medium mb-2">
Firmware version
</label>
<select
id="build-version"
value={selectedVersion}
onChange={event => onVersionChange(event.target.value)}
className="w-full h-10 px-3 rounded-md border border-slate-800 bg-slate-950 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:ring-offset-slate-950"
>
{VERSIONS.map(version => (
<option key={version} value={version}>
{version}
</option>
))}
</select>
</div>
)
}

View File

@@ -1,210 +1,210 @@
{
"betafpv2400txmicro": "esp32",
"betafpv900txnano": "esp32",
"betafpv_2400_tx_micro": "esp32",
"betafpv_900_tx_nano": "esp32",
"chatter2": "esp32",
"9m2ibraprsloratracker": "esp32",
"meshtasticdrdev": "esp32",
"9m2ibr_aprs_lora_tracker": "esp32",
"meshtastic-dr-dev": "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",
"meshtastic-diy-v1": "esp32",
"meshtastic-diy-v1_1": "esp32",
"hackerboxes-esp32-io": "esp32",
"heltec-v1": "esp32",
"heltec-v2_0": "esp32",
"heltec-v2_1": "esp32",
"heltec-wireless-bridge": "esp32",
"heltec-wsl-v2_1": "esp32",
"m5stack-core": "esp32",
"m5stack-coreink": "esp32",
"nano-g1": "esp32",
"nano-g1-explorer": "esp32",
"radiomaster_900_bandit": "esp32",
"radiomaster_900_bandit_micro": "esp32",
"radiomaster_900_bandit_nano": "esp32",
"rak11200": "esp32",
"stationg1": "esp32",
"station-g1": "esp32",
"tbeam": "esp32",
"tbeamdisplayshield": "tbeam",
"tbeam07": "esp32",
"tlorav1": "esp32",
"tlorav13": "esp32",
"tlorav2": "esp32",
"tlorav2116": "esp32",
"sugarcube": "tlorav2116",
"tlorav2116tcxo": "esp32",
"tlorav2118": "esp32",
"tlorav330tcxo": "esp32",
"tbeam-displayshield": "tbeam",
"tbeam0_7": "esp32",
"tlora-v1": "esp32",
"tlora_v1_3": "esp32",
"tlora-v2": "esp32",
"tlora-v2-1-1_6": "esp32",
"sugarcube": "tlora-v2-1-1_6",
"tlora-v2-1-1_6-tcxo": "esp32",
"tlora-v2-1-1_8": "esp32",
"tlora-v3-3-0-tcxo": "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",
"ai-c3": "esp32c3",
"esp32c3_super_mini": "esp32c3",
"esp32c3_base": "esp32",
"hackerboxes-esp32c3-oled": "esp32c3",
"heltec-ht62-esp32c3-sx1262": "esp32c3",
"heltec-hru-3601": "esp32c3",
"m5stack-stamp-c3": "esp32c3",
"esp32c6_base": "esp32",
"m5stack-unitc6l": "esp32c6",
"tlora-c6": "esp32c6",
"esp32s2_base": "esp32",
"nugget-s2-lora": "esp32s2",
"CDEBYTE_EoRa-S3": "esp32s3",
"EBYTE_ESP32-S3": "esp32s3",
"thinknode_m2": "esp32s3",
"thinknode_m5": "esp32s3",
"bpi_picow_esp32_s3": "esp32s3",
"crowpanel-esp32s3-5-epaper": "esp32s3",
"crowpanel-esp32s3-4-epaper": "esp32s3",
"crowpanel-esp32s3-2-epaper": "esp32s3",
"my-esp32s3-diy-eink": "esp32s3",
"my-esp32s3-diy-oled": "esp32s3",
"t-energy-s3_e22": "esp32s3",
"dreamcatcher-2206": "esp32s3",
"crowpanel_base": "crowpanel",
"elecrow-adv-24-28-tft": "crowpanel_small_esp32s3_base",
"elecrow-adv-35-tft": "crowpanel_small_esp32s3_base",
"elecrow-adv1-43-50-70-tft": "crowpanel_large_esp32s3_base",
"ESP32-S3-Pico": "esp32s3",
"esp32s3_base": "esp32",
"hackaday-communicator": "esp32s3",
"heltec_capsule_sensor_v3": "esp32s3",
"heltec_sensor_hub": "esp32s3",
"heltec-v3": "esp32s3",
"heltec_v4_base": "esp32s3",
"heltec-v4": "heltec_v4_base",
"heltec-v4-tft": "heltec_v4_base",
"heltec-vision-master-e213": "esp32s3",
"heltec-vision-master-e213-inkhud": "esp32s3",
"heltec-vision-master-e290": "esp32s3",
"heltec-vision-master-e290-inkhud": "esp32s3",
"heltec-vision-master-t190": "esp32s3",
"heltec-wireless-paper": "esp32s3",
"heltec-wireless-paper-inkhud": "esp32s3",
"heltec-wireless-paper-v1_0": "esp32s3",
"heltec-wireless-tracker": "esp32s3",
"heltec-wireless-tracker-V1-0": "esp32s3",
"heltec-wireless-tracker-v2": "esp32s3",
"heltec-wsl-v3": "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",
"link32-s3-v1": "esp32s3",
"m5stack-cores3": "esp32s3",
"mesh_tab_base": "esp32s3",
"mesh-tab-3-2-TN-resistive": "mesh_tab_base",
"mesh-tab-3-2-IPS-resistive": "mesh_tab_base",
"mesh-tab-3-5-IPS-resistive": "mesh_tab_base",
"mesh-tab-3-5-TN-resistive": "mesh_tab_base",
"mesh-tab-3-2-IPS-capacitive": "mesh_tab_base",
"mesh-tab-3-5-IPS-capacitive": "mesh_tab_base",
"mesh-tab-4-0-IPS-capacitive": "mesh_tab_base",
"nibble-esp32": "esp32s3",
"nugget-s3-lora": "esp32s3",
"picomputer-s3": "esp32s3",
"picomputer-s3-tft": "picomputer-s3",
"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",
"rak_wismesh_tap_v2-tft": "rak_wismeshtap_s3",
"seeed-sensecap-indicator": "esp32s3",
"seeed-sensecap-indicator-tft": "seeed-sensecap-indicator",
"seeed-xiao-s3": "esp32s3",
"station-g2": "esp32s3",
"t-deck": "esp32s3",
"t-deck-tft": "t-deck",
"t-deck-pro": "esp32s3",
"t-eth-elite": "esp32s3",
"t-watch-s3": "esp32s3",
"tbeam-s3-core": "esp32s3",
"tlora-pager": "esp32s3",
"tlora-pager-tft": "tlora-pager",
"tlora-t3s3-epaper": "esp32s3",
"tlora-t3s3-epaper-inkhud": "esp32s3",
"tlora-t3s3-v1": "esp32s3",
"tracksenger": "esp32s3",
"tracksengerlcd": "esp32s3",
"tracksengeroled": "esp32s3",
"tracksenger-lcd": "esp32s3",
"tracksenger-oled": "esp32s3",
"unphone": "esp32s3",
"unphonetft": "unphone",
"unphone-tft": "unphone",
"coverage": "native",
"buildroot": "portduino",
"pca10059diyeink": "nrf52840",
"thinknodem1": "nrf52840",
"thinknodem1inkhud": "nrf52840",
"thinknodem3": "nrf52840",
"thinknodem6": "nrf52840",
"ME25LS014Y10TD": "nrf52840",
"ME25LS014Y10TDeink": "nrf52840",
"pca10059_diy_eink": "nrf52840",
"thinknode_m1": "nrf52840",
"thinknode_m1-inkhud": "nrf52840",
"thinknode_m3": "nrf52840",
"thinknode_m6": "nrf52840",
"ME25LS01-4Y10TD": "nrf52840",
"ME25LS01-4Y10TD_e-ink": "nrf52840",
"ms24sf1": "nrf52840",
"makerpythonnrf52840sx1280eink": "nrf52840",
"makerpythonnrf52840sx1280oled": "nrf52840",
"TWCmeshv4": "nrf52840",
"makerpython_nrf52840_sx1280_eink": "nrf52840",
"makerpython_nrf52840_sx1280_oled": "nrf52840",
"TWC_mesh_v4": "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",
"nrf52_promicro_diy_tcxo": "nrf52840",
"nrf52_promicro_diy-inkhud": "nrf52840",
"seeed-xiao-nrf52840-wio-sx1262": "nrf52840",
"seeed_xiao_nrf52840_e22_900m30s": "seeed_xiao_nrf52840_kit",
"seeed_xiao_nrf52840_e22_900m33s": "seeed_xiao_nrf52840_kit",
"xiao_ble": "seeed_xiao_nrf52840_kit",
"feather_diy": "nrf52840",
"gat562_mesh_trial_tracker": "nrf52840",
"heltec-mesh-node-t114": "nrf52840",
"heltec-mesh-node-t114-inkhud": "nrf52840",
"heltec-mesh-pocket-5000": "nrf52840",
"heltec-mesh-pocket-5000-inkhud": "nrf52840",
"heltec-mesh-pocket-10000": "nrf52840",
"heltec-mesh-pocket-10000-inkhud": "nrf52840",
"heltec_mesh_solar_base": "nrf52840",
"heltec-mesh-solar": "heltec_mesh_solar_base",
"heltec-mesh-solar-eink": "heltec_mesh_solar_base",
"heltec-mesh-solar-inkhud": "heltec_mesh_solar_base",
"heltec-mesh-solar-oled": "heltec_mesh_solar_base",
"heltec-mesh-solar-tft": "heltec_mesh_solar_base",
"meshlink": "nrf52840",
"meshlinkeink": "nrf52840",
"meshlink_eink": "nrf52840",
"meshtiny": "nrf52840",
"monteopshw1": "nrf52840",
"muzibase": "nrf52840",
"nanog2ultra": "nrf52840",
"nrf52832base": "nrf52",
"nrf52840base": "nrf52",
"r1neo": "nrf52840",
"monteops_hw1": "nrf52840",
"muzi-base": "nrf52840",
"nano-g2-ultra": "nrf52840",
"nrf52832_base": "nrf52",
"nrf52840_base": "nrf52",
"r1-neo": "nrf52840",
"rak2560": "nrf52840",
"rak34011watt": "nrf52840",
"rak3401-1watt": "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",
"rak4631_dbg": "rak4631",
"rak4631_eink": "nrf52840",
"rak4631_eink_onrxtx": "nrf52840",
"rak4631_eth_gw": "nrf52840",
"rak4631_eth_gw_dbg": "rak4631",
"rak4631_nomadstar_meteor_pro": "nrf52840",
"rak4631_nomadstar_meteor_pro_dbg": "rak4631_nomadstar_meteor_pro",
"rak_wismeshtag": "nrf52840",
"rak_wismeshtap": "nrf52840",
"seeed_solar_node": "nrf52840",
"seeed_wio_tracker_L1": "nrf52840",
"seeed_wio_tracker_L1_eink": "nrf52840",
"seeed_wio_tracker_L1_eink-inkhud": "nrf52840",
"seeed_xiao_nrf52840_kit": "nrf52840",
"seeed_xiao_nrf52840_kit_i2c": "seeed_xiao_nrf52840_kit",
"t-echo": "nrf52840",
"t-echo-inkhud": "nrf52840",
"t-echo-lite": "nrf52840",
"tracker-t1000-e": "nrf52840",
"wio-sdk-wm1110": "nrf52840",
"wio-t1000-s": "nrf52840",
"wio-tracker-wm1110": "nrf52840",
"challenger_2040_lora": "rp2040",
"catsniffer": "rp2040",
"featherrp2040rfm95": "rp2040",
"nibblerp2040": "rp2040",
"feather_rp2040_rfm95": "rp2040",
"nibble-rp2040": "rp2040",
"rak11310": "rp2040",
"rp2040lora": "rp2040",
"rp2040-lora": "rp2040",
"pico": "rp2040",
"picoslowclock": "rp2040",
"pico_slowclock": "rp2040",
"picow": "rp2040",
"senselorarp2040": "rp2040",
"senselora_rp2040": "rp2040",
"pico2": "rp2350",
"pico2w": "rp2350",
"CDEBYTEE77MBL": "stm32",
"CDEBYTE_E77-MBL": "stm32",
"rak3172": "stm32",
"wioe5": "stm32",
"wio-e5": "stm32",
"esp32c3": "esp32",
"esp32c6": "esp32",
"esp32s2": "esp32",
@@ -217,4 +217,4 @@
"rp2350": null,
"stm32": null,
"portduino": null
}
}

View File

@@ -1,4 +1,5 @@
import hardwareList from "@/vendor/web-flasher/public/data/hardware-list.json"
import architectureHierarchy from "@/constants/architecture-hierarchy.json"
import vendorsData from "@/constants/vendors.json"
export interface TargetMetadata {
name: string
@@ -6,17 +7,43 @@ export interface TargetMetadata {
architecture?: string
}
/**
* Trace a target back to its base architecture
*/
function getBaseArchitecture(target: string): string | null {
const parentMap = architectureHierarchy as Record<string, string | null>
const visited = new Set<string>()
let current: string | null = target
while (current && !visited.has(current)) {
visited.add(current)
if (!current) break
const parent: string | null | undefined = parentMap[current]
if (parent === null) {
return current
}
if (parent === undefined) {
return current
}
current = parent
}
return current || target
}
export const TARGETS: Record<string, TargetMetadata> = {}
// Sort by display name
const sortedHardware = [...hardwareList].sort((a, b) => (a.displayName || "").localeCompare(b.displayName || ""))
sortedHardware.forEach(hw => {
if (hw.platformioTarget) {
TARGETS[hw.platformioTarget] = {
name: hw.displayName || hw.platformioTarget,
category: hw.tags?.[0] || "Other",
architecture: hw.architecture,
// Build TARGETS from vendors.json and architecture-hierarchy.json
for (const [vendor, models] of Object.entries(vendorsData)) {
for (const [modelName, target] of Object.entries(models)) {
const architecture = getBaseArchitecture(target)
TARGETS[target] = {
name: modelName,
category: vendor,
architecture: architecture || undefined,
}
}
})
}

244
constants/vendors.json Normal file
View File

@@ -0,0 +1,244 @@
{
"B&Q": {
"Nano G1": "nano-g1",
"Nano G1 Explorer": "nano-g1-explorer",
"Nano G2 Ultra": "nano-g2-ultra",
"Station G1": "station-g1",
"Station G2": "station-g2"
},
"BetaFPV": {
"2400TX Micro": "betafpv_2400_tx_micro",
"900TX Nano": "betafpv_900_tx_nano"
},
"Canary": {
"One": "canaryone"
},
"CDEByte": {
"EoRa S3": "CDEBYTE_EoRa-S3",
"E77MBL": "CDEBYTE_E77-MBL"
},
"DIY": {
"DR-DEV": "meshtastic-dr-dev",
"Hydra": "hydra",
"V1": "meshtastic-diy-v1",
"V1.1": "meshtastic-diy-v1_1",
"NRF52 Pro-micro DIY": "nrf52_promicro_diy_tcxo",
"NRF52 Pro-micro DIY InkHUD": "nrf52_promicro_diy-inkhud",
"PCA10059 DIY E-Ink": "pca10059_diy_eink"
},
"EByte": {
"ESP32-S3": "EBYTE_ESP32-S3"
},
"Elecrow": {
"Crowpanel Adv 2.4/2.8 TFT": "elecrow-adv-24-28-tft",
"Crowpanel Adv 3.5 TFT": "elecrow-adv-35-tft",
"Crowpanel Adv 4.3/5.0/7.0 TFT": "elecrow-adv1-43-50-70-tft",
"ThinkNode M1": "thinknode_m1",
"ThinkNode M2": "thinknode_m2",
"ThinkNode M3": "thinknode_m3",
"ThinkNode M5": "thinknode_m5",
"ThinkNode M6": "thinknode_m6"
},
"Heltec": {
"V1": "heltec-v1",
"V2.0": "heltec-v2_0",
"V2.1": "heltec-v2_1",
"V3": "heltec-v3",
"V4": "heltec-v4",
"V4 TFT": "heltec-v4-tft",
"HT62": "heltec-ht62-esp32c3-sx1262",
"RU3601": "heltec-hru-3601",
"Wireless Bridge": "heltec-wireless-bridge",
"Wireless Stick Lite V2.1": "heltec-wsl-v2_1",
"Wireless Stick Lite V3": "heltec-wsl-v3",
"Wireless Paper": "heltec-wireless-paper",
"Wireless Paper InkHUD": "heltec-wireless-paper-inkhud",
"Wireless Paper V1.0": "heltec-wireless-paper-v1_0",
"Wireless Tracker": "heltec-wireless-tracker",
"Wireless Tracker V1.0": "heltec-wireless-tracker-V1-0",
"Wireless Tracker V2": "heltec-wireless-tracker-v2",
"Vision Master E213": "heltec-vision-master-e213",
"Vision Master E213 InkHUD": "heltec-vision-master-e213-inkhud",
"Vision Master E290": "heltec-vision-master-e290",
"Vision Master E290 InkHUD": "heltec-vision-master-e290-inkhud",
"Vision Master T190": "heltec-vision-master-t190",
"Capsule Sensor V3": "heltec_capsule_sensor_v3",
"Sensor Hub": "heltec_sensor_hub",
"Mesh Node T114": "heltec-mesh-node-t114",
"Mesh Node T114 InkHUD": "heltec-mesh-node-t114-inkhud",
"MeshPocket 5000": "heltec-mesh-pocket-5000",
"MeshPocket 5000 InkHUD": "heltec-mesh-pocket-5000-inkhud",
"MeshPocket 10000": "heltec-mesh-pocket-10000",
"MeshPocket 10000 InkHUD": "heltec-mesh-pocket-10000-inkhud",
"MeshSolar": "heltec-mesh-solar",
"MeshSolar E-Ink": "heltec-mesh-solar-eink",
"MeshSolar InkHUD": "heltec-mesh-solar-inkhud",
"MeshSolar OLED": "heltec-mesh-solar-oled",
"MeshSolar TFT": "heltec-mesh-solar-tft"
},
"HackerBoxes": {
"ESP32 IO": "hackerboxes-esp32-io",
"ESP32-C3 OLED": "hackerboxes-esp32c3-oled"
},
"LilyGo": {
"T-Beam": "tbeam",
"T-Beam Display Shield": "tbeam-displayshield",
"T-Beam V0.7": "tbeam0_7",
"T-Beam S3 Core": "tbeam-s3-core",
"T-Deck": "t-deck",
"T-Deck TFT": "t-deck-tft",
"T-Deck Pro": "t-deck-pro",
"T-Echo": "t-echo",
"T-Echo InkHUD": "t-echo-inkhud",
"T-Echo Lite": "t-echo-lite",
"T-LoRa V1": "tlora-v1",
"T-LoRa V1.3": "tlora_v1_3",
"T-LoRa V2": "tlora-v2",
"T-LoRa V2.1-1.6": "tlora-v2-1-1_6",
"T-LoRa V2.1-1.6 TCXO": "tlora-v2-1-1_6-tcxo",
"T-LoRa V2.1-1.8": "tlora-v2-1-1_8",
"T-LoRa V3.3.0 TCXO": "tlora-v3-3-0-tcxo",
"T-LoRa C6": "tlora-c6",
"T-LoRa Pager": "tlora-pager",
"T-LoRa Pager TFT": "tlora-pager-tft",
"T-LoRa T3-S3": "tlora-t3s3-v1",
"T-LoRa T3-S3 E-Paper": "tlora-t3s3-epaper",
"T-LoRa T3-S3 E-Paper InkHUD": "tlora-t3s3-epaper-inkhud",
"T-Watch S3": "t-watch-s3",
"Sugar Cube": "sugarcube"
},
"M5Stack": {
"Core": "m5stack-core",
"Core Ink": "m5stack-coreink",
"Core S3": "m5stack-cores3",
"Stamp C3": "m5stack-stamp-c3",
"Unit C6L": "m5stack-unitc6l"
},
"MakerPython": {
"NRF52840 SX1280 E-Ink": "makerpython_nrf52840_sx1280_eink",
"NRF52840 SX1280 OLED": "makerpython_nrf52840_sx1280_oled"
},
"muzi": {
"R1 Neo": "r1-neo"
},
"NomadStar": {
"Meteor Pro": "rak4631_nomadstar_meteor_pro",
"Meteor Pro Debug": "rak4631_nomadstar_meteor_pro_dbg"
},
"RAK": {
"WisBlock 11200": "rak11200",
"WisBlock 11310": "rak11310",
"WisBlock 4631": "rak4631",
"WisBlock 4631 Debug": "rak4631_dbg",
"WisBlock 4631 E-Ink": "rak4631_eink",
"WisBlock 4631 E-Ink on RX/TX": "rak4631_eink_onrxtx",
"WisBlock 4631 ETH Gateway": "rak4631_eth_gw",
"WisBlock 4631 ETH Gateway Debug": "rak4631_eth_gw_dbg",
"WisBlock 3312": "rak3312",
"WisBlock 3172": "rak3172",
"WisBlock 2560": "rak2560",
"WisBlock 3401 1Watt": "rak3401-1watt",
"WisMesh Tag": "rak_wismeshtag",
"WisMesh Tap": "rak_wismeshtap",
"WisMesh Tap V2 TFT": "rak_wismesh_tap_v2-tft"
},
"RadioMaster": {
"900 Bandit": "radiomaster_900_bandit",
"900 Bandit Micro": "radiomaster_900_bandit_micro",
"900 Bandit Nano": "radiomaster_900_bandit_nano"
},
"RPi": {
"Pico": "pico",
"Pico Slow Clock": "pico_slowclock",
"Pico W": "picow",
"Pico 2": "pico2",
"Pico 2W": "pico2w"
},
"Seeed": {
"Xiao ESP32-S3": "seeed-xiao-s3",
"Xiao NRF52840 Kit": "seeed_xiao_nrf52840_kit",
"Xiao NRF52840 Kit I2C": "seeed_xiao_nrf52840_kit_i2c",
"Xiao NRF52840 WIO SX1262": "seeed-xiao-nrf52840-wio-sx1262",
"Xiao NRF52840 E22900M30S": "seeed_xiao_nrf52840_e22_900m30s",
"Xiao NRF52840 E22900M33S": "seeed_xiao_nrf52840_e22_900m33s",
"Xiao BLE": "xiao_ble",
"Wio Tracker L1": "seeed_wio_tracker_L1",
"Wio Tracker L1 E-Ink": "seeed_wio_tracker_L1_eink",
"Wio Tracker L1 E-Ink InkHUD": "seeed_wio_tracker_L1_eink-inkhud",
"Wio Tracker WM1110": "wio-tracker-wm1110",
"Wio SDK WM1110": "wio-sdk-wm1110",
"Wio T1000S": "wio-t1000-s",
"SenseCAP Indicator": "seeed-sensecap-indicator",
"SenseCAP Indicator TFT": "seeed-sensecap-indicator-tft",
"Solar Node": "seeed_solar_node"
},
"Waveshare": {
"RP2040 LoRa": "rp2040-lora"
},
"Other": {
"Chatter2": "chatter2",
"9M2 IBRA PRS LoRa Tracker": "9m2ibr_aprs_lora_tracker",
"AIC3": "ai-c3",
"ESP32-C3": "esp32c3",
"ESP32-C3 Super Mini": "esp32c3_super_mini",
"ESP32-C6": "esp32c6",
"ESP32-S2": "esp32s2",
"ESP32-S3": "esp32s3",
"ESP32-S3 Pico": "ESP32-S3-Pico",
"Crowpanel ESP32-S3 2 E-Paper": "crowpanel-esp32s3-2-epaper",
"Crowpanel ESP32-S3 4 E-Paper": "crowpanel-esp32s3-4-epaper",
"Crowpanel ESP32-S3 5 E-Paper": "crowpanel-esp32s3-5-epaper",
"BPi Pico W ESP32-S3": "bpi_picow_esp32_s3",
"My ESP32-S3 DIY E-Ink": "my-esp32s3-diy-eink",
"My ESP32-S3 DIY OLED": "my-esp32s3-diy-oled",
"T-Energy S3 E22": "t-energy-s3_e22",
"Dreamcatcher 2206": "dreamcatcher-2206",
"Hackaday Communicator": "hackaday-communicator",
"Icarus": "icarus",
"Link32S3 V1": "link32-s3-v1",
"MeshTab 32 TN Resistive": "mesh-tab-3-2-TN-resistive",
"MeshTab 32 IPS Resistive": "mesh-tab-3-2-IPS-resistive",
"MeshTab 35 IPS Resistive": "mesh-tab-3-5-IPS-resistive",
"MeshTab 35 TN Resistive": "mesh-tab-3-5-TN-resistive",
"MeshTab 32 IPS Capacitive": "mesh-tab-3-2-IPS-capacitive",
"MeshTab 35 IPS Capacitive": "mesh-tab-3-5-IPS-capacitive",
"MeshTab 40 IPS Capacitive": "mesh-tab-4-0-IPS-capacitive",
"Nibble ESP32": "nibble-esp32",
"Nugget S2 LoRa": "nugget-s2-lora",
"Nugget S3 LoRa": "nugget-s3-lora",
"Pi Computer S3": "picomputer-s3",
"Pi Computer S3 TFT": "picomputer-s3-tft",
"T-Echo Lite": "t-eth-elite",
"Tracksenger": "tracksenger",
"Tracksenger LCD": "tracksenger-lcd",
"Tracksenger OLED": "tracksenger-oled",
"unPhone": "unphone",
"unPhone TFT": "unphone-tft",
"NRF52832": "nrf52832",
"NRF52840": "nrf52840",
"ThinkNode M1 InkHUD": "thinknode_m1-inkhud",
"ME25LS014Y10TD": "ME25LS01-4Y10TD",
"ME25LS014Y10TD E-Ink": "ME25LS01-4Y10TD_e-ink",
"MS24SF1": "ms24sf1",
"TWC Mesh V4": "TWC_mesh_v4",
"WashTastic": "WashTastic",
"Feather DIY": "feather_diy",
"Feather RP2040 RFM95": "feather_rp2040_rfm95",
"GAT562 Mesh Trial Tracker": "gat562_mesh_trial_tracker",
"MeshLink": "meshlink",
"MeshLink E-Ink": "meshlink_eink",
"MeshTiny": "meshtiny",
"MonteOps HW1": "monteops_hw1",
"R1 Neo": "r1-neo",
"Tracker T1000E": "tracker-t1000-e",
"Tracker D": "trackerd",
"WiPhone": "wiphone",
"Challenger 2040 LoRa": "challenger_2040_lora",
"Cat Sniffer": "catsniffer",
"Nibbler RP2040": "nibble-rp2040",
"SenseLoRa RP2040": "senselora_rp2040",
"Wio E5": "wio-e5",
"Coverage": "coverage",
"Buildroot": "buildroot"
}
}

View File

@@ -3,15 +3,10 @@ import { v } from "convex/values"
import { api, internal } from "./_generated/api"
import type { Doc, Id } from "./_generated/dataModel"
import { internalMutation, mutation, query } from "./_generated/server"
import { getArtifactFilenameBase } from "./lib/filename"
import { ArtifactType, getArtifactFilenameBase } from "./lib/filename"
import { generateSignedDownloadUrl } from "./lib/r2"
import { buildFields } from "./schema"
export enum ArtifactType {
Firmware = "firmware",
Source = "source",
}
type BuildUpdateData = {
status: string
completedAt?: number

View File

@@ -1,3 +1,12 @@
/**
* Artifact type enumeration.
* Safe to import in client-side code.
*/
export enum ArtifactType {
Firmware = "firmware",
Source = "source",
}
/**
* Generates the artifact filename base (without extension) matching the download filename format.
* Format: meshtastic-{version}-{target}-{last4hash}-{jobId}-{artifactType}

View File

@@ -0,0 +1,74 @@
import { TARGETS } from "@/constants/targets"
import { getTargetsCompatibleWithIncludes } from "@/lib/utils"
import registryData from "@/public/registry.json"
type TargetGroup = (typeof TARGETS)[string] & { id: string }
const GROUPED_TARGETS = Object.entries(TARGETS).reduce(
(acc, [id, meta]) => {
const category = meta.category || "Other"
if (!acc[category]) acc[category] = []
acc[category].push({ id, ...meta })
return acc
},
{} as Record<string, TargetGroup[]>
)
export function usePluginCompatibility(enabledPlugins: string[], preselectedPlugin?: { includes?: string[] } | null) {
// Start with preselected plugin compatibility if present
let compatibleTargets: Set<string> | null = preselectedPlugin?.includes
? getTargetsCompatibleWithIncludes(preselectedPlugin.includes)
: null
// Intersect with compatibility of all enabled plugins
if (enabledPlugins.length > 0) {
const pluginRegistry = registryData as Record<string, { includes?: string[] }>
const allCompatibleSets: Set<string>[] = []
for (const pluginId of enabledPlugins) {
const plugin = pluginRegistry[pluginId]
if (plugin?.includes && plugin.includes.length > 0) {
allCompatibleSets.push(getTargetsCompatibleWithIncludes(plugin.includes))
}
}
if (allCompatibleSets.length > 0) {
if (compatibleTargets) {
compatibleTargets = new Set(
Array.from(compatibleTargets).filter(target => allCompatibleSets.every(set => set.has(target)))
)
} else {
compatibleTargets = allCompatibleSets[0]
for (let i = 1; i < allCompatibleSets.length; i++) {
compatibleTargets = new Set(Array.from(compatibleTargets).filter(target => allCompatibleSets[i].has(target)))
}
}
} else if (!compatibleTargets) {
compatibleTargets = null
}
}
const filteredGroupedTargets = compatibleTargets
? Object.entries(GROUPED_TARGETS).reduce(
(acc, [category, targets]) => {
const filtered = targets.filter(target => {
const normalizedId = target.id.replace(/[-_]/g, "")
return compatibleTargets.has(target.id) || compatibleTargets.has(normalizedId)
})
if (filtered.length > 0) {
acc[category] = filtered
}
return acc
},
{} as Record<string, TargetGroup[]>
)
: GROUPED_TARGETS
const filteredTargetCategories = Object.keys(filteredGroupedTargets).sort((a, b) => a.localeCompare(b))
return {
compatibleTargets,
filteredGroupedTargets,
filteredTargetCategories,
}
}

215
hooks/useTargetSelection.ts Normal file
View File

@@ -0,0 +1,215 @@
import { TARGETS } from "@/constants/targets"
import { useEffect, useState } from "react"
type TargetGroup = (typeof TARGETS)[string] & { id: string }
const GROUPED_TARGETS = Object.entries(TARGETS).reduce(
(acc, [id, meta]) => {
const category = meta.category || "Other"
if (!acc[category]) acc[category] = []
acc[category].push({ id, ...meta })
return acc
},
{} as Record<string, TargetGroup[]>
)
const TARGET_CATEGORIES = Object.keys(GROUPED_TARGETS).sort((a, b) => a.localeCompare(b))
const DEFAULT_TARGET =
TARGET_CATEGORIES.length > 0 && GROUPED_TARGETS[TARGET_CATEGORIES[0]]?.length
? GROUPED_TARGETS[TARGET_CATEGORIES[0]][0].id
: ""
const STORAGE_KEY = "quick_build_target"
const getStorageKeyForCategory = (category: string) => `quick_build_target_${category}`
export function useTargetSelection(
compatibleTargets: Set<string> | null,
filteredGroupedTargets: Record<string, TargetGroup[]>,
filteredTargetCategories: string[]
) {
const [activeCategory, setActiveCategory] = useState<string>(TARGET_CATEGORIES[0] ?? "")
const [selectedTarget, setSelectedTarget] = useState<string>(DEFAULT_TARGET)
const persistTargetSelection = (targetId: string, category?: string) => {
if (typeof window === "undefined") return
try {
window.localStorage.setItem(STORAGE_KEY, targetId)
if (category) {
window.localStorage.setItem(getStorageKeyForCategory(category), targetId)
}
} catch (error) {
console.error("Failed to persist target selection", error)
}
}
const getSavedTargetForCategory = (category: string): string | null => {
if (typeof window === "undefined") return null
try {
return window.localStorage.getItem(getStorageKeyForCategory(category))
} catch (error) {
console.error("Failed to read saved target for category", error)
return null
}
}
const handleSelectTarget = (targetId: string) => {
if (compatibleTargets) {
const normalizedId = targetId.replace(/[-_]/g, "")
const isCompatible = compatibleTargets.has(targetId) || compatibleTargets.has(normalizedId)
if (!isCompatible) {
return
}
}
setSelectedTarget(targetId)
const category = TARGETS[targetId]?.category || "Other"
persistTargetSelection(targetId, category)
if (category && TARGET_CATEGORIES.includes(category)) {
setActiveCategory(category)
}
}
// Initialize active category
useEffect(() => {
const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES
if (!activeCategory && categories.length > 0) {
setActiveCategory(categories[0])
}
}, [activeCategory, compatibleTargets, filteredTargetCategories])
// Handle category change - auto-select target
useEffect(() => {
if (activeCategory) {
const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS
const categoryTargets = targets[activeCategory] || []
if (categoryTargets.length === 0) return
const isCurrentTargetInCategory = categoryTargets.some(t => t.id === selectedTarget)
if (!isCurrentTargetInCategory) {
const savedTargetForCategory = getSavedTargetForCategory(activeCategory)
const isValidSavedTarget = savedTargetForCategory && categoryTargets.some(t => t.id === savedTargetForCategory)
if (isValidSavedTarget) {
setSelectedTarget(savedTargetForCategory)
persistTargetSelection(savedTargetForCategory, activeCategory)
} else {
const firstTarget = categoryTargets[0].id
setSelectedTarget(firstTarget)
persistTargetSelection(firstTarget, activeCategory)
}
}
}
}, [activeCategory, compatibleTargets, filteredGroupedTargets, selectedTarget])
// Restore saved target on mount
useEffect(() => {
if (typeof window === "undefined") return
try {
const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS
const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES
if (categories.length === 0) return
const savedTarget = localStorage.getItem(STORAGE_KEY)
if (savedTarget && TARGETS[savedTarget]) {
const isCompatible = Object.values(targets).some(categoryTargets =>
categoryTargets.some(target => target.id === savedTarget)
)
if (isCompatible) {
const category = TARGETS[savedTarget].category || "Other"
if (categories.includes(category)) {
setActiveCategory(category)
setSelectedTarget(savedTarget)
persistTargetSelection(savedTarget, category)
return
}
}
}
const firstCategory = categories[0]
const categoryTargets = targets[firstCategory] || []
if (categoryTargets.length > 0) {
const savedTargetForCategory = getSavedTargetForCategory(firstCategory)
const isValidSavedTarget = savedTargetForCategory && categoryTargets.some(t => t.id === savedTargetForCategory)
if (isValidSavedTarget) {
setActiveCategory(firstCategory)
setSelectedTarget(savedTargetForCategory)
persistTargetSelection(savedTargetForCategory, firstCategory)
} else {
const firstTarget = categoryTargets[0].id
setActiveCategory(firstCategory)
setSelectedTarget(firstTarget)
persistTargetSelection(firstTarget, firstCategory)
}
}
} catch (error) {
console.error("Failed to read saved target", error)
}
}, [compatibleTargets, filteredGroupedTargets, filteredTargetCategories])
// Update selected target if it becomes incompatible
useEffect(() => {
if (!selectedTarget || !compatibleTargets) return
const normalizedId = selectedTarget.replace(/[-_]/g, "")
const isCompatible = compatibleTargets.has(selectedTarget) || compatibleTargets.has(normalizedId)
if (!isCompatible) {
const targets = filteredGroupedTargets
const categories = filteredTargetCategories
if (categories.length > 0) {
const currentCategory = TARGETS[selectedTarget]?.category
if (currentCategory && targets[currentCategory] && targets[currentCategory].length > 0) {
const savedTargetForCategory = getSavedTargetForCategory(currentCategory)
const isValidSavedTarget =
savedTargetForCategory && targets[currentCategory].some(t => t.id === savedTargetForCategory)
if (isValidSavedTarget) {
setSelectedTarget(savedTargetForCategory)
persistTargetSelection(savedTargetForCategory, currentCategory)
return
}
setSelectedTarget(targets[currentCategory][0].id)
persistTargetSelection(targets[currentCategory][0].id, currentCategory)
return
}
const firstCategory = categories[0]
const firstTarget = targets[firstCategory]?.[0]?.id
if (firstTarget) {
setSelectedTarget(firstTarget)
setActiveCategory(firstCategory)
persistTargetSelection(firstTarget, firstCategory)
}
}
}
}, [compatibleTargets, filteredGroupedTargets, filteredTargetCategories, selectedTarget])
// Initialize storage
useEffect(() => {
if (typeof window === "undefined" || !selectedTarget) return
try {
if (!window.localStorage.getItem(STORAGE_KEY)) {
window.localStorage.setItem(STORAGE_KEY, selectedTarget)
}
} catch (error) {
console.error("Failed to initialize target storage", error)
}
}, [selectedTarget])
return {
activeCategory,
selectedTarget,
setActiveCategory,
handleSelectTarget,
GROUPED_TARGETS: GROUPED_TARGETS as Record<string, TargetGroup[]>,
TARGET_CATEGORIES,
}
}

View File

@@ -127,30 +127,19 @@ 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
let current: string | null = name
while (current && !visited.has(current)) {
visited.add(current)
const parent = parentMap[current]
if (!current) break
const parent: string | null | undefined = parentMap[current]
// If parent is null, we've reached a base architecture
if (parent === null) {
@@ -162,11 +151,11 @@ export function getBaseArchitecture(name: string): string | null {
return current
}
current = normalizeArchitecture(parent)
current = parent
}
// Circular reference or unknown, return the last known
return current || normalized
return current || name
}
/**
@@ -174,17 +163,16 @@ export function getBaseArchitecture(name: string): string | null {
* (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 compatible = [arch]
const visited = new Set<string>()
let current = normalized
let current: string | null = arch
// Follow parent chain up to base architecture
while (current && !visited.has(current)) {
visited.add(current)
const parent = parentMap[current]
if (!current) break
const parent: string | null | undefined = parentMap[current]
if (parent === null) {
// Reached base architecture
@@ -196,12 +184,11 @@ export function getCompatibleArchitectures(arch: string): string[] {
break
}
const normalizedParent = normalizeArchitecture(parent)
if (!compatible.includes(normalizedParent)) {
compatible.push(normalizedParent)
if (!compatible.includes(parent)) {
compatible.push(parent)
}
current = normalizedParent
current = parent
}
return compatible
@@ -227,18 +214,16 @@ export function isPluginCompatibleWithTarget(
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 compatibleNames = new Set<string>([targetName])
const visited = new Set<string>()
let current = normalizedTarget
let current: string | null = targetName
// Follow parent chain (all keys and values in parentMap are already normalized)
// Follow parent chain
while (current && !visited.has(current)) {
visited.add(current)
const parent = parentMap[current]
if (!current) break
const parent: string | null | undefined = parentMap[current]
if (parent === null) {
// Reached base architecture
@@ -251,30 +236,21 @@ export function isPluginCompatibleWithTarget(
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)
})
const isExcluded = pluginExcludes.some(exclude => compatibleNames.has(exclude))
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)
})
return pluginIncludes.some(include => compatibleNames.has(include))
}
// If no includes/excludes specified, assume compatible with all (backward compatible)
@@ -301,34 +277,30 @@ export function isPluginCompatibleWithArchitecture(
export function getTargetsCompatibleWithIncludes(includes: string[]): Set<string> {
const parentMap = PARENT_MAP as Record<string, string | null>
const compatibleTargets = new Set<string>()
// Normalize includes
const normalizedIncludes = new Set(includes.map(include => normalizeArchitecture(include)))
const includesSet = new Set(includes)
// For each target in the parent map, check if it or any of its ancestors match the includes
for (const target of Object.keys(parentMap)) {
const normalizedTarget = normalizeArchitecture(target)
const visited = new Set<string>()
let current: string | null = normalizedTarget
let current: string | null = target
// Trace up the parent chain
while (current && !visited.has(current)) {
visited.add(current)
if (!current) break
// Check if current matches any of the includes
if (normalizedIncludes.has(current)) {
// Add both the normalized version and the original (for matching against TARGETS)
compatibleTargets.add(normalizedTarget)
if (includesSet.has(current)) {
compatibleTargets.add(target)
break
}
// Move to parent
const parentValue = parentMap[current]
if (parentValue === null || parentValue === undefined) {
const parent: string | null | undefined = parentMap[current]
if (parent === null || parent === undefined) {
break
}
current = normalizeArchitecture(parentValue)
current = parent
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "mesh-forge",
"version": "0.3.0",
"version": "0.4.0",
"private": true,
"author": "benallfree",
"license": "MIT",

View File

@@ -1,3 +1,4 @@
import Builder from "@/components/Builder"
import { BuildProgress } from "@/components/BuildProgress"
import { GiscusComments } from "@/components/GiscusComments"
import { api } from "@/convex/_generated/api"
@@ -6,29 +7,27 @@ import { Loader2 } from "lucide-react"
import { toast } from "sonner"
import { usePageContext } from "vike-react/usePageContext"
export default function BuildProgressPage() {
export default function BuildsPage() {
const pageContext = usePageContext()
const buildHash = pageContext.routeParams?.buildHash as string | undefined
const build = useQuery(api.builds.getByHash, buildHash ? { buildHash } : "skip")
const urlSearchParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null
const cloneHash = urlSearchParams?.get("clone")
const buildId = urlSearchParams?.get("id")
const pluginParam = urlSearchParams?.get("plugin")
// If we have a build ID, show the build progress page
if (buildId) {
return <BuildViewPage buildHash={buildId} />
}
// Otherwise, show the builder (handles clone and plugin params)
return <Builder cloneHash={cloneHash || undefined} pluginParam={pluginParam || undefined} />
}
function BuildViewPage({ buildHash }: { buildHash: string }) {
const build = useQuery(api.builds.getByHash, { buildHash })
const isAdmin = useQuery(api.admin.isAdmin)
const retryBuild = useMutation(api.admin.retryBuild)
if (!buildHash) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto">
<p className="text-slate-300">
Build hash missing.{" "}
<a href="/builds/new" className="text-cyan-400">
Start a new build
</a>
.
</p>
</div>
</div>
)
}
if (build === undefined) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8 flex items-center justify-center">

View File

@@ -1,5 +0,0 @@
import Builder from "./Builder"
export default function BuildNew() {
return <Builder />
}

View File

@@ -1,5 +0,0 @@
import Builder from "../Builder"
export default function BuildNew() {
return <Builder />
}

View File

@@ -1,857 +0,0 @@
import { ModuleToggle } from "@/components/ModuleToggle"
import { PluginCard } from "@/components/PluginCard"
import { Button } from "@/components/ui/button"
import { TARGETS } from "@/constants/targets"
import { VERSIONS } from "@/constants/versions"
import { api } from "@/convex/_generated/api"
import modulesData from "@/convex/modules.json"
import {
getDependedPlugins,
getImplicitDependencies,
getTargetsCompatibleWithIncludes,
isPluginCompatibleWithTarget,
isRequiredByOther,
} from "@/lib/utils"
import registryData from "@/public/registry.json"
import { useMutation, useQuery } from "convex/react"
import { CheckCircle2, ChevronDown, ChevronRight, Loader2 } from "lucide-react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { usePageContext } from "vike-react/usePageContext"
import { navigate } from "vike/client/router"
type TargetGroup = (typeof TARGETS)[string] & { id: string }
const GROUPED_TARGETS = Object.entries(TARGETS).reduce(
(acc, [id, meta]) => {
const category = meta.category || "Other"
if (!acc[category]) acc[category] = []
acc[category].push({ id, ...meta })
return acc
},
{} as Record<string, TargetGroup[]>
)
const TARGET_CATEGORIES = Object.keys(GROUPED_TARGETS).sort((a, b) => a.localeCompare(b))
const DEFAULT_TARGET =
TARGET_CATEGORIES.length > 0 && GROUPED_TARGETS[TARGET_CATEGORIES[0]]?.length
? GROUPED_TARGETS[TARGET_CATEGORIES[0]][0].id
: ""
export default function BuildNew() {
const pageContext = usePageContext()
const buildHashParam = pageContext.routeParams?.buildHash
const ensureBuildFromConfig = useMutation(api.builds.ensureBuildFromConfig)
const pluginFlashCounts = useQuery(api.plugins.getAll) ?? {}
const sharedBuild = useQuery(api.builds.getByHash, buildHashParam ? { buildHash: buildHashParam } : "skip")
// Get plugin from URL query parameter
const pluginParam = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("plugin") : null
const preselectedPlugin =
pluginParam && pluginParam in registryData
? (
registryData as Record<
string,
{ includes?: string[]; name: string; description: string; imageUrl?: string; featured?: boolean }
>
)[pluginParam]
: null
const STORAGE_KEY = "quick_build_target"
const getStorageKeyForCategory = (category: string) => `quick_build_target_${category}`
const persistTargetSelection = (targetId: string, category?: string) => {
if (typeof window === "undefined") return
try {
// Store global most recent selection
window.localStorage.setItem(STORAGE_KEY, targetId)
// Store per-brand selection if category provided
if (category) {
window.localStorage.setItem(getStorageKeyForCategory(category), targetId)
}
} catch (error) {
console.error("Failed to persist target selection", error)
}
}
const getSavedTargetForCategory = (category: string): string | null => {
if (typeof window === "undefined") return null
try {
return window.localStorage.getItem(getStorageKeyForCategory(category))
} catch (error) {
console.error("Failed to read saved target for category", error)
return null
}
}
const [activeCategory, setActiveCategory] = useState<string>(TARGET_CATEGORIES[0] ?? "")
const [selectedTarget, setSelectedTarget] = useState<string>(DEFAULT_TARGET)
const [selectedVersion, setSelectedVersion] = useState<string>(VERSIONS[0])
const [moduleConfig, setModuleConfig] = useState<Record<string, boolean>>({})
const [pluginConfig, setPluginConfig] = useState<Record<string, boolean>>({})
const [isFlashing, setIsFlashing] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [showModuleOverrides, setShowModuleOverrides] = useState(false)
const [showPlugins, setShowPlugins] = useState(true)
const [isLoadingSharedBuild, setIsLoadingSharedBuild] = useState(false)
// Get all enabled plugins
const enabledPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
// Filter targets based on plugin compatibility
// Start with preselected plugin compatibility if present
let compatibleTargets: Set<string> | null = preselectedPlugin?.includes
? getTargetsCompatibleWithIncludes(preselectedPlugin.includes)
: null
// Intersect with compatibility of all enabled plugins
if (enabledPlugins.length > 0) {
const pluginRegistry = registryData as Record<string, { includes?: string[] }>
const allCompatibleSets: Set<string>[] = []
// Get compatible targets for each enabled plugin
for (const pluginId of enabledPlugins) {
const plugin = pluginRegistry[pluginId]
if (plugin?.includes && plugin.includes.length > 0) {
// Plugin has includes - get compatible targets
allCompatibleSets.push(getTargetsCompatibleWithIncludes(plugin.includes))
}
// If plugin has no includes, it's compatible with all targets (don't add to set)
}
// If we have compatible sets, find intersection
if (allCompatibleSets.length > 0) {
if (compatibleTargets) {
// Intersect with preselected plugin compatibility
compatibleTargets = new Set(
Array.from(compatibleTargets).filter(target => allCompatibleSets.every(set => set.has(target)))
)
} else {
// Start with first set, then intersect with others
compatibleTargets = allCompatibleSets[0]
for (let i = 1; i < allCompatibleSets.length; i++) {
compatibleTargets = new Set(Array.from(compatibleTargets).filter(target => allCompatibleSets[i].has(target)))
}
}
} else if (!compatibleTargets) {
// No enabled plugins have includes, so all targets are compatible
// (only if there's no preselected plugin with includes)
compatibleTargets = null
}
}
const filteredGroupedTargets = compatibleTargets
? Object.entries(GROUPED_TARGETS).reduce(
(acc, [category, targets]) => {
const filtered = targets.filter(target => {
// Check both normalized and original target ID
const normalizedId = target.id.replace(/[-_]/g, "")
return compatibleTargets.has(target.id) || compatibleTargets.has(normalizedId)
})
if (filtered.length > 0) {
acc[category] = filtered
}
return acc
},
{} as Record<string, TargetGroup[]>
)
: GROUPED_TARGETS
const filteredTargetCategories = Object.keys(filteredGroupedTargets).sort((a, b) => a.localeCompare(b))
// Preselect plugin from URL parameter
useEffect(() => {
if (pluginParam && preselectedPlugin && !buildHashParam) {
setPluginConfig({ [pluginParam]: true })
setShowPlugins(true)
}
}, [pluginParam, preselectedPlugin, buildHashParam])
useEffect(() => {
const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES
if (!activeCategory && categories.length > 0) {
setActiveCategory(categories[0])
}
}, [activeCategory, compatibleTargets, filteredTargetCategories])
useEffect(() => {
if (activeCategory) {
const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS
const categoryTargets = targets[activeCategory] || []
if (categoryTargets.length === 0) return
// Check if current selected target is in this category
const isCurrentTargetInCategory = categoryTargets.some(t => t.id === selectedTarget)
if (!isCurrentTargetInCategory) {
// Try to restore per-brand selection
const savedTargetForCategory = getSavedTargetForCategory(activeCategory)
const isValidSavedTarget = savedTargetForCategory && categoryTargets.some(t => t.id === savedTargetForCategory)
if (isValidSavedTarget) {
setSelectedTarget(savedTargetForCategory)
// Persist the restored selection
persistTargetSelection(savedTargetForCategory, activeCategory)
} else {
// Default to first target in category and persist it
const firstTarget = categoryTargets[0].id
setSelectedTarget(firstTarget)
persistTargetSelection(firstTarget, activeCategory)
}
}
}
}, [activeCategory, compatibleTargets, filteredGroupedTargets, selectedTarget])
useEffect(() => {
if (typeof window === "undefined") return
try {
const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS
const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES
if (categories.length === 0) return
// Try to restore the most recent global selection first
const savedTarget = localStorage.getItem(STORAGE_KEY)
if (savedTarget && TARGETS[savedTarget]) {
// Check if saved target exists in filtered targets
const isCompatible = Object.values(targets).some(categoryTargets =>
categoryTargets.some(target => target.id === savedTarget)
)
if (isCompatible) {
const category = TARGETS[savedTarget].category || "Other"
if (categories.includes(category)) {
setActiveCategory(category)
setSelectedTarget(savedTarget)
persistTargetSelection(savedTarget, category)
return
}
}
}
// Fall back to per-brand selection for first category
const firstCategory = categories[0]
const categoryTargets = targets[firstCategory] || []
if (categoryTargets.length > 0) {
// Try to restore per-brand selection
const savedTargetForCategory = getSavedTargetForCategory(firstCategory)
const isValidSavedTarget = savedTargetForCategory && categoryTargets.some(t => t.id === savedTargetForCategory)
if (isValidSavedTarget) {
setActiveCategory(firstCategory)
setSelectedTarget(savedTargetForCategory)
persistTargetSelection(savedTargetForCategory, firstCategory)
} else {
// Default to first target in category
const firstTarget = categoryTargets[0].id
setActiveCategory(firstCategory)
setSelectedTarget(firstTarget)
persistTargetSelection(firstTarget, firstCategory)
}
}
} catch (error) {
console.error("Failed to read saved target", error)
}
}, [compatibleTargets, filteredGroupedTargets, filteredTargetCategories])
const handleSelectTarget = (targetId: string) => {
// Validate target is compatible with selected plugins
if (compatibleTargets) {
const normalizedId = targetId.replace(/[-_]/g, "")
const isCompatible = compatibleTargets.has(targetId) || compatibleTargets.has(normalizedId)
if (!isCompatible) {
// Target is not compatible, don't allow selection
return
}
}
setSelectedTarget(targetId)
const category = TARGETS[targetId]?.category || "Other"
persistTargetSelection(targetId, category)
if (category && TARGET_CATEGORIES.includes(category)) {
setActiveCategory(category)
}
}
// Update selected target if it becomes incompatible with selected plugins
useEffect(() => {
if (!selectedTarget || !compatibleTargets) return
const normalizedId = selectedTarget.replace(/[-_]/g, "")
const isCompatible = compatibleTargets.has(selectedTarget) || compatibleTargets.has(normalizedId)
if (!isCompatible) {
// Current target is no longer compatible, find a compatible one
const targets = filteredGroupedTargets
const categories = filteredTargetCategories
if (categories.length > 0) {
// Try to find a compatible target in the current category first
const currentCategory = TARGETS[selectedTarget]?.category
if (currentCategory && targets[currentCategory] && targets[currentCategory].length > 0) {
const savedTargetForCategory = getSavedTargetForCategory(currentCategory)
const isValidSavedTarget =
savedTargetForCategory && targets[currentCategory].some(t => t.id === savedTargetForCategory)
if (isValidSavedTarget) {
setSelectedTarget(savedTargetForCategory)
persistTargetSelection(savedTargetForCategory, currentCategory)
return
}
// Default to first target in current category
setSelectedTarget(targets[currentCategory][0].id)
persistTargetSelection(targets[currentCategory][0].id, currentCategory)
return
}
// Fall back to first compatible target
const firstCategory = categories[0]
const firstTarget = targets[firstCategory]?.[0]?.id
if (firstTarget) {
setSelectedTarget(firstTarget)
setActiveCategory(firstCategory)
persistTargetSelection(firstTarget, firstCategory)
}
}
}
}, [compatibleTargets, filteredGroupedTargets, filteredTargetCategories, selectedTarget])
useEffect(() => {
if (typeof window === "undefined" || !selectedTarget) return
try {
if (!window.localStorage.getItem(STORAGE_KEY)) {
window.localStorage.setItem(STORAGE_KEY, selectedTarget)
}
} catch (error) {
console.error("Failed to initialize target storage", error)
}
}, [selectedTarget])
// Pre-populate form from shared build
useEffect(() => {
if (!buildHashParam) return
if (sharedBuild === undefined) {
setIsLoadingSharedBuild(true)
return
}
setIsLoadingSharedBuild(false)
if (!sharedBuild) {
setErrorMessage("Build not found. The shared build may have been deleted.")
toast.error("Build not found", {
description: "The shared build could not be loaded.",
})
return
}
const config = sharedBuild.config
// Set target and category
if (config.target && TARGETS[config.target]) {
setSelectedTarget(config.target)
const category = TARGETS[config.target].category || "Other"
if (TARGET_CATEGORIES.includes(category)) {
setActiveCategory(category)
}
}
// Set version
if (config.version && (VERSIONS as readonly string[]).includes(config.version)) {
setSelectedVersion(config.version as (typeof VERSIONS)[number])
}
// Set module config (already in the correct format)
if (config.modulesExcluded) {
setModuleConfig(config.modulesExcluded)
if (Object.keys(config.modulesExcluded).length > 0) {
setShowModuleOverrides(true)
}
}
// Set plugin config (convert array to object format)
// Only add explicitly selected plugins, not implicit dependencies
if (config.pluginsEnabled && config.pluginsEnabled.length > 0) {
const allPluginSlugs = config.pluginsEnabled.map(pluginId => {
return pluginId.includes("@") ? pluginId.split("@")[0] : pluginId
})
// Determine which plugins are required by others (implicit dependencies)
const requiredByOthers = new Set<string>()
for (const pluginSlug of allPluginSlugs) {
if (
isRequiredByOther(
pluginSlug,
allPluginSlugs,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
) {
requiredByOthers.add(pluginSlug)
}
}
// Only add plugins that are NOT required by others (explicitly selected)
const pluginObj: Record<string, boolean> = {}
allPluginSlugs.forEach(slug => {
if (slug in registryData && !requiredByOthers.has(slug)) {
pluginObj[slug] = true
}
})
setPluginConfig(pluginObj)
setShowPlugins(true)
}
}, [buildHashParam, sharedBuild])
const moduleCount = Object.keys(moduleConfig).length
const pluginCount = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true).length
const selectedTargetLabel = (selectedTarget && TARGETS[selectedTarget]?.name) || selectedTarget
const handleToggleModule = (id: string, excluded: boolean) => {
setModuleConfig(prev => {
const next = { ...prev }
if (excluded) {
next[id] = true
} else {
delete next[id]
}
return next
})
}
const handleTogglePlugin = (id: string, enabled: boolean) => {
// Get current explicit selections
const explicitPlugins = Object.keys(pluginConfig).filter(pluginId => pluginConfig[pluginId] === true)
// Check if this plugin is currently an implicit dependency
const implicitDeps = getImplicitDependencies(
explicitPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Check if this plugin is required by another explicitly selected plugin
const isRequired = isRequiredByOther(
id,
explicitPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Don't allow toggling implicit dependencies at all
// (they should be disabled in the UI, but add this as a safeguard)
if (implicitDeps.has(id)) {
return // Can't toggle implicit dependencies
}
// Don't allow disabling if it's required by another explicitly selected plugin
if (!enabled && isRequired) {
return // Can't disable required plugins
}
setPluginConfig(prev => {
const next = { ...prev }
if (enabled) {
// Enabling: add to explicit selection (even if it was implicit)
next[id] = true
} else {
// Disabling: remove from explicit selection
delete next[id]
// Recompute what plugins are still needed after removal
const remainingExplicit = Object.keys(next).filter(pluginId => next[pluginId] === true)
const allStillNeeded = getDependedPlugins(
remainingExplicit,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Remove any plugins from config that are no longer needed
// BUT preserve all plugins that are currently explicitly selected (in remainingExplicit)
// This ensures that plugins that were explicitly selected remain explicitly selected
// even if they temporarily became implicit and then un-implicit
for (const pluginId of Object.keys(next)) {
if (next[pluginId] === true && !allStillNeeded.includes(pluginId) && !remainingExplicit.includes(pluginId)) {
// This plugin is no longer needed and is not in the remaining explicit list
// Only remove if it's truly not needed and wasn't explicitly selected
// Note: If a plugin is in `next` with value `true`, it should be in `remainingExplicit`
// So this condition should rarely be true, but we keep it as a safety check
delete next[pluginId]
}
}
// Ensure all remaining explicitly selected plugins stay in config
// (they should already be there, but this ensures they remain even if they're not needed)
for (const pluginId of remainingExplicit) {
next[pluginId] = true
}
}
return next
})
}
const handleFlash = async () => {
if (!selectedTarget) return
setIsFlashing(true)
setErrorMessage(null)
try {
const enabledSlugs = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
// Double-check: filter out any implicit dependencies that might have snuck in
// This ensures we only send explicitly selected plugins to the backend
const implicitDeps = getImplicitDependencies(
enabledSlugs,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
const explicitOnlySlugs = enabledSlugs.filter(slug => !implicitDeps.has(slug))
const pluginsEnabled = explicitOnlySlugs.map(slug => {
const plugin = (registryData as Record<string, { version: string }>)[slug]
return `${slug}@${plugin.version}`
})
const result = await ensureBuildFromConfig({
target: selectedTarget,
version: selectedVersion,
modulesExcluded: moduleConfig,
pluginsEnabled: pluginsEnabled.length > 0 ? pluginsEnabled : undefined,
})
navigate(`/builds/${result.buildHash}`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
setErrorMessage("Failed to start build. Please try again.")
toast.error("Failed to start build", {
description: message,
})
} finally {
setIsFlashing(false)
}
}
const isFlashDisabled = !selectedTarget || isFlashing
if (isLoadingSharedBuild) {
return (
<div className="min-h-screen bg-slate-950 text-white p-6 md:p-10 flex items-center justify-center">
<div className="text-center space-y-4">
<Loader2 className="w-8 h-8 animate-spin text-cyan-500 mx-auto" />
<p className="text-slate-400">Loading shared build configuration...</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-slate-950 text-white p-6 md:p-10">
<div className="max-w-6xl mx-auto space-y-8">
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<p className="text-sm uppercase tracking-wider text-slate-500">
{preselectedPlugin ? "Plugin build" : "Quick build"}
</p>
<h1 className="text-4xl font-bold mt-1">
{preselectedPlugin ? `Build firmware for ${preselectedPlugin.name}` : "Flash a custom firmware version"}
</h1>
<p className="text-slate-400 mt-2 max-w-2xl">
{preselectedPlugin
? `Select a compatible Meshtastic target and configure your build for ${preselectedPlugin.name}. We'll send you to the build status page as soon as it starts.`
: "Choose your Meshtastic target, adjust optional modules, and queue a new build instantly. We'll send you to the build status page as soon as it starts."}
</p>
</div>
</div>
{preselectedPlugin && (
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
<div className="flex items-start gap-4">
<CheckCircle2 className="w-8 h-8 text-green-400 shrink-0 mt-1" />
<div className="flex items-start gap-4 flex-1">
{preselectedPlugin.imageUrl && (
<img
src={preselectedPlugin.imageUrl}
alt={`${preselectedPlugin.name} logo`}
className="w-16 h-16 rounded-lg object-contain shrink-0"
/>
)}
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h2 className="text-2xl font-bold">{preselectedPlugin.name}</h2>
{preselectedPlugin.featured && (
<span className="px-2 py-1 text-xs font-medium text-green-400 bg-green-400/10 border border-green-400/20 rounded">
Featured
</span>
)}
</div>
<p className="text-slate-400 mb-3">{preselectedPlugin.description}</p>
{preselectedPlugin.includes && preselectedPlugin.includes.length > 0 && (
<p className="text-sm text-slate-500">Compatible with: {preselectedPlugin.includes.join(", ")}</p>
)}
</div>
</div>
</div>
</div>
)}
<div className="space-y-6 bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{(compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES).map(category => {
const isActive = activeCategory === category
return (
<button
key={category}
type="button"
onClick={() => {
// Always allow switching to category - the useEffect will handle target selection
setActiveCategory(category)
}}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
isActive ? "bg-blue-600 text-white" : "bg-slate-800 text-slate-300 hover:bg-slate-700"
}`}
>
{category}
</button>
)
})}
</div>
<div className="bg-slate-950/60 p-4 rounded-lg border border-slate-800/60">
<div className="flex flex-wrap gap-2">
{(() => {
const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS
return (activeCategory ? targets[activeCategory] : [])?.map(target => {
const isSelected = selectedTarget === target.id
return (
<button
key={target.id}
type="button"
onClick={() => handleSelectTarget(target.id)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
isSelected ? "bg-cyan-600 text-white" : "bg-slate-800 text-slate-300 hover:bg-slate-700"
}`}
>
{target.name}
</button>
)
})
})()}
</div>
</div>
</div>
<div>
<label htmlFor="build-version" className="block text-sm font-medium mb-2">
Firmware version
</label>
<select
id="build-version"
value={selectedVersion}
onChange={event => setSelectedVersion(event.target.value)}
className="w-full h-10 px-3 rounded-md border border-slate-800 bg-slate-950 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:ring-offset-slate-950"
>
{VERSIONS.map(version => (
<option key={version} value={version}>
{version}
</option>
))}
</select>
</div>
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-950/70 p-6">
<button
type="button"
onClick={() => setShowModuleOverrides(prev => !prev)}
className="w-full flex items-center justify-between text-left"
>
<div>
<p className="text-sm font-medium">Core Modules</p>
<p className="text-xs text-slate-400">
{moduleCount === 0
? "Using default modules for this target."
: `${moduleCount} module${moduleCount === 1 ? "" : "s"} excluded.`}
</p>
</div>
{showModuleOverrides ? (
<ChevronDown className="w-4 h-4 text-slate-400" />
) : (
<ChevronRight className="w-4 h-4 text-slate-400" />
)}
</button>
{showModuleOverrides && (
<div className="space-y-2 pr-1">
<div className="rounded-lg bg-slate-800/50 border border-slate-700 p-3">
<p className="text-xs text-slate-400 leading-relaxed">
Core Modules are officially maintained modules by Meshtastic. They are selectively included or
excluded by default depending on the target device. You can explicitly exclude modules you know you
don't want.
</p>
</div>
<div className="flex justify-end">
<button
type="button"
className="text-xs text-slate-400 hover:text-white underline"
onClick={() => setModuleConfig({})}
disabled={moduleCount === 0}
>
Reset overrides
</button>
</div>
<div className="grid gap-2 md:grid-cols-2">
{modulesData.modules.map(module => (
<ModuleToggle
key={module.id}
id={module.id}
name={module.name}
description={module.description}
isExcluded={moduleConfig[module.id] === true}
onToggle={excluded => handleToggleModule(module.id, excluded)}
/>
))}
</div>
</div>
)}
</div>
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-950/70 p-6">
<button
type="button"
onClick={() => setShowPlugins(prev => !prev)}
className="w-full flex items-center justify-between text-left"
>
<div>
<p className="text-sm font-medium">Plugins</p>
<p className="text-xs text-slate-400">
{pluginCount === 0
? "No plugins enabled."
: `${pluginCount} plugin${pluginCount === 1 ? "" : "s"} enabled.`}
</p>
</div>
{showPlugins ? (
<ChevronDown className="w-4 h-4 text-slate-400" />
) : (
<ChevronRight className="w-4 h-4 text-slate-400" />
)}
</button>
{showPlugins && (
<div className="space-y-2 pr-1">
<div className="rounded-lg bg-slate-800/50 border border-slate-700 p-3">
<p className="text-xs text-slate-400 leading-relaxed">
Plugins are 3rd party add-ons. They are not maintained, endorsed, or supported by Meshtastic. Use at
your own risk.
</p>
</div>
<div className="flex justify-end">
<button
type="button"
className="text-xs text-slate-400 hover:text-white underline"
onClick={() => setPluginConfig({})}
disabled={pluginCount === 0}
>
Reset plugins
</button>
</div>
<div className="grid gap-2 md:grid-cols-2" key={`plugins-${selectedTarget}`}>
{(() => {
// Get explicitly selected plugins (user-selected)
const explicitPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
// Compute implicit dependencies (dependencies that are not explicitly selected)
const implicitDeps = getImplicitDependencies(
explicitPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Compute all enabled plugins (explicit + implicit)
const allEnabledPlugins = getDependedPlugins(
explicitPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
return Object.entries(registryData)
.sort(([, pluginA], [, pluginB]) => {
// Featured plugins first
const featuredA = pluginA.featured ?? false
const featuredB = pluginB.featured ?? false
if (featuredA !== featuredB) {
return featuredA ? -1 : 1
}
// Then alphabetical by name
return pluginA.name.localeCompare(pluginB.name)
})
.map(([slug, plugin]) => {
// Check if plugin is required by another explicitly selected plugin
const isRequired = isRequiredByOther(
slug,
explicitPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Plugin is implicit if it's either:
// 1. Not explicitly selected but is a dependency, OR
// 2. Explicitly selected but required by another explicitly selected plugin
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
// Check if this is the preselected plugin from URL
const isPreselected = pluginParam === slug
return (
<PluginCard
key={`${slug}-${selectedTarget}`}
variant="link-toggle"
id={slug}
name={plugin.name}
description={plugin.description}
imageUrl={plugin.imageUrl}
isEnabled={allEnabledPlugins.includes(slug)}
onToggle={enabled => handleTogglePlugin(slug, enabled)}
disabled={isImplicit || isIncompatible || isPreselected}
enabledLabel={isPreselected ? "Locked" : isImplicit ? "Required" : "Add"}
incompatibleReason={isIncompatible ? "Not compatible with this target" : undefined}
featured={plugin.featured ?? false}
flashCount={pluginFlashCounts[slug] ?? 0}
homepage={plugin.homepage}
version={plugin.version}
repo={plugin.repo}
/>
)
})
})()}
</div>
</div>
)}
</div>
<div className="space-y-2">
<Button onClick={handleFlash} disabled={isFlashDisabled} className="w-full bg-cyan-600 hover:bg-cyan-700">
{isFlashing ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Queuing build...
</span>
) : (
`Flash ${selectedTargetLabel || ""}`.trim() || "Flash"
)}
</Button>
{errorMessage && <p className="text-sm text-red-400">{errorMessage}</p>}
</div>
</div>
</div>
</div>
)
}

View File

@@ -149,7 +149,7 @@ export default function LandingPage() {
description={customBuildPlugin.description}
imageUrl={customBuildPlugin.imageUrl}
featured={false}
href="/builds/new"
href="/builds"
prominent={true}
/>
</div>

View File

@@ -132,7 +132,7 @@ export default function PluginPage() {
Homepage
</Button>
)}
<Button onClick={() => navigate(`/builds/new?plugin=${slug}`)} className="bg-cyan-600 hover:bg-cyan-700">
<Button onClick={() => navigate(`/builds?plugin=${slug}`)} className="bg-cyan-600 hover:bg-cyan-700">
Build with this Plugin
</Button>
</div>

View File

@@ -9,14 +9,6 @@ const FIRMWARE_DIR = path.resolve(__dirname, "../vendor/firmware")
const VARIANTS_DIR = path.join(FIRMWARE_DIR, "variants")
const OUTPUT_FILE = path.resolve(__dirname, "../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
*/
@@ -342,15 +334,8 @@ function buildParentMapping() {
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
// Return map with actual PlatformIO environment names (no normalization)
return resolvedParentMap
}
/**

1
vendor/api vendored

Submodule vendor/api deleted from 1774354d2b

1
vendor/firmware vendored

Submodule vendor/firmware deleted from 5910cc2e26

1
vendor/meshcore vendored

Submodule vendor/meshcore deleted from 6d3219329f

1
vendor/web-flasher vendored

Submodule vendor/web-flasher deleted from c165572117