通过 Chrome 浏览器向 Shelly 设备发送 BLE RPC(离线调试)
本页介绍如何使用桌面版 Chrome(Mac/Windows/Linux)通过 Web Bluetooth 向 Shelly 设备发送 BLE JSON‑RPC 请求并接收响应,适用于需要本地离线调试或无需 Cloud 的场景。
LIVE DEMO https://faq.shellyiot.cn/ble
主要要点:
前提条件
- 你的电脑必须支持蓝牙(BLE)。
- 使用桌面版 Chrome,且浏览器需支持 Web Bluetooth API(某些历史版本需在地址栏启用:
chrome://flags/#enable-web-bluetooth)。 - 页面必须在安全上下文中运行(HTTPS 或
localhost),浏览器会提示授权访问蓝牙设备。 - 目标设备需启用 BLE RPC,已 Cloud 绑定或禁用 BLE RPC 的设备将无法使用本方法。
快速上手
- 打开本页面并点击“选择设备”,允许 Chrome 访问蓝牙并选择你的 Shelly 设备。
- 连接成功后,可先调用
Shelly.ListMethods获取设备支持的 RPC 列表。 - 选择想要调用的 RPC(例如
Shelly.GetStatus、Shelly.GetConfig、Shelly.Reboot),填写 JSON 参数,点击“Call RPC” 查看响应。
工作原理(简述)
- 客户端先向 TX 控制特征写入 4 字节的总长度,然后将 JSON 数据按 MTU 分片写入数据特征;设备通过 RX 控制特征的通知或通过读取数据特征返回结果。代码中实现了通知/轮询两种读取方式,并支持 JWT 特征用于认证。
- 有关 RPC 方法与参数,请参考官方 Integrator API 文档: Shelly Integrator API
常见问题与排查建议
- 无法发现设备:确认系统蓝牙已开启,且设备未被其他客户端占用或已进入可被发现状态。
- 权限被拒绝:在浏览器地址栏或系统设置中允许网站访问蓝牙设备;可以尝试刷新页面或重启浏览器。
- 连接后没有响应:检查设备固件是否支持 BLE RPC、尝试重启设备并在控制台查看日志。
- 仅在 HTTPS/localhost 有效:在开发调试时请使用 localhost 或启用 HTTPS,否则 Web Bluetooth 将被阻止。
安全提示
- 本工具用于本地调试,请勿在不受信任的网络或页面上执行敏感操作。对需要认证的 RPC(例如改变配置、重启等)请谨慎使用 auth/JWT,避免泄露凭据。
下面是示例实现与页面代码:
shelly-ble-controller.ts
typescript
type RequestDeviceOptions = Parameters<Bluetooth['requestDevice']>[0]
const SHELLY = {
BLU_DEVICES_REG:
/^(SBDI|SBBT|SBDW|SBHT|SBMO|SBDS|SBWS|SBRC|SBSM|SMSN)-\w|^BLU\s/,
UUID_PROVISION_DATA_LO: '0c31d671-5262-4620-87f3-51851c367cec',
UUID_PROVISION_DATA_HI: '55bef871-f611-46f5-b498-7b9cc9759927',
GATT_SERVICE_UUID: '5f6d4f53-5f52-5043-5f53-56435f49445f',
GATT_SERVICE_UUID_BLUE: 'de8a5aac-a99b-c315-0c80-60d4cbb51225', //blue tooth gatt service
RPC_CHAR_TX_CTL_UUID: '5f6d4f53-5f52-5043-5f74-785f63746c5f', //W_UUID
RPC_CHAR_DATA_UUID: '5f6d4f53-5f52-5043-5f64-6174615f5f5f', //RW_UUID
RPC_CHAR_RX_CTL_UUID: '5f6d4f53-5f52-5043-5f72-785f63746c5f', //READ_NOTIFY_UUID
RPC_CHAR_RX_JWT_UUID: '0c31d671-5262-4620-87f3-51851c367cec', //JWT Characteristic
ALLTERCO_MFID: 0x0ba9,
MTU_SIZE: 512, // Typical BLE MTU size, adjust if needed
} as const
interface ShellyRpcRequest {
id: number
method: string
params?: any
auth?: string
}
interface ShellyRpcResponse {
id: number
src: string
dst: string
result?: any
error?: object
}
function getExpectedLength(data: DataView): number {
return data.getUint32(0, false)
}
export class ShellyBleController {
private device?: BluetoothDevice
private server?: BluetoothRemoteGATTServer
private service?: BluetoothRemoteGATTService
private txCtlCharacteristic?: BluetoothRemoteGATTCharacteristic //write control
private dataCharacteristic?: BluetoothRemoteGATTCharacteristic
private rxCtlCharacteristic?: BluetoothRemoteGATTCharacteristic //receive control
private isRxSupportNotify: boolean = false
private requestId: number = 1
private callRpcTimeout = 1000 * 15
private pendingRequests: Map<
number,
{
resolve: (value: ShellyRpcResponse) => void
reject: (reason: Error) => void
timeoutThreadID: number
}
> = new Map()
get deviceName(): string {
return this.device?.name || 'unknown'
}
/**
* Connect to a Shelly BLE device
*/
static async connect(
prefix?: string
): Promise<ShellyBleController> {
const instance = new ShellyBleController()
try {
// Request device with fallback options for better browser compatibility
const requestOptions: RequestDeviceOptions = {
filters: [
{
namePrefix: prefix,
manufacturerData: [
{ companyIdentifier: SHELLY.ALLTERCO_MFID },
],
},
],
optionalServices: [
SHELLY.GATT_SERVICE_UUID,
SHELLY.GATT_SERVICE_UUID_BLUE,
],
}
instance.device =
await navigator.bluetooth.requestDevice(requestOptions)
console.log(`Connecting to ${instance.device.name}...`)
// Connect to GATT server
instance.server = await instance.device.gatt!.connect()
console.log('GATT Server connected')
if (SHELLY.BLU_DEVICES_REG.test(instance.device.name || '')) {
instance.service = await instance.server.getPrimaryService(
SHELLY.GATT_SERVICE_UUID_BLUE
)
} else {
//blue RPC service
instance.service = await instance.server.getPrimaryService(
SHELLY.GATT_SERVICE_UUID
)
}
// Get characteristics
instance.txCtlCharacteristic =
await instance.service.getCharacteristic(
SHELLY.RPC_CHAR_TX_CTL_UUID
)
instance.dataCharacteristic =
await instance.service.getCharacteristic(
SHELLY.RPC_CHAR_DATA_UUID
)
instance.rxCtlCharacteristic =
await instance.service.getCharacteristic(
SHELLY.RPC_CHAR_RX_CTL_UUID
)
console.log('All characteristics found')
console.log(
`rx read supported: ${instance.rxCtlCharacteristic.properties.read}`
)
await instance.rxCtlCharacteristic
.startNotifications()
.then(() => {
console.log('RX Control notifications started')
instance.isRxSupportNotify = true
})
.catch((error) => {
console.warn(
'RX Control notifications not supported:',
error
)
instance.isRxSupportNotify = false
})
console.log(`isRxSupportNotify: ${instance.isRxSupportNotify}`)
if (instance.isRxSupportNotify) {
console.log('Setting up RX Control notification handler')
instance.rxCtlCharacteristic.addEventListener(
'characteristicvaluechanged',
instance.onRxCtlNotification
)
}
// Handle disconnection
instance.device.addEventListener(
'gattserverdisconnected',
instance.onDisconnect
)
return instance
} catch (error) {
console.error('Connection failed:', error)
throw error
}
}
/**
* Disconnect from the device
*/
async disconnect(): Promise<void> {
if (this.rxCtlCharacteristic) {
try {
await this.rxCtlCharacteristic.stopNotifications()
this.rxCtlCharacteristic.removeEventListener(
'characteristicvaluechanged',
this.onRxCtlNotification
)
} catch (e) {
console.warn(
'Failed to stop RX notifications or remove handler:',
e
)
}
}
if (this.dataCharacteristic) {
try {
await this.dataCharacteristic.stopNotifications()
} catch (e) {
console.warn(
'Failed to stop data notifications or remove handler:',
e
)
}
}
if (this.device) {
try {
this.device.removeEventListener(
'gattserverdisconnected',
this.onDisconnect
)
} catch (e) {
console.warn('Failed to remove device disconnect handler:', e)
}
}
if (this.server?.connected) {
this.server.disconnect()
}
this.device = undefined
this.server = undefined
this.service = undefined
this.txCtlCharacteristic = undefined
this.dataCharacteristic = undefined
this.rxCtlCharacteristic = undefined
// Reject all pending requests
this.pendingRequests.forEach(({ reject, timeoutThreadID }) => {
clearTimeout(timeoutThreadID)
reject(new Error('Disconnected'))
})
this.pendingRequests.clear()
console.log('Disconnected')
}
/**
* Check if connected
*/
isConnected(): boolean {
return this.server?.connected || false
}
/**
* Send RPC request and wait for response
*/
async call(
method: string,
params?: any,
auth?: string
): Promise<ShellyRpcResponse> {
const id = this.requestId++
const payload: ShellyRpcRequest = {
id,
method,
auth,
...(params && { params }),
}
if (!this.isConnected()) {
throw new Error(
'GATT Server is disconnected. Cannot perform GATT operations. (Re)connect first'
)
}
if (!this.txCtlCharacteristic || !this.dataCharacteristic) {
throw new Error(
'Characteristics not initialized. Device may be disconnected'
)
}
// return eventually the response
const onResult = new Promise<ShellyRpcResponse>(
(resolve, reject) => {
const timeoutID = window.setTimeout(() => {
this.pendingRequests.delete(id)
reject(new Error(`Request timeout for method: ${method}`))
}, this.callRpcTimeout)
// Store pending request
this.pendingRequests.set(id, {
resolve,
reject,
timeoutThreadID: timeoutID,
})
}
)
const jsonRequestData: string = JSON.stringify(payload)
const lengthBuffer: ArrayBuffer = new ArrayBuffer(4)
const view: DataView = new DataView(lengthBuffer)
view.setUint32(0, jsonRequestData.length, false)
console.log(
`Sending data of length ${jsonRequestData.length} bytes`
)
const data = new TextEncoder().encode(jsonRequestData)
//1. Write total length to TX control characteristic
await this.txCtlCharacteristic.writeValueWithResponse(
lengthBuffer
)
//2. Write data in chunks
let offset = 0
while (offset < data.length) {
const chunkSize = Math.min(
SHELLY.MTU_SIZE,
data.length - offset
)
const chunk = data.slice(offset, offset + chunkSize)
await this.dataCharacteristic.writeValueWithResponse(chunk)
offset += chunkSize
}
// Sleep 50ms to allow device to process
if (!this.isRxSupportNotify) {
await new Promise((resolve) => setTimeout(resolve, 100))
//4. Read Response control characteristics for debug
const expectedLength = await this.getRxValue()
return await this.fetchDataFromRwChar(expectedLength)
}
return onResult
}
async getRxValue(): Promise<number> {
const isAllowRead = this.rxCtlCharacteristic?.properties.read
if (!isAllowRead) {
throw new Error(
'RX Control characteristic does not support read'
)
}
const value = await this.rxCtlCharacteristic!.readValue()
return getExpectedLength(value)
}
async fetchDataFromRwChar(
expectedLength: number
): Promise<ShellyRpcResponse> {
const isAllowRead = this.dataCharacteristic?.properties.read
if (!isAllowRead) {
throw new Error('Data characteristic does not support read')
}
let count = 0
let response = ''
while (count < expectedLength) {
const resultChunk =
(await this.dataCharacteristic?.readValue()) ||
new DataView(new ArrayBuffer(0))
count += resultChunk.byteLength
response += new TextDecoder().decode(resultChunk)
console.log(
`Received: ${resultChunk.byteLength} bytes: ${count}/${expectedLength}`
)
console.log(
'Chunk data:',
new TextDecoder().decode(resultChunk)
)
}
console.log('Full response received', response)
return JSON.parse(response) as ShellyRpcResponse
}
/**
* Handle RX control characteristic notification
*/
private onRxCtlNotification = async (
event: Event
): Promise<void> => {
console.log('RX Control notification received')
try {
const characteristic =
event.target as BluetoothRemoteGATTCharacteristic
if (!characteristic || !characteristic.value) {
console.warn(
'RX control notification: characteristic or value is null'
)
return
}
const expectedLength = getExpectedLength(characteristic.value)
const response = await this.fetchDataFromRwChar(expectedLength)
const id = response.id
const pending = this.pendingRequests.get(id)
if (pending) {
window.clearTimeout(pending.timeoutThreadID)
this.pendingRequests.delete(id)
pending.resolve(response)
} else {
console.warn(
`No pending request found for response ID: ${id}`
)
}
} catch (error) {
console.error('Error in onRxCtlNotification:', error)
}
}
private onDisconnect = (): void => {
console.log('Device disconnected')
this.disconnect()
}
}ble/page.tsx
tsx
'use client'
import { useEffect, useState } from 'react'
import { ShellyBleController } from './shelly-ble-controller'
const DEFAULT_RPC_METHODS = [
'Shelly.ListMethods',
'Shelly.GetStatus',
'Shelly.GetConfig',
'Shelly.Reboot',
]
export default function Page() {
const [device, setDevice] = useState<ShellyBleController | null>(
null
)
const [response, setResponse] = useState<string>('')
const [methods, setMethods] = useState<string[]>(
DEFAULT_RPC_METHODS
)
const [method, setMethod] = useState('Shelly.GetConfig')
const [params, setParams] = useState('{}')
const [rpcReq, setRpcReq] = useState('')
const [loading, setLoading] = useState(false)
const appendLog = (msg: string, clear = false) => {
if (clear) setResponse('')
setResponse((r) => r + msg + '\n')
}
async function selectShellyDevice() {
setLoading(true)
appendLog('选择设备中...', true)
try {
const dev = await ShellyBleController.connect()
setDevice(dev)
const payload = {
id: Math.floor(Math.random() * 1e9),
src: 'web-connect',
method: 'Shelly.ListMethods',
params: {},
}
setRpcReq(JSON.stringify(payload, null, 2))
const result = await dev.call('Shelly.ListMethods', {})
//{"id":619369004,"src":"shellypro4pm-08f9e0e4f754","dst":"web-BLE-page","result":{"methods":["UI.SetConfig","UI.GetConfig","UI.GetStatus","Switch.ResetCounters","Switch.SetConfig","Switch.GetConfig","Switch.GetStatus","Switch.Toggle","Switch.Set","Input.CheckExpression","Input.SetConfig","Input.GetConfig","Input.GetStatus","Button.SetConfig","Button.GetConfig","Button.GetStatus","Button.Trigger","Text.SetConfig","Text.GetConfig","Text.GetStatus","Text.Set","Number.SetConfig","Number.GetConfig","Number.GetStatus","Number.Set","Group.SetConfig","Group.GetConfig","Group.GetStatus","Group.Set","Enum.SetConfig","Enum.GetConfig","Enum.GetStatus","Enum.Set","Boolean.SetConfig","Boolean.GetConfig","Boolean.GetStatus","Boolean.Set","Virtual.Delete","Virtual.Add","Schedule.List","Schedule.DeleteAll","Schedule.Delete","Schedule.Update","Schedule.Create","KNX.SetConfig","KNX.GetConfig","KNX.GetStatus","BTHomeSensor.SetConfig","BTHomeSensor.GetConfig","BTHomeSensor.GetStatus","BTHomeDevice.UpdateFirmware","BTHomeDevice.GetKnownObjects","BTHomeDevice.SetConfig","BTHomeDevice.GetConfig","BTHomeDevice.GetStatus","BTHome.GetObjectInfos","BTHome.DeleteSensor","BTHome.AddSensor","BTHome.DeleteDevice","BTHome.AddDevice","BTHome.StartDeviceDiscovery","BTHome.SetConfig","BTHome.GetConfig","BTHome.GetStatus","Webhook.ListSupported","Webhook.List","Webhook.DeleteAll","Webhook.Delete","Webhook.Update","Webhook.Create","Script.Stop","Script.Start","Script.Eval","Script.GetCode","Script.PutCode","Script.SetConfig","Script.GetConfig","Script.GetStatus","Script.List","Script.Delete","Script.Create","WS.SetConfig","WS.GetConfig","WS.GetStatus","Mqtt.SetConfig","Mqtt.GetConfig","Mqtt.GetStatus","Cloud.SetConfig","Cloud.GetConfig","Cloud.GetStatus","BLE.SetConfig","BLE.GetConfig","BLE.GetStatus","BLE.CloudRelay.ListInfos","BLE.CloudRelay.List","Eth.ListClients","Eth.SetConfig","Eth.GetConfig","Eth.GetStatus","Wifi.Scan","Wifi.ListAPClients","Wifi.SetConfig","Wifi.GetConfig","Wifi.GetStatus","Sys.SetTime","Sys.SetConfig","Sys.GetConfig","Sys.GetStatus","KVS.Delete","KVS.Set","KVS.GetMany","KVS.Get","KVS.List","Shelly.Identify","HTTP.Request","HTTP.POST","HTTP.GET","Shelly.ListMethods","Shelly.PutTLSClientKey","Shelly.PutTLSClientCert","Shelly.PutUserCA","Shelly.Reboot","Shelly.SetAuth","Shelly.Update","Shelly.CheckForUpdate","Shelly.DetectLocation","Shelly.ListTimezones","Shelly.GetComponents","Shelly.GetStatus","Shelly.FactoryReset","Shelly.ResetWiFiConfig","Shelly.GetConfig","Shelly.GetDeviceInfo","OTA.Abort","OTA.Write","OTA.Data","OTA.Start","OTA.Revert","OTA.Commit","OTA.Update","Sys.SetDebug"]}}
const methodList = result.result?.methods || []
setMethods(methodList)
appendLog(`获取方法列表成功`)
appendLog(JSON.stringify(result, null, 2))
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
alert(`连接蓝牙设备失败: ${msg}`)
} finally {
setMethod('Shelly.GetConfig')
setLoading(false)
}
}
async function callRpc() {
setLoading(true)
if (device) {
const payload = {
id: Math.floor(Math.random() * 1e9),
src: 'web-bluetooth',
method,
params: params ? JSON.parse(params) : undefined,
}
setRpcReq(JSON.stringify(payload, null, 2))
const result = await device.call(method, payload.params)
appendLog(JSON.stringify(result, null, 2), true)
} else {
appendLog('Device not connected')
}
setLoading(false)
}
//when page destroy, disconnect device
useEffect(() => {
return () => {
if (device) {
device.disconnect()
}
}
}, [device])
return (
<main className='p-6 font-mono min-h-screen bg-gray-50 flex flex-col'>
<h1 className='text-2xl font-bold mb-6'>
Shelly调试台,可以离线使用,浏览器蓝牙向设备发送RPC,只支持Chrome浏览器,不支持IoT设备禁用BLE
RPC 和 已经Cloud绑定设备
</h1>
<p className='mb-4 text-xs'>
使用浏览器的蓝牙功能连接到Shelly设备并调用RPC方法。
</p>
<p className='mb-4 text-xs'>
RPC Method param{' '}
<a
className='text-red-500 underline hover:text-blue-600 transition-colors'
href='https://shelly-api-docs.shelly.cloud/gen2/0.14/ComponentsAndServices/Shelly#shellylisttimezones'
target='_blank'
>
API Docs
</a>
</p>
<div className='grid grid-cols-2 gap-4 mb-6 flex-1'>
<div>
<button
onClick={selectShellyDevice}
disabled={loading}
className='px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition mb-6'
>
{loading ? 'Connecting...' : '选择设备'}
</button>
<p className='mb-2 text-primary font-bold'>
设备连接:{' '}
<b className='text-red-600 text-xs'>
{device?.deviceName || 'No device connected'}
</b>
</p>
<div className='mb-3'>
<label className='block mb-1 font-medium'>
RPC Method
</label>
<select
value={method}
onChange={(e) => setMethod(e.target.value)}
className='w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white mb-2'
>
{methods.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</div>
<div className='mb-3'>
<label className='block mb-1 font-medium'>
Params (JSON)
</label>
<textarea
value={params}
onChange={(e) => setParams(e.target.value)}
rows={5}
placeholder='{"ident": true} // RPC Params in JSON format'
className='w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white'
/>
</div>
<button
onClick={callRpc}
disabled={loading}
className='px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 transition'
>
{loading ? 'Calling...' : 'Call RPC'}
</button>
</div>
<div className='h-full flex flex-col'>
<h3>BLE JSON-RPC-2.0 Request</h3>
<pre className='text-sm mt-2 bg-black text-blue-400 p-3 rounded overflow-auto whitespace-pre-wrap break-words shadow-inner'>
{rpcReq}
</pre>
<h3 className='mt-2'>BLE JSON-RPC-2.0 Response</h3>
<pre className='text-sm mt-2 bg-black text-green-400 p-3 rounded overflow-auto whitespace-pre-wrap break-words shadow-inner flex-1'>
{response}
</pre>
</div>
</div>
</main>
)
}