最近在做一个蓝牙 App,需要连接 JDY-31 模块,实现手机和单片机之间的数据通信。刚开始我按照 uni-app 常见的 BLE 蓝牙流程去写,使用了 openBluetoothAdapter、startBluetoothDevicesDiscovery、createBLEConnection、getBLEDeviceServices、notifyBLECharacteristicValueChange 等接口。
结果发现一个问题:手机上的蓝牙串口助手可以搜索到 JDY-31,但自己写的 App 却一直搜不到。
后来排查后发现,问题并不是代码写错了,而是 JDY-31 和 BLE 蓝牙不是同一种协议
一、JDY-31 不是 BLE 蓝牙
uni-app 官方提供的蓝牙接口主要是针对 BLE 低功耗蓝牙 的,比如 JDY-23、HM-10、AT-09 这类模块。
而 JDY-31 属于 经典蓝牙 SPP 串口模块,更接近 HC-05、HC-06 这一类。它走的是经典蓝牙串口协议,不存在 BLE 里的服务 UUID、特征值 UUID、notify 监听这些概念。
所以如果使用 BLE 接口去搜索 JDY-31,就会出现:
蓝牙串口助手能搜到 uni-app BLE 接口搜不到
二、解决思路
既然 JDY-31 是经典蓝牙 SPP,就不能再走 BLE 流程,而是需要在 Android App 端调用原生蓝牙能力,通过 BluetoothSocket 建立串口连接。
整体流程如下:
- 申请蓝牙和定位权限
- 获取 Android 蓝牙适配器
- 搜索附近经典蓝牙设备
- 读取已经配对过的蓝牙设备
- 筛选名称中包含 JDY、JDY31 或 JDY-31 的设备
- 点击设备后,如果未配对,先触发系统配对
- 配对成功后,通过 SPP UUID 创建 socket 连接
- 获取输入流和输出流
- 定时读取串口数据并显示到页面
SPP 常用 UUID 是:00001101-0000-1000-8000-00805F9B34FB
三.关键权限
经典蓝牙在 Android 上需要配置相关权限,尤其是 Android 12 以后,蓝牙权限更加严格。
manifest.json 中需要加入类似权限:
cpp
"<uses-permission android:name=\"android.permission.BLUETOOTH\"/>",
"<uses-permission android:name=\"android.permission.BLUETOOTH_ADMIN\"/>",
"<uses-permission android:name=\"android.permission.BLUETOOTH_SCAN\"/>",
"<uses-permission android:name=\"android.permission.BLUETOOTH_CONNECT\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\"/>"

四.接口说明
1. 搜索附近和已配对设备
入口方法:
searchJdy31()
它会做几件事:
this.deviceList = [] this.showDeviceList = true this.searching = true this.statusText = '正在搜索JDY31...'然后申请权限:
requestBluetoothPermission()权限包括:
ACCESS_FINE_LOCATION BLUETOOTH BLUETOOTH_ADMIN BLUETOOTH_SCAN BLUETOOTH_CONNECT之后调用:
startClassicSearch()
这个方法会:
- 获取手机蓝牙适配器 BluetoothAdapter
- 读取已配对设备 getBondedDevices()
- 开始搜索附近设备 startDiscovery()
- 通过广播接收附近发现的蓝牙设备
2. 筛选 JDY31
搜索到设备后,会进入:
addJdy31Device(name, address, bondState)内部会判断设备名:
isJdy31Name(name)目前只显示名称包含这些字段的设备:
JDY JDY31 JDY-31如果你的模块改过名字,比如叫 BT05,这里就需要把过滤条件加进去。
3. 连接 JDY31
点击设备后调用:
connectJdy31(item)如果设备已经配对,直接连接。
如果设备没有配对,会先调用:
device.createBond()这时手机会弹出系统配对框,JDY31 常见密码是:
1234或:
0000配对成功后,广播监听会自动继续连接。
4. 打开串口 Socket
真正建立串口连接的是:
openJdy31Socket(device, adapter)这里使用经典蓝牙 SPP UUID:
const sppUuid = UUID.fromString('00001101-0000-1000-8000-00805F9B34FB')然后创建 socket:
this.socket = device.createRfcommSocketToServiceRecord(sppUuid) this.socket.connect()连接成功后获取输入输出流:
this.inputStream = this.socket.getInputStream() this.outputStream = this.socket.getOutputStream()这两个流就是后面接收和发送串口数据的核心。
5. 接收数据
连接成功后调用:
listenJdy31()它内部用定时器轮询串口输入流:
const count = this.inputStream.available()如果有数据,就逐字节读取:
data.push(this.inputStream.read() & 0xff)然后显示两种格式:
this.messageHex = data.map(item => ('00' + item.toString(16)).slice(-2)).join('') this.message = String.fromCharCode.apply(null, data)所以页面上能看到:
- 普通字符内容
- 十六进制内容
6. 发送字符
新增按钮直接调用:
sendJdy31('A') sendJdy31('B')发送函数是:
sendJdy31(msg) { for (let i = 0; i < msg.length; i++) { this.outputStream.write(msg.charCodeAt(i)) } this.outputStream.flush() }发送 'A' 时,实际发出的字节是:
0x41发送 'B' 时,实际发出的字节是:
0x42STM32 或其他单片机串口端可以按普通串口字符接收:
'A' 'B'
7. 断开连接
断开按钮调用:
disconnectJdy31()它会关闭:
inputStream outputStream socket readTimer核心清理方法是:
closeSocket()断开后状态显示:
已断开连接8. 重新连接
重新连接按钮调用:
reconnectJdy31()连接成功过一次后,会保存上次设备:
this.lastDevice = { deviceId: data.deviceId, name: data.name || data.deviceId, bondState: data.bondState || 12, stateText: data.stateText || '已配对' }重新连接时不重新搜索,直接用上一次地址:
this.connectJdy31(this.lastDevice)
五.完整代码
cpp
<template>
<view class="page">
<view class="top">
<view class="title">JDY31连接</view>
<view class="status" :class="{ connected: connected }">{{ statusText }}</view>
</view>
<view class="search-box">
<button class="search-btn" :loading="searching" @click="searchJdy31">
{{ searching ? '正在搜索JDY31...' : '搜索附近/已配对JDY31' }}
</button>
<view class="action-row">
<button class="action-btn disconnect" :disabled="!connected" @click="disconnectJdy31">断开连接</button>
<button class="action-btn reconnect" :loading="connecting" :disabled="!lastDevice" @click="reconnectJdy31">重新连接</button>
</view>
<view class="action-row">
<button class="action-btn send-a" :disabled="!connected" @click="sendJdy31('A')">发送字符A</button>
<button class="action-btn send-b" :disabled="!connected" @click="sendJdy31('B')">发送字符B</button>
</view>
<view class="selected" v-if="connected && connectedDeviceName">
已连接:{{ connectedDeviceName }}
</view>
</view>
<view class="msg_x">
<view class="msg_txt">
监听到的内容:{{ message || '--' }}
</view>
<view class="msg_hex">
监听到的内容(十六进制):{{ messageHex || '--' }}
</view>
</view>
<view class="device-mask" v-if="showDeviceList" @click="closeDeviceList">
<view class="device-panel" @click.stop>
<view class="panel-head">
<text>JDY31设备</text>
<text class="panel-tip">{{ searching ? '搜索中' : '已停止' }}</text>
</view>
<scroll-view scroll-y class="device-list">
<view
class="device-item"
v-for="item in deviceList"
:key="item.deviceId"
@click="connectJdy31(item)"
>
<view class="device-line">
<text class="device-name">{{ item.name || 'JDY31' }}</text>
<text class="device-type">{{ item.stateText }}</text>
</view>
<view class="device-id">{{ item.deviceId }}</view>
</view>
<view class="empty" v-if="deviceList.length === 0">
没有发现JDY31,请确认模块已上电并处于可连接状态
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
deviceList: [],
deviceId: '',
message: '',
messageHex: '',
showDeviceList: false,
searching: false,
connecting: false,
connected: false,
connectedDeviceName: '',
lastDevice: null,
statusText: '未连接',
adapter: null,
receiver: null,
bondReceiver: null,
pendingConnectDevice: null,
socket: null,
inputStream: null,
outputStream: null,
readTimer: null
}
},
onUnload() {
this.stopSearch()
this.unregisterBondReceiver()
this.closeSocket()
},
methods: {
searchJdy31() {
this.deviceList = []
this.showDeviceList = true
this.searching = true
this.statusText = '正在搜索JDY31...'
this.requestBluetoothPermission(() => {
this.startClassicSearch()
})
},
requestBluetoothPermission(done) {
if (typeof plus === 'undefined' || !plus.android || !plus.android.requestPermissions) {
done()
return
}
const permissions = [
'android.permission.ACCESS_FINE_LOCATION',
'android.permission.BLUETOOTH',
'android.permission.BLUETOOTH_ADMIN',
'android.permission.BLUETOOTH_SCAN',
'android.permission.BLUETOOTH_CONNECT'
]
plus.android.requestPermissions(permissions, () => {
done()
}, () => {
done()
})
},
startClassicSearch() {
if (typeof plus === 'undefined' || !plus.android) {
this.searching = false
this.statusText = '经典蓝牙只支持Android App'
uni.showToast({
title: '只支持Android App',
icon: 'none'
})
return
}
try {
const BluetoothAdapter = plus.android.importClass('android.bluetooth.BluetoothAdapter')
const BluetoothDevice = plus.android.importClass('android.bluetooth.BluetoothDevice')
const IntentFilter = plus.android.importClass('android.content.IntentFilter')
const main = plus.android.runtimeMainActivity()
this.adapter = BluetoothAdapter.getDefaultAdapter()
if (!this.adapter) {
this.searching = false
this.statusText = '手机不支持蓝牙'
return
}
plus.android.importClass(this.adapter)
if (!this.adapter.isEnabled()) {
this.adapter.enable()
}
this.addBondedJdy31Devices()
this.unregisterReceiver()
this.receiver = plus.android.implements('io.dcloud.feature.internal.reflect.BroadcastReceiver', {
onReceive: (context, intent) => {
plus.android.importClass(intent)
const action = intent.getAction()
if (action === BluetoothDevice.ACTION_FOUND) {
const device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
plus.android.importClass(device)
this.addJdy31Device(device.getName(), device.getAddress(), device.getBondState())
}
if (action === BluetoothAdapter.ACTION_DISCOVERY_FINISHED && this.searching) {
this.searching = false
this.statusText = this.deviceList.length > 0 ? '请选择JDY31' : '未发现JDY31'
}
}
})
const filter = new IntentFilter()
filter.addAction(BluetoothDevice.ACTION_FOUND)
filter.addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED)
main.registerReceiver(this.receiver, filter)
if (this.adapter.isDiscovering()) {
this.adapter.cancelDiscovery()
}
this.adapter.startDiscovery()
console.log('开始搜索JDY31')
} catch (err) {
console.log('搜索JDY31失败')
console.error(err)
this.searching = false
this.statusText = '搜索失败'
uni.showToast({
title: '搜索失败',
icon: 'error'
})
}
},
addBondedJdy31Devices() {
try {
const bondedDevices = this.adapter.getBondedDevices()
const iterator = bondedDevices.iterator()
plus.android.importClass(iterator)
while (iterator.hasNext()) {
const device = iterator.next()
plus.android.importClass(device)
this.addJdy31Device(device.getName(), device.getAddress(), device.getBondState())
}
} catch (err) {
console.log('读取已配对设备失败')
console.error(err)
}
},
addJdy31Device(name, address, bondState) {
if (!address || !this.isJdy31Name(name)) {
return
}
const stateText = bondState === 12 ? '已配对' : '附近'
const exists = this.deviceList.find(item => item.deviceId === address)
if (exists) {
exists.name = name || exists.name
exists.bondState = bondState
exists.stateText = stateText
} else {
this.deviceList.push({
deviceId: address,
name: name || 'JDY31',
bondState: bondState,
stateText: stateText
})
}
},
isJdy31Name(name) {
if (!name) {
return false
}
const upperName = String(name).toUpperCase()
return upperName.indexOf('JDY') !== -1 || upperName.indexOf('JDY-31') !== -1 || upperName.indexOf('JDY31') !== -1
},
closeDeviceList() {
this.showDeviceList = false
this.stopSearch()
if (!this.connected && !this.connecting) {
this.statusText = '已停止搜索'
}
},
connectJdy31(data) {
if (this.connecting) {
return
}
if (typeof plus === 'undefined' || !plus.android) {
uni.showToast({
title: '只支持Android App',
icon: 'none'
})
return
}
try {
console.log(data)
this.connecting = true
this.deviceId = data.deviceId
this.connectedDeviceName = data.name || data.deviceId
this.lastDevice = {
deviceId: data.deviceId,
name: data.name || data.deviceId,
bondState: data.bondState || 12,
stateText: data.stateText || '已配对'
}
this.showDeviceList = false
this.stopSearch()
this.statusText = '正在连接JDY31...'
const BluetoothAdapter = plus.android.importClass('android.bluetooth.BluetoothAdapter')
const BluetoothDevice = plus.android.importClass('android.bluetooth.BluetoothDevice')
const adapter = this.adapter || BluetoothAdapter.getDefaultAdapter()
plus.android.importClass(adapter)
const device = adapter.getRemoteDevice(data.deviceId)
plus.android.importClass(device)
adapter.cancelDiscovery()
if (device.getBondState && device.getBondState() !== BluetoothDevice.BOND_BONDED) {
this.statusText = '请在弹窗中完成配对...'
this.pendingConnectDevice = data
this.registerBondReceiver(data)
const started = device.createBond()
if (!started) {
this.unregisterBondReceiver()
this.connecting = false
this.statusText = '配对失败'
uni.showToast({
title: '配对失败',
icon: 'error'
})
}
return
}
this.openJdy31Socket(device, adapter)
} catch (err) {
console.log('JDY31连接失败')
console.error(err)
this.closeSocket()
this.connected = false
this.connecting = false
this.connectedDeviceName = ''
this.statusText = '连接失败'
uni.showToast({
title: '连接失败,请先配对',
icon: 'none'
})
}
},
registerBondReceiver(data) {
this.unregisterBondReceiver()
try {
const BluetoothDevice = plus.android.importClass('android.bluetooth.BluetoothDevice')
const IntentFilter = plus.android.importClass('android.content.IntentFilter')
const BluetoothAdapter = plus.android.importClass('android.bluetooth.BluetoothAdapter')
const main = plus.android.runtimeMainActivity()
this.bondReceiver = plus.android.implements('io.dcloud.feature.internal.reflect.BroadcastReceiver', {
onReceive: (context, intent) => {
plus.android.importClass(intent)
const action = intent.getAction()
if (action !== BluetoothDevice.ACTION_BOND_STATE_CHANGED) {
return
}
const device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
plus.android.importClass(device)
const address = device.getAddress()
const state = device.getBondState()
if (address !== data.deviceId) {
return
}
if (state === BluetoothDevice.BOND_BONDED) {
this.unregisterBondReceiver()
this.statusText = '配对成功,正在连接...'
const adapter = this.adapter || BluetoothAdapter.getDefaultAdapter()
plus.android.importClass(adapter)
this.openJdy31Socket(device, adapter)
} else if (state === BluetoothDevice.BOND_NONE) {
this.unregisterBondReceiver()
this.connecting = false
this.connectedDeviceName = ''
this.statusText = '配对失败'
uni.showToast({
title: '配对失败',
icon: 'error'
})
}
}
})
const filter = new IntentFilter()
filter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
main.registerReceiver(this.bondReceiver, filter)
} catch (err) {
console.log('注册配对监听失败')
console.error(err)
}
},
unregisterBondReceiver() {
if (!this.bondReceiver || typeof plus === 'undefined' || !plus.android) {
return
}
try {
const main = plus.android.runtimeMainActivity()
main.unregisterReceiver(this.bondReceiver)
} catch (err) {
console.log('注销配对监听失败')
console.error(err)
}
this.bondReceiver = null
this.pendingConnectDevice = null
},
openJdy31Socket(device, adapter) {
try {
const UUID = plus.android.importClass('java.util.UUID')
const sppUuid = UUID.fromString('00001101-0000-1000-8000-00805F9B34FB')
this.closeSocket()
this.socket = device.createRfcommSocketToServiceRecord(sppUuid)
plus.android.importClass(this.socket)
adapter.cancelDiscovery()
this.socket.connect()
this.inputStream = this.socket.getInputStream()
this.outputStream = this.socket.getOutputStream()
plus.android.importClass(this.inputStream)
plus.android.importClass(this.outputStream)
this.connected = true
this.connecting = false
this.connectedDeviceName = this.connectedDeviceName || this.deviceId
this.statusText = 'JDY31已连接,监听中'
uni.showToast({
title: '连接成功'
})
this.listenJdy31()
} catch (err) {
console.log('打开JDY31串口失败')
console.error(err)
this.closeSocket()
this.connected = false
this.connecting = false
this.connectedDeviceName = ''
this.statusText = '连接失败'
uni.showToast({
title: '连接失败',
icon: 'error'
})
}
},
stopSearch() {
if (!this.searching) {
return
}
this.searching = false
try {
if (this.adapter) {
plus.android.importClass(this.adapter)
if (this.adapter.isDiscovering()) {
this.adapter.cancelDiscovery()
}
}
} catch (err) {
console.log('停止搜索失败')
console.error(err)
}
this.unregisterReceiver()
},
unregisterReceiver() {
if (!this.receiver || typeof plus === 'undefined' || !plus.android) {
return
}
try {
const main = plus.android.runtimeMainActivity()
main.unregisterReceiver(this.receiver)
} catch (err) {
console.log('注销广播失败')
console.error(err)
}
this.receiver = null
},
listenJdy31() {
if (this.readTimer) {
clearInterval(this.readTimer)
this.readTimer = null
}
this.readTimer = setInterval(() => {
try {
if (!this.inputStream) {
return
}
const count = this.inputStream.available()
if (count <= 0) {
return
}
const data = []
for (let i = 0; i < count; i++) {
data.push(this.inputStream.read() & 0xff)
}
this.messageHex = data.map(item => ('00' + item.toString(16)).slice(-2)).join('')
this.message = String.fromCharCode.apply(null, data)
console.log('JDY31收到数据', this.messageHex, this.message)
} catch (err) {
console.log('JDY31读取失败')
console.error(err)
this.closeSocket()
this.connected = false
this.connectedDeviceName = ''
this.statusText = 'JDY31已断开'
}
}, 120)
},
closeSocket() {
if (this.readTimer) {
clearInterval(this.readTimer)
this.readTimer = null
}
try {
if (this.inputStream) {
this.inputStream.close()
}
} catch (err) {
console.log(err)
}
try {
if (this.outputStream) {
this.outputStream.close()
}
} catch (err) {
console.log(err)
}
try {
if (this.socket) {
this.socket.close()
}
} catch (err) {
console.log(err)
}
this.inputStream = null
this.outputStream = null
this.socket = null
},
disconnectJdy31() {
this.stopSearch()
this.unregisterBondReceiver()
this.closeSocket()
this.connected = false
this.connecting = false
this.connectedDeviceName = ''
this.statusText = '已断开连接'
uni.showToast({
title: '已断开',
icon: 'none'
})
},
reconnectJdy31() {
if (!this.lastDevice) {
uni.showToast({
title: '没有可重连设备',
icon: 'none'
})
return
}
this.disconnectForReconnect()
this.connectJdy31(this.lastDevice)
},
disconnectForReconnect() {
this.stopSearch()
this.unregisterBondReceiver()
this.closeSocket()
this.connected = false
this.connecting = false
this.connectedDeviceName = ''
},
sendJdy31(msg) {
if (!this.outputStream) {
uni.showToast({
title: 'JDY31未连接',
icon: 'none'
})
return
}
try {
for (let i = 0; i < msg.length; i++) {
this.outputStream.write(msg.charCodeAt(i))
}
this.outputStream.flush()
} catch (err) {
console.error(err)
uni.showToast({
title: '发送失败',
icon: 'error'
})
}
}
}
}
</script>
<style>
.page {
min-height: 100vh;
padding: 30rpx;
box-sizing: border-box;
background: #f5f7fb;
}
.top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 28rpx;
}
.title {
font-size: 40rpx;
font-weight: 700;
color: #111827;
}
.status {
max-width: 360rpx;
padding: 10rpx 18rpx;
border-radius: 8rpx;
font-size: 24rpx;
color: #4b5563;
background: #e5e7eb;
}
.status.connected {
color: #047857;
background: #d1fae5;
}
.search-box {
margin-bottom: 24rpx;
}
.search-btn {
height: 88rpx;
line-height: 88rpx;
border-radius: 8rpx;
color: #fff;
background: #2563eb;
font-size: 30rpx;
}
.action-row {
display: flex;
gap: 18rpx;
margin-top: 18rpx;
}
.action-btn {
flex: 1;
height: 76rpx;
line-height: 76rpx;
border-radius: 8rpx;
font-size: 28rpx;
color: #fff;
}
.action-btn.disconnect {
background: #dc2626;
}
.action-btn.reconnect {
background: #059669;
}
.action-btn.send-a {
background: #7c3aed;
}
.action-btn.send-b {
background: #ea580c;
}
.action-btn[disabled] {
color: #9ca3af;
background: #e5e7eb;
}
.selected {
margin-top: 18rpx;
font-size: 26rpx;
color: #374151;
}
.msg_x {
border: 2rpx solid #10b981;
border-radius: 8rpx;
background: #fff;
box-sizing: border-box;
padding: 24rpx;
}
.msg_x .msg_txt {
margin-bottom: 20rpx;
}
.msg_txt,
.msg_hex {
font-size: 28rpx;
color: #111827;
word-break: break-all;
}
.device-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 99;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 160rpx;
background: rgba(17, 24, 39, 0.36);
}
.device-panel {
width: 690rpx;
max-height: 760rpx;
border-radius: 8rpx;
overflow: hidden;
background: #fff;
box-shadow: 0 12rpx 36rpx rgba(15, 23, 42, 0.22);
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx;
border-bottom: 1rpx solid #e5e7eb;
font-size: 30rpx;
font-weight: 600;
color: #111827;
}
.panel-tip {
font-size: 24rpx;
font-weight: 400;
color: #6b7280;
}
.device-list {
height: 600rpx;
}
.device-item {
padding: 22rpx 24rpx;
border-bottom: 1rpx solid #edf0f4;
background: #fff;
}
.device-item:active {
background: #eff6ff;
}
.device-line {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8rpx;
}
.device-name {
flex: 1;
font-size: 30rpx;
color: #111827;
}
.device-type {
min-width: 96rpx;
padding: 4rpx 10rpx;
border-radius: 8rpx;
text-align: center;
font-size: 22rpx;
color: #047857;
background: #d1fae5;
}
.device-id {
font-size: 22rpx;
color: #6b7280;
word-break: break-all;
}
.empty {
padding: 80rpx 20rpx;
text-align: center;
color: #6b7280;
font-size: 26rpx;
}
</style>
六.实现效果
说明:这个只是dome接收数据 和发送的格式需要和你自己单片机的一样看上面接口介绍改就行


七.踩坑总结
这次主要踩了几个坑:
- JDY-31 不是 BLE,不能用 BLE 接口连接。
- 蓝牙串口助手能搜到,不代表 uni-app BLE 接口也能搜到。
- 经典蓝牙 SPP 需要 Android 原生能力。
- 第一次连接通常必须先配对。
- Android 12 以后必须注意 BLUETOOTH_SCAN 和 BLUETOOTH_CONNECT 权限。
- 修改 manifest.json 后,需要重新运行或重新打包,否则权限可能不生效。
- 如果 JDY-31 名称被改


