feat(ui): mesh header "Flash LoRa" button opens the in-app flash flow
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m9s

Replaces the external flasher.meshcore.co.uk link with a button that
opens the global device-setup modal directly at its flash step, via a
new manual entry point in the mesh store (flashFlowPath). Manual opens
target the connected radio (else the first detected stick), skip the
read-only probe — the port is held by the live session and a second tty
opener corrupts it; the backend flash job stops the listener itself —
and close the modal instead of stepping back to the detection screen.
Button is disabled with a hint when no radio is present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-29 04:43:38 -04:00
parent c99f1c7b77
commit 6ba39041d0
4 changed files with 59 additions and 7 deletions

View File

@ -340,7 +340,7 @@ function stopProbeProgress(done = false) {
if (done) probeProgress.value = 100
}
const devicePath = computed(() => mesh.undismissedDetectedDevices[0] ?? '')
const devicePath = computed(() => mesh.flashFlowPath ?? mesh.undismissedDetectedDevices[0] ?? '')
const show = computed(() => !!devicePath.value)
const imageFailed = ref(false)
const deviceImage = computed(() =>
@ -402,6 +402,23 @@ watch([show, devicePath], async ([visible]) => {
stopFlashPoll()
return
}
if (mesh.flashFlowPath) {
// Manual "Flash LoRa" entry: jump straight to the flash step. Skip the
// read-only probe the port is usually the live session's, and a second
// opener on the tty corrupts the running connection; status already
// knows the firmware kind for the connected radio.
probe.value = null
probeError.value = ''
probing.value = false
stopProbeProgress()
imageFailed.value = false
openFlashStep()
const t = (mesh.status?.device_type ?? '').toLowerCase()
if (t === 'meshcore' || t === 'meshtastic' || t === 'reticulum') {
flashFamily.value = t as FlashFirmwareFamily
}
return
}
step.value = 1
error.value = ''
imageFailed.value = false
@ -428,6 +445,10 @@ watch([show, devicePath], async ([visible]) => {
}, { immediate: false })
function dismiss() {
if (mesh.flashFlowPath) {
mesh.closeFlashFlow()
return
}
if (devicePath.value) mesh.dismissDetectedDevice(devicePath.value)
}
@ -598,6 +619,11 @@ async function cancelFlash() {
function closeFlashStep() {
stopFlashPoll()
if (mesh.flashFlowPath) {
// Manual entry has no detection step to go back to close the modal.
mesh.closeFlashFlow()
return
}
step.value = 1
}
</script>

View File

@ -377,6 +377,16 @@ export const useMeshStore = defineStore('mesh', () => {
}
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value))
}
// Manual "Flash LoRa" entry (Mesh header button): forces the global setup
// modal open at the flash step for this port — including the live session's
// port, which detection-driven opening deliberately excludes.
const flashFlowPath = ref<string | null>(null)
function openFlashFlow(path: string) {
flashFlowPath.value = path
}
function closeFlashFlow() {
flashFlowPath.value = null
}
/** Read-only firmware/config probe of a detected port (hot-swap modal). */
async function probeDevice(path: string): Promise<MeshDeviceProbe> {
return rpcClient.call<MeshDeviceProbe>({
@ -1066,6 +1076,9 @@ export const useMeshStore = defineStore('mesh', () => {
fetchStatus,
undismissedDetectedDevices,
dismissDetectedDevice,
flashFlowPath,
openFlashFlow,
closeFlashFlow,
probeDevice,
flashListFirmware,
flashDevice,

View File

@ -21,6 +21,18 @@ const mesh = useMeshStore()
const transport = useTransportStore()
const route = useRoute()
// "Flash LoRa" header button target: the connected radio wins, else any
// detected-but-unconnected stick. Opens the global setup modal at its
// flash step (backend stops the listener and frees the port itself).
const flashLoraPath = computed(() =>
(mesh.status?.device_connected && mesh.status.device_path) ||
mesh.status?.detected_devices?.[0] ||
''
)
function openFlashLora() {
if (flashLoraPath.value) mesh.openFlashFlow(flashLoraPath.value)
}
// Responsive layout breakpoints
const isWideDesktop = ref(window.innerWidth >= 1536)
const isVeryWideDesktop = ref(window.innerWidth >= 2560 && window.innerHeight >= 1200)
@ -1860,14 +1872,14 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<span v-else-if="mesh.status?.device_present" class="mesh-subtitle-badge">Mesh device detected</span>
</p>
</div>
<a
href="https://flasher.meshcore.co.uk/"
target="_blank"
rel="noopener noreferrer"
<button
class="glass-button mesh-flasher-btn"
:disabled="!flashLoraPath"
:title="flashLoraPath ? '' : 'Plug in a LoRa radio first'"
@click="openFlashLora"
>
Flash Meshcore <span class="mesh-flasher-sep">|</span> Choose Companion USB
</a>
Flash LoRa
</button>
</div>
<!-- Error banner -->

View File

@ -28,6 +28,7 @@
.mesh-subtitle { color: rgba(255, 255, 255, 0.5); font-size: 0.85rem; margin: 2px 0 0; display: flex; align-items: center; gap: 8px; }
.mesh-subtitle-badge { font-size: 0.65rem; font-weight: 600; color: #4ade80; background: rgba(74, 222, 128, 0.12); padding: 1px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.mesh-flasher-btn { display: inline-flex; align-items: center; gap: 0; padding: 8px 16px; font-size: 0.9rem; text-decoration: none; white-space: nowrap; flex-shrink: 0; }
.mesh-flasher-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.mesh-flasher-sep { margin: 0 8px; color: rgba(255, 255, 255, 0.2); }
.mesh-error { color: #ef4444; font-size: 0.85rem; padding: 8px 12px; background: rgba(239, 68, 68, 0.1); border-radius: 8px; border: 1px solid rgba(239, 68, 68, 0.2); flex-shrink: 0; }
.mesh-columns { display: flex; gap: 16px; flex: 1; min-height: 0; overflow: hidden; }