feat(lefu): 迁移乐福体脂秤 SDK,接入设备扫描连接与 WiFi 配网

- 新增 lefu 服务层(扫描/连接/断开/保活重连/测量回调/WiFi 配网),事件回调支持多监听
- connectedDevice 接入真实蓝牙扫描连接,修复断线重连 UI 未同步、蓝牙/定位开关误判
- connectedWifi 接入真实 WiFi 配网,补连接状态校验与退出配网模式
- 新增 5 张设备图标并调整应用入口

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
17792275749
2026-08-20 15:57:16 +08:00
co-authored by Claude Opus 4.7
parent feb494080c
commit f53f11e6e9
14 changed files with 901 additions and 71 deletions
@@ -1,21 +1,30 @@
/** 展示用设备项 */
import { leFuService } from '../../lefu/index'
import type { ScannedDevice } from '../../lefu/index'
import type { RawDevice } from '../../lefu/types'
/** 渲染用的设备项(RawDevice 含复杂对象,不进 setData */
interface DeviceItem {
name: string
connected: boolean
}
/** 页面状态 */
type DeviceStatus = 'idle' | 'searching' | 'found' | 'empty'
/** 假设备数据 */
const MOCK_DEVICES: DeviceItem[] = [
{ name: '乐福体脂秤 Pro', connected: false },
{ name: '乐福体脂秤 Air', connected: false },
{ name: '乐福体脂秤 Mini', connected: false },
]
/** 扫描定时器 */
let _searchTimer: number | null = null
/** 扫描到的原始设备缓存 */
let scannedDevices: ScannedDevice[] = []
/** 扫描 / 断开回调的取消订阅句柄 */
let unsubDevicesList: (() => void) | null = null
let unsubDisconnected: (() => void) | null = null
let unsubDeviceConnect: (() => void) | null = null
/** 连接流程的清理句柄 */
let connectCleanup: {
timer: number | null
unsubState: (() => void) | null
unsubConnect: (() => void) | null
} | null = null
/** 一次性防重入标记 */
let modalLocked = false
let autoConnectTried = false
Page({
data: {
@@ -23,34 +32,33 @@ Page({
deviceList: [] as DeviceItem[],
},
onHide() {
this._clearSearchTimer()
},
onUnload() {
this._clearSearchTimer()
},
_clearSearchTimer() {
if (_searchTimer !== null) {
clearTimeout(_searchTimer)
_searchTimer = null
onShow() {
// 从其他页面返回时同步连接状态
if (!leFuService.isConnected) {
this._markAllDisconnected()
}
},
/** 点击「开始搜索 / 重新搜索」:模拟扫描 */
onStartSearch() {
this._clearSearchTimer()
this.setData({ status: 'searching', deviceList: [] })
_searchTimer = setTimeout(() => {
this.setData({
status: 'found',
deviceList: MOCK_DEVICES.map(item => ({ ...item })),
})
}, 1500)
onHide() {
leFuService.stopScan()
},
/** 点击整张卡片:已连接 → 跳转配网页;未连接 → 走连接流程 */
onUnload() {
leFuService.stopScan()
this._clearSubscriptions()
this._clearConnectCleanup()
},
/** 点击「开始 / 重新搜索」 */
async onStartSearch() {
if (modalLocked) return
scannedDevices = []
autoConnectTried = false
this.setData({ status: 'searching', deviceList: [] })
await this._requestPermissionAndScan()
},
/** 点击设备卡片:已连接跳配网,未连接走连接流程 */
onTapCard(e: WechatMiniprogram.TouchEvent) {
const connected = e.currentTarget.dataset.connected as boolean
if (connected) {
@@ -60,24 +68,220 @@ Page({
this.onConnect(e)
},
/** 模拟连接指定设备 */
/** 手动连接指定设备 */
onConnect(e: WechatMiniprogram.TouchEvent) {
const index = e.currentTarget.dataset.index as number
const list = this.data.deviceList.map((item, i) => ({
...item,
connected: i === index,
}))
this.setData({ deviceList: list })
this._connectByIndex(index, false)
},
/** 断开当前连接 */
onDisconnect() {
const list = this.data.deviceList.map(item => ({ ...item, connected: false }))
this.setData({ deviceList: list })
this._markAllDisconnected()
leFuService.disconnect()
},
/** 查看帮助 */
onOpenHelp() {
wx.showToast({ title: '请确保设备已开机且在附近', icon: 'none' })
},
/** 将所有卡片置为未连接 */
_markAllDisconnected() {
this.setData({
deviceList: this.data.deviceList.map(item => ({ ...item, connected: false })),
})
},
/** 连接成功后把对应设备卡片标记为已连接(覆盖手动连接与自动重连) */
_markConnected() {
const cached = wx.getStorageSync('connectDeviceInfo') as Record<string, any> | null
const name = leFuService.deviceInfo?.modelNumber
|| cached?.scaleDeviceName
|| cached?.name
|| cached?.deviceName
if (!name) return
this.setData({
deviceList: this.data.deviceList.map(item => ({ ...item, connected: item.name === name })),
})
},
_clearSubscriptions() {
unsubDevicesList?.()
unsubDisconnected?.()
unsubDeviceConnect?.()
unsubDevicesList = null
unsubDisconnected = null
unsubDeviceConnect = null
},
_clearConnectCleanup() {
if (!connectCleanup) return
if (connectCleanup.timer !== null) clearTimeout(connectCleanup.timer)
connectCleanup.unsubState?.()
connectCleanup.unsubConnect?.()
connectCleanup = null
},
/** 申请位置权限后打开蓝牙适配器(蓝牙权限由 openBluetoothAdapter 自动触发) */
async _requestPermissionAndScan() {
try {
const { authSetting } = await wx.getSetting()
const setting = authSetting as Record<string, boolean | undefined>
if (!setting['scope.userLocation']) {
await wx.authorize({ scope: 'scope.userLocation' })
}
} catch {
this._showLocationGuide()
return
}
await this._openAdapterAndScan()
},
async _openAdapterAndScan() {
try {
await wx.openBluetoothAdapter()
} catch (err: any) {
console.error('[connectedDevice] openBluetoothAdapter 失败:', err)
this.setData({ status: 'idle' })
this._handleAdapterError()
return
}
this._startScan()
},
/** 区分蓝牙/定位服务开关,给出准确提示 */
_handleAdapterError() {
try {
const setting = wx.getSystemSetting()
if (!setting.bluetoothEnabled) {
this._showBluetoothOff()
return
}
if (!setting.locationEnabled) {
this._showLocationOff()
return
}
} catch {
// 基础库不支持 wx.getSystemSetting 时,退化为通用提示
}
wx.showToast({ title: '蓝牙初始化失败,请重试', icon: 'none' })
},
_startScan() {
this._clearSubscriptions()
unsubDevicesList = leFuService.onDevicesList((devices) => {
scannedDevices = devices
if (!devices.length) {
this.setData({ status: 'empty', deviceList: [] })
return
}
const connectedName = this.data.deviceList.find(d => d.connected)?.name
this.setData({
status: 'found',
deviceList: devices.map(d => ({ name: d.name, connected: d.name === connectedName })),
})
this._tryAutoConnect()
})
unsubDisconnected = leFuService.onDisconnected(() => {
this._markAllDisconnected()
})
unsubDeviceConnect = leFuService.onDeviceConnect(() => {
this._markConnected()
})
leFuService.startScan()
},
/** 命中缓存设备时自动连接一次 */
_tryAutoConnect() {
if (autoConnectTried) return
const cached = wx.getStorageSync('connectDeviceInfo') as RawDevice | null
if (!cached?.deviceId) return
const index = scannedDevices.findIndex(d => d.raw.deviceId === cached.deviceId)
if (index < 0) return
autoConnectTried = true
this._connectByIndex(index, true)
},
/** 统一连接流程(手动 / 自动共用),手动连接成功后跳配网 */
_connectByIndex(index: number, isAuto: boolean) {
const scanned = scannedDevices[index]
if (!scanned) return
wx.showLoading({ title: '连接中...', mask: true })
leFuService.stopScan()
leFuService.connect(scanned.raw)
this._clearConnectCleanup()
connectCleanup = { timer: null, unsubState: null, unsubConnect: null }
const finish = () => {
if (connectCleanup?.timer !== null) clearTimeout(connectCleanup.timer)
connectCleanup?.unsubState?.()
connectCleanup?.unsubConnect?.()
connectCleanup = null
wx.hideLoading()
}
connectCleanup.timer = setTimeout(() => {
finish()
wx.showToast({ title: '连接超时', icon: 'none' })
}, 15000)
connectCleanup.unsubState = leFuService.onConnectState((state) => {
if (state === leFuService.BLUE_STATE.CONNECTFAILED) {
finish()
wx.showToast({ title: '连接失败,请重试', icon: 'none' })
}
})
connectCleanup.unsubConnect = leFuService.onDeviceConnect(() => {
finish()
wx.setStorageSync('connectDeviceInfo', { ...scanned.raw, scaleDeviceName: scanned.raw.name })
if (!isAuto) {
wx.navigateTo({ url: '/pages/connectedWifi/connectedWifi' })
}
})
},
_showBluetoothOff() {
wx.showModal({
title: '蓝牙未开启',
content: '请先开启手机蓝牙后重试',
showCancel: false,
confirmText: '知道了',
success: () => this.setData({ status: 'idle' }),
})
},
_showLocationOff() {
wx.showModal({
title: '定位服务未开启',
content: '安卓手机搜索蓝牙设备需开启定位服务,请前往系统设置开启后重试。',
showCancel: false,
confirmText: '知道了',
})
},
_showLocationGuide() {
modalLocked = true
wx.showModal({
title: '需要授权',
content: '小程序需要位置权限才能搜索蓝牙设备,请前往设置开启。',
confirmText: '去开启',
cancelText: '取消',
success: (res) => {
modalLocked = false
this.setData({ status: 'idle' })
if (res.confirm) {
wx.openSetting({
success: () => this.onStartSearch(),
fail: () => this.setData({ status: 'idle' }),
})
}
},
fail: () => {
modalLocked = false
this.setData({ status: 'idle' })
},
})
},
})
@@ -1,3 +1,10 @@
import { leFuService } from '../../lefu/index'
/** 连接状态事件的取消订阅句柄 */
let unsubDisconnected: (() => void) | null = null
let unsubDeviceConnect: (() => void) | null = null
/** 渲染用的 WiFi 项(在 SSID 基础上追加连接状态) */
interface WifiItem {
ssid: string
connected: boolean
@@ -6,17 +13,6 @@ interface WifiItem {
type WifiStatus = 'idle' | 'searching' | 'list' | 'empty' | 'password' | 'configuring'
type BleStatus = 'connected' | 'failed' | 'connecting'
/** 假 WiFi 列表 */
const MOCK_WIFI: WifiItem[] = [
{ ssid: 'TP-LINK_5G_8A3C', connected: false },
{ ssid: 'Home_WiFi', connected: false },
{ ssid: 'Xiaomi_1234', connected: false },
]
/** 搜索 / 配网定时器 */
let _searchTimer: number | null = null
let _configTimer: number | null = null
Page({
data: {
status: 'idle' as WifiStatus,
@@ -30,31 +26,74 @@ Page({
},
onLoad() {
this._initDeviceInfo()
this._subscribeDisconnect()
this.onStartSearch()
},
onUnload() {
wx.hideLoading()
this._clearTimers()
unsubDisconnected?.()
unsubDeviceConnect?.()
unsubDisconnected = null
unsubDeviceConnect = null
leFuService.exitWifiConfig()
},
_clearTimers() {
if (_searchTimer !== null) { clearTimeout(_searchTimer); _searchTimer = null }
if (_configTimer !== null) { clearTimeout(_configTimer); _configTimer = null }
/** 订阅连接状态事件:断开 / 重连时同步状态到 UI */
_subscribeDisconnect() {
unsubDisconnected?.()
unsubDeviceConnect?.()
unsubDisconnected = leFuService.onDisconnected(() => {
this.setData({ bleStatus: 'failed' })
})
unsubDeviceConnect = leFuService.onDeviceConnect(() => {
this.setData({ bleStatus: 'connected' })
})
},
/** 模拟获取 WiFi 列表 */
onStartSearch() {
if (_searchTimer !== null) { clearTimeout(_searchTimer); _searchTimer = null }
/** 初始化设备信息:读取缓存设备名 + 同步蓝牙连接状态 */
_initDeviceInfo() {
const cached = wx.getStorageSync('connectDeviceInfo') as Record<string, any> | null
const deviceName = cached?.scaleDeviceName || cached?.name
if (deviceName) {
this.setData({ deviceName })
}
this.setData({ bleStatus: leFuService.isConnected ? 'connected' : 'failed' })
},
/** 校验设备连接状态:未连接则同步状态 + 提示,返回是否可继续操作 */
_ensureConnected(): boolean {
if (leFuService.isConnected) return true
this.setData({ bleStatus: 'failed' })
wx.showToast({ title: '设备已断开,请重新连接', icon: 'none' })
return false
},
/** 通过设备协议获取周围 WiFi 列表 */
async onStartSearch() {
if (!this._ensureConnected()) {
this.setData({ status: 'idle', wifiList: [] })
return
}
this.setData({ status: 'searching', wifiList: [] })
wx.showLoading({ title: '正在获取 WiFi 列表...', mask: true })
_searchTimer = setTimeout(() => {
try {
const list = await leFuService.getWifiList()
wx.hideLoading()
if (!list.length) {
this.setData({ status: 'empty', wifiList: [] })
return
}
this.setData({
status: 'list',
wifiList: MOCK_WIFI.map(item => ({ ...item })),
wifiList: list.map(item => ({ ssid: item.ssid, connected: false })),
})
}, 1200)
} catch {
wx.hideLoading()
this.setData({ status: 'empty', wifiList: [] })
wx.showToast({ title: '获取 WiFi 列表失败', icon: 'none' })
}
},
onRefresh() {
@@ -62,6 +101,7 @@ Page({
},
onTapWifi(e: WechatMiniprogram.TouchEvent) {
if (!this._ensureConnected()) return
const ssid = e.currentTarget.dataset.ssid as string
const target = this.data.wifiList.find(item => item.ssid === ssid)
if (!target || target.connected) return
@@ -80,13 +120,15 @@ Page({
this.setData({ status: 'list', selectedSsid: '', selectWifiPWD: '' })
},
/** 模拟配网 */
onConfirmPwd() {
/** 下发配网指令,成功后标记对应 WiFi 已连接 */
async onConfirmPwd() {
if (!this._ensureConnected()) return
const { selectedSsid, selectWifiPWD } = this.data
if (!selectedSsid) return
this.setData({ status: 'configuring' })
wx.showLoading({ title: '正在配网...', mask: true })
_configTimer = setTimeout(() => {
try {
await leFuService.configWifi(selectedSsid, selectWifiPWD)
wx.hideLoading()
this.setData({
status: 'list',
@@ -95,11 +137,21 @@ Page({
selectedSsid: '',
selectWifiPWD: '',
})
}, 1500)
wx.showToast({ title: '配网成功', icon: 'success' })
} catch (err: any) {
wx.hideLoading()
this.setData({ selectedSsid: '', selectWifiPWD: '' })
wx.showToast({ title: err?.message || '配网失败', icon: 'none' })
// 配网失败:延时 1.5s 后清空数据并重新获取 WiFi 列表
setTimeout(() => {
this.onStartSearch()
}, 1500)
}
},
onConfirm() {
if (!this.data.hasConnected) return
wx.navigateTo({ url: '/pages/supplementPersonal/supplementPersonal' })
if (!this._ensureConnected()) return
wx.navigateTo({ url: '/pages/equipment/equipment' })
},
})