feat(mesh): Device settings tab (backlog #8)

New MeshDevicePanel.vue, added as a 4th/5th tab entry to activeTab/toolsTab/
mobileTab following the exact existing pattern (chat/bitcoin/deadman/
assistant/map). Shows firmware version, node ID, advert name, LoRa region,
channel, and device type -- firmware_version/self_node_id were already
server-side but never rendered; region is new (composed into MeshStatus from
MeshConfig.lora_region at read time, not part of the live session state).
Reboot button wired to the already-working mesh.reboot-radio RPC.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-30 23:03:09 -04:00
parent 4a309a3ee4
commit 494f272815
7 changed files with 115 additions and 5 deletions

View File

@ -275,6 +275,7 @@ impl MeshState {
channel_name: channel_name.to_string(),
messages_sent: 0,
messages_received: 0,
region: None,
}),
event_tx: tx,
next_message_id: RwLock::new(1),

View File

@ -939,7 +939,13 @@ impl MeshService {
/// Get current mesh status.
pub async fn status(&self) -> MeshStatus {
self.state.status.read().await.clone()
let mut status = self.state.status.read().await.clone();
// The operator-configured LoRa region isn't part of the live session
// state (it's config, read once at session start) — compose it in
// here rather than threading it through the session's shared status
// writes, for the Device tab (#8) to display.
status.region = self.config.lora_region.clone();
status
}
/// Get a reference to the shared mesh state.

View File

@ -178,6 +178,11 @@ pub struct MeshStatus {
pub channel_name: String,
pub messages_sent: u64,
pub messages_received: u64,
/// Operator-configured LoRa region (e.g. "EU_868"), for the Device tab
/// (#8) — composed in by `MeshService::status()` from `MeshConfig`, not
/// part of the live session state itself.
#[serde(default)]
pub region: Option<String>,
}
/// Information returned from device during initialization.

View File

@ -19,6 +19,8 @@ export interface MeshStatus {
/** Bitcoin block-header send/receive prefs (issue #28). */
announce_block_headers?: boolean
receive_block_headers?: boolean
/** Operator-configured LoRa region (e.g. "EU_868"), for the Device tab. */
region?: string | null
}
export interface MeshPeer {
@ -555,6 +557,13 @@ export const useMeshStore = defineStore('mesh', () => {
}
}
async function rebootRadio(seconds = 2) {
return rpcClient.call<{ reboot: boolean; seconds: number }>({
method: 'mesh.reboot-radio',
params: { seconds },
})
}
async function getOutbox() {
try {
return await rpcClient.call<{ count: number; messages?: unknown[] }>({ method: 'mesh.outbox' })
@ -812,6 +821,7 @@ export const useMeshStore = defineStore('mesh', () => {
fetchContent,
sendReply,
sendReaction,
rebootRadio,
getOutbox,
sendReadReceipt,
forwardMessage,

View File

@ -8,6 +8,7 @@ import AnimatedLogo from '@/components/AnimatedLogo.vue'
import MeshMap from '@/components/MeshMap.vue'
import MeshBitcoinPanel from '@/views/mesh/MeshBitcoinPanel.vue'
import MeshDeadmanPanel from '@/views/mesh/MeshDeadmanPanel.vue'
import MeshDevicePanel from '@/views/mesh/MeshDevicePanel.vue'
import MeshAssistantPanel from '@/views/mesh/MeshAssistantPanel.vue'
import { rpcClient } from '@/api/rpc-client'
import { wsClient } from '@/api/websocket'
@ -259,16 +260,16 @@ async function clearAllMesh() {
}
// Phase 4: Off-grid Bitcoin + Dead Man's Switch
const activeTab = ref<'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map'>('chat')
const activeTab = ref<'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map' | 'device'>('chat')
// Tools tab for 3rd column on wide desktop and mobile below-chat
const toolsTab = ref<'bitcoin' | 'deadman' | 'assistant' | 'map'>('bitcoin')
const toolsTab = ref<'bitcoin' | 'deadman' | 'assistant' | 'map' | 'device'>('bitcoin')
// Mobile: a single set of floating tabs drives the whole pane (Chat + tools).
// 'chat' shows the peers list / active conversation; the rest swap the pane to
// that tool. Selecting a tool leaves any open conversation.
const mobileTab = ref<'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map'>('chat')
function selectMobileTab(tab: 'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map') {
const mobileTab = ref<'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map' | 'device'>('chat')
function selectMobileTab(tab: 'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map' | 'device') {
mobileTab.value = tab
if (tab !== 'chat') mobileShowChat.value = false
}
@ -301,6 +302,12 @@ const showMapPanel = computed(() => {
if (isMobile.value) return mobileTab.value === 'map'
return activeTab.value === 'map'
})
const showDevicePanel = computed(() => {
if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'device'
if (isMobile.value) return mobileTab.value === 'device'
return activeTab.value === 'device'
})
// Mobile: tool pane shows whenever a non-chat tab is active.
const showMobileTools = computed(() => isMobile.value && mobileTab.value !== 'chat')
const showTabBar = computed(() => !isWideDesktop.value && !isMobile.value)
@ -1860,6 +1867,9 @@ function isImageMime(mime?: string): boolean {
<button class="mesh-tab" :class="{ active: activeTab === 'map' }" @click="activeTab = 'map'">
Map
</button>
<button class="mesh-tab" :class="{ active: activeTab === 'device' }" @click="activeTab = 'device'" title="Device settings">
</button>
</div>
<!-- Chat Panel -->
@ -2203,11 +2213,13 @@ function isImageMime(mime?: string): boolean {
AI
</button>
<button class="mesh-tab" :class="{ active: toolsTab === 'map' }" @click="toolsTab = 'map'">Map</button>
<button class="mesh-tab" :class="{ active: toolsTab === 'device' }" @click="toolsTab = 'device'" title="Device settings"></button>
</div>
<MeshBitcoinPanel v-if="showBitcoinPanel" />
<MeshDeadmanPanel v-if="showDeadmanPanel" />
<MeshAssistantPanel v-if="showAssistantPanel" />
<div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></div>
<MeshDevicePanel v-if="showDevicePanel" />
</div>
</div>
@ -2217,6 +2229,7 @@ function isImageMime(mime?: string): boolean {
<MeshBitcoinPanel v-if="showBitcoinPanel" />
<MeshDeadmanPanel v-if="showDeadmanPanel" />
<MeshAssistantPanel v-if="showAssistantPanel" />
<MeshDevicePanel v-if="showDevicePanel" />
</div>
</div>
@ -2234,6 +2247,7 @@ function isImageMime(mime?: string): boolean {
</button>
<button class="mesh-mtab" :class="{ active: mobileTab === 'assistant' }" @click="selectMobileTab('assistant')">AI</button>
<button class="mesh-mtab" :class="{ active: mobileTab === 'map' }" @click="selectMobileTab('map')">Map</button>
<button class="mesh-mtab" :class="{ active: mobileTab === 'device' }" @click="selectMobileTab('device')" title="Device settings"></button>
</div>
</Teleport>

View File

@ -0,0 +1,68 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useMeshStore } from '@/stores/mesh'
const mesh = useMeshStore()
const rebooting = ref(false)
const rebootError = ref<string | null>(null)
async function handleReboot() {
rebooting.value = true
rebootError.value = null
try {
await mesh.rebootRadio()
} catch (e) {
rebootError.value = e instanceof Error ? e.message : 'Failed to reboot radio'
} finally {
rebooting.value = false
}
}
</script>
<template>
<div class="glass-card mesh-device-panel">
<h3 class="mesh-panel-title">Device</h3>
<p class="mesh-panel-sub">Firmware, identity, and radio controls for the connected mesh device</p>
<div v-if="mesh.status" class="mesh-device-panel-grid">
<div class="mesh-stat">
<span class="mesh-stat-label">Firmware</span>
<span class="mesh-stat-value">{{ mesh.status.firmware_version ?? '—' }}</span>
</div>
<div class="mesh-stat">
<span class="mesh-stat-label">Node ID</span>
<span class="mesh-stat-value">{{ mesh.status.self_node_id != null ? `!${mesh.status.self_node_id.toString(16).padStart(8, '0')}` : '—' }}</span>
</div>
<div class="mesh-stat">
<span class="mesh-stat-label">Name</span>
<span class="mesh-stat-value">{{ mesh.status.self_advert_name ?? '—' }}</span>
</div>
<div class="mesh-stat">
<span class="mesh-stat-label">Region</span>
<span class="mesh-stat-value">{{ mesh.status.region ?? 'Not set' }}</span>
</div>
<div class="mesh-stat">
<span class="mesh-stat-label">Channel</span>
<span class="mesh-stat-value">{{ mesh.status.channel_name }}</span>
</div>
<div class="mesh-stat">
<span class="mesh-stat-label">Type</span>
<span class="mesh-stat-value">{{ mesh.status.device_type === 'unknown' ? '—' : mesh.status.device_type }}</span>
</div>
</div>
<div class="mesh-device-panel-actions">
<button
class="glass-button mesh-device-reboot-btn"
:disabled="rebooting || !mesh.status?.device_connected"
@click="handleReboot"
>
<span v-if="rebooting" class="mesh-spinner" aria-hidden="true"></span>
<template v-else>Reboot Radio</template>
</button>
<p class="mesh-device-reboot-hint">Use this if the device stops responding to sent messages or seems stuck.</p>
<p v-if="rebootError" class="mesh-device-reboot-error">{{ rebootError }}</p>
</div>
</div>
</template>

View File

@ -368,6 +368,12 @@
.mesh-assistant-addkey input { flex: 1; min-width: 0; }
.mesh-panel-title { font-size: 1rem; font-weight: 700; color: rgba(255,255,255,0.95); margin: 0; }
.mesh-panel-sub { font-size: 0.8rem; color: rgba(255,255,255,0.45); margin: -4px 0 0; }
.mesh-device-panel { display: flex; flex-direction: column; gap: 12px; }
.mesh-device-panel-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
.mesh-device-panel-actions { display: flex; flex-direction: column; gap: 6px; padding-top: 4px; border-top: 1px solid rgba(255,255,255,0.08); }
.mesh-device-reboot-btn { align-self: flex-start; padding: 8px 16px; font-size: 0.85rem; }
.mesh-device-reboot-hint { font-size: 0.75rem; color: rgba(255,255,255,0.4); margin: 0; }
.mesh-device-reboot-error { font-size: 0.8rem; color: #ef4444; margin: 0; }
.mesh-bitcoin-section { display: flex; flex-direction: column; gap: 8px; }
.mesh-bitcoin-section-header { display: flex; justify-content: space-between; align-items: center; }
.mesh-bitcoin-label { font-size: 0.75rem; font-weight: 600; color: rgba(255,255,255,0.5); text-transform: uppercase; letter-spacing: 0.5px; }