From 6ba39041d0061de8db2de13c262a5a38676e07d4 Mon Sep 17 00:00:00 2001
From: archipelago
Date: Wed, 29 Jul 2026 04:43:38 -0400
Subject: [PATCH] feat(ui): mesh header "Flash LoRa" button opens the in-app
flash flow
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.../components/mesh/MeshDeviceSetupModal.vue | 28 ++++++++++++++++++-
neode-ui/src/stores/mesh.ts | 13 +++++++++
neode-ui/src/views/Mesh.vue | 24 ++++++++++++----
neode-ui/src/views/mesh/mesh-styles.css | 1 +
4 files changed, 59 insertions(+), 7 deletions(-)
diff --git a/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue b/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue
index 4b4973bc..895e910a 100644
--- a/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue
+++ b/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue
@@ -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
}
diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts
index 7d9a815a..96788428 100644
--- a/neode-ui/src/stores/mesh.ts
+++ b/neode-ui/src/stores/mesh.ts
@@ -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(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 {
return rpcClient.call({
@@ -1066,6 +1076,9 @@ export const useMeshStore = defineStore('mesh', () => {
fetchStatus,
undismissedDetectedDevices,
dismissDetectedDevice,
+ flashFlowPath,
+ openFlashFlow,
+ closeFlashFlow,
probeDevice,
flashListFirmware,
flashDevice,
diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue
index 05d927eb..923601a7 100644
--- a/neode-ui/src/views/Mesh.vue
+++ b/neode-ui/src/views/Mesh.vue
@@ -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) {
Mesh device detected
-
- Flash Meshcore | Choose Companion USB
-
+ Flash LoRa
+
diff --git a/neode-ui/src/views/mesh/mesh-styles.css b/neode-ui/src/views/mesh/mesh-styles.css
index a6f679c1..7f468f5f 100644
--- a/neode-ui/src/views/mesh/mesh-styles.css
+++ b/neode-ui/src/views/mesh/mesh-styles.css
@@ -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; }