Skip to content

通过 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 的设备将无法使用本方法。
  • 快速上手

    1. 打开本页面并点击“选择设备”,允许 Chrome 访问蓝牙并选择你的 Shelly 设备。
    2. 连接成功后,可先调用 Shelly.ListMethods 获取设备支持的 RPC 列表。
    3. 选择想要调用的 RPC(例如 Shelly.GetStatusShelly.GetConfigShelly.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>
  )
}