Skip to content

Commit 79194db

Browse files
authored
Merge pull request #327 from meshtastic/claude/wizardly-khayyam
Add BLE provisioning component and store for WiFi
2 parents b63bbf9 + 63cb30d commit 79194db

File tree

6 files changed

+643
-0
lines changed

6 files changed

+643
-0
lines changed

app.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@
112112
>
113113
{{ $t('buttons.serial_monitor') }} <Terminal class="h-4 w-4 shrink-0" />
114114
</button>
115+
<BleProvisioning v-if="!serialMonitorStore.isConnected" />
115116
<a
116117
v-if="!serialMonitorStore.isConnected"
117118
href="https://meshtastic.org/docs"

components/BleProvisioning.vue

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
<template>
2+
<div v-if="bleStore.isWebBluetoothSupported">
3+
<button
4+
type="button"
5+
class="btn-secondary"
6+
data-modal-target="ble-provisioning-modal"
7+
data-modal-toggle="ble-provisioning-modal"
8+
>
9+
{{ $t('ble.provision_wifi') }} <Bluetooth class="h-4 w-4 shrink-0" />
10+
</button>
11+
12+
<Teleport to="body">
13+
<div
14+
id="ble-provisioning-modal"
15+
tabindex="-1"
16+
aria-hidden="true"
17+
class="hidden fixed inset-0 z-[50] modal-backdrop backdrop-blur-sm px-4 sm:px-6 md:px-8 py-8 md:py-12"
18+
>
19+
<div class="flex h-full w-full items-start justify-center">
20+
<div class="relative w-full max-w-lg">
21+
<div class="modal-content relative flex flex-col max-h-[90vh] overflow-hidden rounded-2xl shadow-2xl text-theme">
22+
<!-- Header -->
23+
<div class="flex items-center justify-between p-4 border-b border-[var(--border-default)]">
24+
<h3 class="text-lg font-semibold text-theme flex items-center gap-2">
25+
<Bluetooth class="h-5 w-5 text-meshtastic" />
26+
{{ $t('ble.provision_wifi') }}
27+
</h3>
28+
<button
29+
type="button"
30+
class="btn-icon"
31+
data-modal-hide="ble-provisioning-modal"
32+
@click="handleClose"
33+
>
34+
<X class="h-4 w-4" />
35+
</button>
36+
</div>
37+
38+
<!-- Body -->
39+
<div class="flex-1 overflow-y-auto p-4 space-y-4">
40+
<p class="text-sm text-theme-muted">
41+
{{ $t('ble.description') }}
42+
</p>
43+
44+
<!-- Step 1: Connect -->
45+
<div v-if="!bleStore.isConnected">
46+
<button
47+
type="button"
48+
class="btn-primary w-full"
49+
:disabled="bleStore.isConnecting"
50+
@click="bleStore.connect(t)"
51+
>
52+
<Loader2 v-if="bleStore.isConnecting" class="h-4 w-4 animate-spin" />
53+
<Bluetooth v-else class="h-4 w-4" />
54+
{{ bleStore.isConnecting ? $t('ble.connecting') : $t('ble.connect_device') }}
55+
</button>
56+
</div>
57+
58+
<!-- Step 2: Configure WiFi -->
59+
<template v-if="bleStore.isConnected">
60+
<!-- Network Scan -->
61+
<div class="space-y-2">
62+
<button
63+
type="button"
64+
class="btn-secondary w-full"
65+
:disabled="bleStore.isScanning"
66+
@click="bleStore.scanNetworks(t)"
67+
>
68+
<Loader2 v-if="bleStore.isScanning" class="h-4 w-4 animate-spin" />
69+
<Wifi v-else class="h-4 w-4" />
70+
{{ bleStore.isScanning ? $t('ble.scanning') : $t('ble.scan_networks') }}
71+
</button>
72+
73+
<!-- Network List -->
74+
<div
75+
v-if="bleStore.availableNetworks.length > 0"
76+
class="space-y-1"
77+
>
78+
<label class="text-xs font-medium text-theme-muted uppercase tracking-wider">
79+
{{ $t('ble.available_networks') }}
80+
</label>
81+
<div class="max-h-40 overflow-y-auto rounded-lg border border-[var(--border-default)]">
82+
<button
83+
v-for="network in bleStore.availableNetworks"
84+
:key="network.bssid || network.ssid"
85+
type="button"
86+
class="w-full text-left px-3 py-2 text-sm text-theme hover:bg-[var(--accent-glow)] transition-colors border-b border-[var(--border-default)] last:border-b-0 flex items-center gap-2"
87+
:class="{ 'bg-[var(--accent-glow)]': bleStore.ssid === network.ssid }"
88+
@click="bleStore.ssid = network.ssid"
89+
>
90+
<Wifi class="h-3 w-3 shrink-0" :class="signalClass(network.signalStrength)" />
91+
<span class="flex-1 truncate">{{ network.ssid }}</span>
92+
<Lock v-if="network.isProtected" class="h-3 w-3 text-theme-muted shrink-0" />
93+
<span class="text-xs text-theme-muted shrink-0">{{ network.signalStrength }} dBm</span>
94+
</button>
95+
</div>
96+
</div>
97+
98+
<div
99+
v-if="!bleStore.isScanning && bleStore.availableNetworks.length === 0 && hasScanned"
100+
class="text-sm text-theme-muted text-center py-2"
101+
>
102+
{{ $t('ble.no_networks_found') }}
103+
</div>
104+
</div>
105+
106+
<!-- SSID Input -->
107+
<div class="space-y-1">
108+
<label class="text-xs font-medium text-theme-muted uppercase tracking-wider">
109+
{{ $t('ble.ssid_label') }}
110+
</label>
111+
<input
112+
v-model="bleStore.ssid"
113+
type="text"
114+
:placeholder="$t('ble.ssid_placeholder')"
115+
class="w-full px-3 py-2 rounded-lg text-sm bg-[var(--surface-secondary)] border border-[var(--border-default)] text-theme placeholder-[var(--text-muted)] focus:border-[var(--accent)] focus:outline-none transition-colors"
116+
>
117+
</div>
118+
119+
<!-- Password Input -->
120+
<div class="space-y-1">
121+
<label class="text-xs font-medium text-theme-muted uppercase tracking-wider">
122+
{{ $t('ble.psk_label') }}
123+
</label>
124+
<div class="relative">
125+
<input
126+
v-model="bleStore.psk"
127+
:type="showPassword ? 'text' : 'password'"
128+
:placeholder="$t('ble.psk_placeholder')"
129+
class="w-full px-3 py-2 pr-10 rounded-lg text-sm bg-[var(--surface-secondary)] border border-[var(--border-default)] text-theme placeholder-[var(--text-muted)] focus:border-[var(--accent)] focus:outline-none transition-colors"
130+
>
131+
<button
132+
type="button"
133+
class="absolute right-2 top-1/2 -translate-y-1/2 text-theme-muted hover:text-theme transition-colors"
134+
:aria-label="showPassword ? $t('ble.hide_password') : $t('ble.show_password')"
135+
@click="showPassword = !showPassword"
136+
>
137+
<EyeOff v-if="showPassword" class="h-4 w-4" />
138+
<Eye v-else class="h-4 w-4" />
139+
</button>
140+
</div>
141+
</div>
142+
143+
<!-- Status Display -->
144+
<div
145+
v-if="bleStore.status !== 'idle'"
146+
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm"
147+
:class="statusClass"
148+
>
149+
<Loader2 v-if="bleStore.status === 'provisioning'" class="h-4 w-4 animate-spin" />
150+
<CheckCircle v-else-if="bleStore.status === 'success'" class="h-4 w-4" />
151+
<XCircle v-else-if="bleStore.status === 'failed'" class="h-4 w-4" />
152+
{{ statusText }}
153+
</div>
154+
155+
<!-- Action Buttons -->
156+
<div class="flex gap-2">
157+
<button
158+
type="button"
159+
class="btn-primary flex-1"
160+
:disabled="!bleStore.canProvision"
161+
@click="bleStore.provisionWifi(t)"
162+
>
163+
<Loader2 v-if="bleStore.isProvisioning" class="h-4 w-4 animate-spin" />
164+
<Wifi v-else class="h-4 w-4" />
165+
{{ bleStore.isProvisioning ? $t('ble.provisioning') : $t('ble.apply') }}
166+
</button>
167+
<button
168+
type="button"
169+
class="btn-secondary"
170+
@click="bleStore.disconnect()"
171+
>
172+
{{ $t('ble.disconnect') }}
173+
</button>
174+
</div>
175+
</template>
176+
</div>
177+
</div>
178+
</div>
179+
</div>
180+
</div>
181+
</Teleport>
182+
</div>
183+
</template>
184+
185+
<script lang="ts" setup>
186+
import { useI18n } from 'vue-i18n'
187+
import {
188+
Bluetooth,
189+
Wifi,
190+
Loader2,
191+
CheckCircle,
192+
XCircle,
193+
Eye,
194+
EyeOff,
195+
Lock,
196+
X,
197+
} from 'lucide-vue-next'
198+
199+
import { useBleProvisioningStore } from '../stores/bleProvisioningStore'
200+
201+
const { t } = useI18n()
202+
const bleStore = useBleProvisioningStore()
203+
204+
const showPassword = ref(false)
205+
const hasScanned = ref(false)
206+
207+
watch(() => bleStore.isScanning, (scanning, wasScanning) => {
208+
if (wasScanning && !scanning) {
209+
hasScanned.value = true
210+
}
211+
})
212+
213+
const signalClass = (dbm: number) => {
214+
if (dbm >= -50) return 'text-green-400'
215+
if (dbm >= -70) return 'text-yellow-400'
216+
return 'text-red-400'
217+
}
218+
219+
const statusClass = computed(() => {
220+
switch (bleStore.status) {
221+
case 'provisioning':
222+
case 'scanning':
223+
return 'bg-amber-500/10 text-amber-400 border border-amber-500/20'
224+
case 'success':
225+
return 'bg-green-500/10 text-green-400 border border-green-500/20'
226+
case 'failed':
227+
return 'bg-red-500/10 text-red-400 border border-red-500/20'
228+
default:
229+
return 'bg-gray-500/10 text-gray-400 border border-gray-500/20'
230+
}
231+
})
232+
233+
const statusText = computed(() => {
234+
switch (bleStore.status) {
235+
case 'provisioning':
236+
return t('ble.status_applying')
237+
case 'scanning':
238+
return t('ble.scanning')
239+
case 'success':
240+
return t('ble.status_applied')
241+
case 'failed':
242+
return t('ble.status_apply_failed')
243+
default:
244+
return t('ble.status_idle')
245+
}
246+
})
247+
248+
const handleClose = () => {
249+
hasScanned.value = false
250+
showPassword.value = false
251+
}
252+
</script>

i18n/locales/en.json

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,44 @@
120120
"error": "Error"
121121
}
122122
},
123+
"ble": {
124+
"provision_wifi": "mPWRD-OS WiFi Provisioning",
125+
"connect_device": "Connect to BLE Device",
126+
"connecting": "Connecting...",
127+
"connected": "Connected",
128+
"connected_message": "Connected to BLE device",
129+
"disconnect": "Disconnect",
130+
"scan_networks": "Scan for Networks",
131+
"scanning": "Scanning...",
132+
"available_networks": "Available Networks",
133+
"no_networks_found": "No networks found",
134+
"ssid_label": "Network Name (SSID)",
135+
"ssid_placeholder": "Enter or select a network",
136+
"psk_label": "Password",
137+
"psk_placeholder": "Enter WiFi password",
138+
"apply": "Apply",
139+
"provisioning": "Provisioning...",
140+
"status_idle": "Ready",
141+
"status_applying": "Applying WiFi configuration...",
142+
"status_applied": "WiFi configured successfully!",
143+
"status_apply_failed": "Failed to apply WiFi configuration",
144+
"status_missing_credentials": "SSID and password are required",
145+
"error_connect_title": "BLE Connection Failed",
146+
"error_connect": "Failed to connect to device via Bluetooth. Ensure the device is nearby and Bluetooth is enabled.",
147+
"error_provision_title": "Provisioning Failed",
148+
"error_provision": "Failed to provision WiFi credentials. Please try again.",
149+
"error_scan_title": "Network Scan Failed",
150+
"error_scan": "Failed to scan for WiFi networks.",
151+
"error_timeout_title": "Provisioning Timeout",
152+
"error_timeout": "The device did not respond in time. Please try again.",
153+
"disconnected_title": "Device Disconnected",
154+
"disconnected": "The BLE device was disconnected.",
155+
"browser_unsupported": "Your browser does not support Web Bluetooth. Please use Chrome or Edge.",
156+
"description": "Provision WiFi credentials to Linux-based Meshtastic devices via Bluetooth.",
157+
"provision_success": "WiFi credentials applied successfully!",
158+
"show_password": "Show password",
159+
"hide_password": "Hide password"
160+
},
123161
"dfu": {
124162
"success_title": "DFU Mode",
125163
"success_message": "Device successfully entered DFU mode",

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"@nuxtjs/tailwindcss": "^6.14.0",
4646
"@types/file-saver": "^2.0.7",
4747
"@types/w3c-web-serial": "^1.0.8",
48+
"@types/web-bluetooth": "^0.0.21",
4849
"@vite-pwa/nuxt": "^1.1.0",
4950
"autoprefixer": "^10.4.23",
5051
"eslint": "^9.39.2",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)