BLE 开发者文档

OAKIOT 灯控设备 — BLE GATT 服务 & WiFi 安全配网协议参考

目录 1. 系统概览 2. BLE GATT 服务总览 2.1 LED 灯光服务 2.2 WiFi 配网服务 2.3 时间服务 2.4 标准 CTS 服务 3. 安全配网模式 (WiFiProv) 3.1 BLE 连接与端点 3.2 PoP 验证码 3.3 Security1 握手实现 3.4 AES-CTR 会话加密 3.5 WiFi 凭据配置 3.6 WiFi 扫描 3.7 Protobuf 字段号完整参考 4. 常见问题

1. 系统概览

设备有两种互斥的 BLE 运行模式:

条件模式BLE 广播名可用服务
无 WiFi 凭据配网模式PROV_OAKIOT_XXXXXXWiFiProv protocomm(安全配网)
已存 WiFi 凭据正常模式OAKIOT_XXXXXXLED / WiFi / Time / CTS GATT 服务

XXXXXX = eFuse MAC 后 3 字节十六进制

两种模式互斥运行(ESP32 BLE 栈限制)。配网成功后设备自动重启进入正常模式。

2. BLE GATT 服务总览

正常模式下注册 4 个 GATT 服务:

服务UUID特征值数
LED 灯光0000ff00-...-00805f9b34fb5
WiFi 配网0000ff10-...-00805f9b34fb2
自定义时间0000ff20-...-00805f9b34fb1
标准 CTS0x18052

2.1 LED 灯光服务 0000ff00

HSV 颜色 — 0000ff01 R/W/Notify 3字节

字节字段范围说明
0H0-255色相(映射 0°-360°)
1S0-255饱和度(0=白色)
2V0-255亮度
// 示例: 红色半亮
write([0x00, 0xFF, 0x80])

电源开关 — 0000ff02 R/W/Notify 1字节

说明
0x00关闭
0x01开启

灯效模式 — 0000ff03 R/W/Notify 1字节

名称说明
0x00静态 HSV固定颜色(默认)
0x01时间智能灯日出日落自动调光
0x64-0x6F动态灯效100-111,共 12 种

灯效 100-111 详表:柔和呼吸(100)、色相漫游(101)、调色板呼吸(102)、烛光摇曳(103)、潮汐渐变(104)、日落渐变(105)、极光流转(106)、心跳脉冲(107)、月光涟漪(108)、色彩交融(109)、萤火虫(110)、四季流转(111)

灯效模式≥1 时,HSV 写入不影响显示;切回 0 恢复。

灯效参数 — 0000ff04 R/W/Notify 3字节

字节字段默认说明
0Speed128灯效速度
1Param1128含义因灯效而异
2Param2128含义因灯效而异

时间灯效配置 — 0000ff05 R/W/Notify

写入 14 字节:

偏移字段类型默认说明
0hueuint8206色相
1saturationuint80饱和度
2maxBrightnessuint8255最大亮度
3nightBrightnessuint830夜灯亮度
4-5startTimeint16 LE-1渐亮开始(分钟,-1=日落)
6-7peakTimeint16 LE1260最亮时间(21:00=1260)
8-9nightTimeint16 LE1290夜灯时间(21:30=1290)
10-11offTimeint16 LE-1关闭时间(-1=日出前30分)
12fadeUpDurationuint80渐亮时长分钟(0=自动)
13fadeDownDurationuint830渐暗时长分钟

读取 18 字节: 前 14 字节配置 + 4 字节状态(阶段, 当前亮度, 日出时, 日落时)

// JavaScript 写入示例
const cfg = new Uint8Array(14);
cfg[0] = 206; cfg[1] = 0; cfg[2] = 255; cfg[3] = 30;
new DataView(cfg.buffer).setInt16(4, -1, true);     // startTime
new DataView(cfg.buffer).setInt16(6, 1260, true);   // peakTime
new DataView(cfg.buffer).setInt16(8, 1290, true);   // nightTime
new DataView(cfg.buffer).setInt16(10, -1, true);    // offTime
cfg[12] = 0; cfg[13] = 30;

2.2 WiFi 配网服务 0000ff10 非安全

此服务以明文传输 WiFi 密码,存在被 BLE 嗅探窃取的风险,仅适用于开发调试和简单测试。生产环境请使用 Security1 安全配网

WiFi 配置 — 0000ff11 R/W

写入: UTF-8 字符串 SSID\nPASSWORD(换行分隔)

读取: 返回当前存储的 SSID(不含密码)

特殊指令: 写入单字节 0x00 → 清除凭据并重启进入安全配网模式

// 设置 WiFi
write(encode("MyWiFi\n12345678"))
// 重置 WiFi → 进入配网模式
write([0x00])

WiFi 状态 — 0000ff12 R/Notify 1字节

说明
0x00未连接
0x01正在连接
0x02已连接
0x03连接失败

2.3 自定义时间服务 0000ff20

时间同步 — 0000ff21 R/W/Notify 4字节

uint32 小端序,UTC epoch 秒数。

// 写入当前时间
const epoch = Math.floor(Date.now() / 1000);
const buf = new ArrayBuffer(4);
new DataView(buf).setUint32(0, epoch, true);
await char.writeValue(buf);

2.4 BLE 标准 CTS 0x1805

Current Time — 0x2A2B R/W/Notify 10字节

偏移字段类型说明
0-1Yearuint16 LE年份
2Monthuint81-12
3Dayuint81-31
4Hoursuint80-23
5Minutesuint80-59
6Secondsuint80-59
7DayOfWeekuint81=周一...7=周日
8Fractions256uint81/256 秒
9AdjustReasonuint8调整原因

写入至少 7 字节可设时间,字节 7-9 可选。

Local Time Info — 0x2A0F Read 2字节

偏移字段说明
0TimeZoneint8, 单位15分钟(UTC+8=32)
1DSTOffset0=标准, 4=+1h, 255=未知
CTS 和自定义时间服务双向同步:写入一方后另一方自动更新。标准客户端用 CTS,自定义 App 用 epoch 更简洁。

3. 安全配网模式 (WiFiProv Security1)

3.1 BLE 连接与端点

配网模式使用 ESP-IDF protocomm 协议,与正常模式的 GATT 服务完全不同。

参数
Service UUID (128-bit)021a9004-0382-4aea-bff4-6b3f1c5adfb4
安全等级Security1(X25519 + AES-256-CTR)

端点特征值(16-bit UUID)

端点UUID用途
prov-ctrl0xFF4F控制端点
prov-scan0xFF50WiFi 扫描
prov-session0xFF51Security1 安全握手
prov-config0xFF52WiFi 凭据配置
proto-ver0xFF53协议版本(JSON)
Web Bluetooth 连接时必须在 optionalServices 中声明 128-bit Service UUID,特征值通过 16-bit UUID 访问。
// Web Bluetooth 连接示例
const device = await navigator.bluetooth.requestDevice({
  filters: [{ namePrefix: 'PROV_' }],
  optionalServices: ['021a9004-0382-4aea-bff4-6b3f1c5adfb4']
});
const server = await device.gatt.connect();
const service = await server.getPrimaryService('021a9004-0382-4aea-bff4-6b3f1c5adfb4');
const charSession = await service.getCharacteristic(0xFF51);
const charConfig  = await service.getCharacteristic(0xFF52);
const charScan    = await service.getCharacteristic(0xFF50);

通信模式

每次请求为 write → 等待 → read:

await characteristic.writeValueWithResponse(requestData);
await sleep(200);    // 等待设备处理
const response = await characteristic.readValue();

握手阶段明文通信;会话建立后 prov-config / prov-scan 数据自动 AES-256-CTR 加解密。

3.2 PoP 验证码

每台设备 PoP 基于 eFuse MAC 地址派生,保证唯一:

pop = SHA-256("OAKIO_POP_" + eFuse_MAC[6字节]).hex()[0:8]
// 例如 MAC = AA:BB:CC:DD:EE:FF → pop = "a3f1b20c"

即取 SHA-256 前 4 字节转为 8 位十六进制字符串。印刷在设备标签或串口输出。

自定义 PoP(固件端)

// ESP32_LED_TEST.ino 中修改 makeDevicePoP()
static String makeDevicePoP() {
  return "12345678";  // 固定 8 位
}

客户端计算 PoP(JavaScript)

async function computePoP(macBytes) {
  // macBytes: Uint8Array(6) — 设备 eFuse MAC
  const input = new Uint8Array(16);
  const prefix = new TextEncoder().encode("OAKIO_POP_");
  input.set(prefix, 0);
  input.set(macBytes, 10);
  const hash = await crypto.subtle.digest('SHA-256', input);
  return Array.from(new Uint8Array(hash).slice(0, 4))
    .map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase();
}

3.3 Security1 握手实现

握手通过 prov-session (0xFF51) 端点完成,共 2 轮请求:

客户端 ESP32 设备 │ │ │── Command0 (client_pubkey 32B) ────────>│ │<── Response0 ──────────────────────────│ │ device_pubkey (32B) + device_random (16B, 用作 AES IV) │ │ │ [双方各自计算] │ │ shared = X25519(my_private, peer_public)│ │ popHash = SHA-256(pop_string) │ │ sessionKey = shared XOR popHash │ │ │ │── Command1 (client_verify) ────────────>│ │<── Response1 (device_verify) ──────────│ │ │ │ ══ 安全会话建立 ══ │

Step 1: 发送 Command0

// 生成 X25519 密钥对
const keyPair = await crypto.subtle.generateKey(
  { name: 'X25519' }, true, ['deriveBits']
);
// 导出公钥 (32 字节 raw)
const clientPubKey = new Uint8Array(
  await crypto.subtle.exportKey('raw', keyPair.publicKey)
);
// 构建 protobuf: SessionData { sec_ver=1, sec1: Sec1Payload { msg=0, sc0: { client_pubkey } } }
const request = buildSessionCmd0(clientPubKey);
// 发送
await charSession.writeValueWithResponse(request);
await sleep(200);
const resp = await charSession.readValue();

Step 2: 解析 Response0

// 解析 protobuf → SessionResp0 { status, device_pubkey(32B), device_random(16B) }
const { devicePubKey, deviceRandom } = parseSessionResp0(resp);
// deviceRandom 即为 AES-CTR 的 IV

Step 3: 计算会话密钥

// X25519 密钥协商
const sharedBits = await crypto.subtle.deriveBits(
  { name: 'X25519', public: await importDevicePubKey(devicePubKey) },
  keyPair.privateKey, 256
);
const shared = new Uint8Array(sharedBits);      // 32 字节

// SHA-256(PoP 字符串)
const popHash = new Uint8Array(
  await crypto.subtle.digest('SHA-256', new TextEncoder().encode(popString))
);

// 会话密钥 = shared XOR popHash
const sessionKey = new Uint8Array(32);
for (let i = 0; i < 32; i++) sessionKey[i] = shared[i] ^ popHash[i];
如果 PoP 错误,sessionKey 计算结果不同,Step 4 验证必然失败。

Step 4: 双向验证 (Command1 / Response1)

// 客户端加密 device_pubkey 作为验证数据
// 使用 AES-256-CTR(sessionKey, IV=deviceRandom)
const clientVerify = await aesCTR_encrypt(sessionKey, deviceRandom, devicePubKey);
// ↑ 消耗计数器块 0-1 (32 字节)

// 构建 Command1 并发送
await charSession.writeValueWithResponse(buildSessionCmd1(clientVerify));
await sleep(200);
const resp1 = await charSession.readValue();
const { deviceVerify } = parseSessionResp1(resp1);

// 验证: 设备加密了 client_pubkey,需要解密比对
// 关键: ESP-IDF 在同一个 AES-CTR 上下文中:
//   先解密 client_verify (32B, 块 0-1)
//   再加密 device_verify (32B, 块 2-3)
// 客户端必须将 devicePubKey || deviceVerify 拼为 64B 一次性解密
const combined = concat(devicePubKey, new Uint8Array(deviceVerify.buffer));
const decrypted = await aesCTR_decrypt(sessionKey, deviceRandom, combined);
const decryptedVerify = decrypted.slice(32);  // 后 32B = 解密后的 device_verify
// 比对: decryptedVerify === clientPubKey
if (!arraysEqual(decryptedVerify, clientPubKey)) throw new Error('验证失败');
握手共消耗 64 字节(4 个 AES 块),后续会话通信的计数器从字节偏移 64 开始。

3.4 AES-CTR 会话加密

握手完成后,所有 prov-config / prov-scan 数据需加解密。ESP-IDF 使用 mbedtls_aes_crypt_ctr 维护字节级计数器,Web Crypto API 每次从整块起始,需手动对齐。

核心实现

let ctrByteOffset = 64; // 握手消耗 64 字节

async function sessionCrypt(data) {
  const byteOff  = ctrByteOffset % 16;     // 块内已消耗字节
  const blockNum = Math.floor(ctrByteOffset / 16);
  const counter  = incrementIV(sessionIV, blockNum);

  let result;
  if (byteOff > 0) {
    // 非 16 字节对齐: 前面填零占位
    const padded = new Uint8Array(byteOff + data.length);
    padded.set(data, byteOff);
    const encrypted = await aesCTR(sessionKey, counter, padded);
    result = encrypted.slice(byteOff);
  } else {
    result = await aesCTR(sessionKey, counter, data);
  }
  ctrByteOffset += data.length;
  return result;
}

IV 递增函数

function incrementIV(iv, blocks) {
  const counter = new Uint8Array(iv);  // 复制 16 字节 IV
  // 大端序递增最后 4 字节
  let carry = blocks;
  for (let i = 15; i >= 12 && carry > 0; i--) {
    const sum = counter[i] + carry;
    counter[i] = sum & 0xFF;
    carry = sum >> 8;
  }
  return counter;
}

加密/解密通信示例

// 发送加密请求
async function sendEncrypted(characteristic, plainData) {
  const encrypted = await sessionCrypt(new Uint8Array(plainData));
  await characteristic.writeValueWithResponse(encrypted);
  await sleep(200);
  const respView = await characteristic.readValue();
  const respEncrypted = new Uint8Array(respView.buffer);
  const respPlain = await sessionCrypt(respEncrypted);
  return respPlain;
}

3.5 WiFi 凭据配置

安全会话建立后,通过 prov-config (0xFF52) 端点发送 WiFi 凭据:

1. CmdSetConfig (SSID + Password) → 加密发送 2. CmdApplyConfig → 设备尝试连接 WiFi 3. CmdGetStatus (轮询) → 检查连接结果 4. 连接成功 → 设备自动重启进入正常模式

发送凭据

// 构建 CmdSetConfig protobuf
// NetworkConfigPayload { msg_type=2, cmd_set_config: { ssid, passphrase } }
const cmd = buildCmdSetConfig(ssidString, passwordString);
await sendEncrypted(charConfig, cmd);

// 应用配置
// NetworkConfigPayload { msg_type=4, cmd_apply_config: {} }
const apply = buildCmdApplyConfig();
await sendEncrypted(charConfig, apply);

轮询状态

// NetworkConfigPayload { msg_type=0, cmd_get_status: {} }
async function pollStatus() {
  for (let i = 0; i < 15; i++) {
    await sleep(2000);
    const resp = await sendEncrypted(charConfig, buildCmdGetStatus());
    const status = parseRespGetStatus(resp);
    // staState: 0=Connected, 1=Connecting, 2=Disconnected
    if (status.staState === 0) return true;   // 成功
    if (status.staState === 2) return false;  // 失败
  }
  return false; // 超时
}

3.6 WiFi 扫描

安全会话建立后,可通过 prov-scan (0xFF50) 端点扫描附近 WiFi:

扫描流程

// 1. 触发扫描
const scanStart = buildCmdScanStart();  // blocking=true
await sendEncrypted(charScan, scanStart);

// 2. 等待完成
let count = 0;
while (true) {
  await sleep(1000);
  const resp = await sendEncrypted(charScan, buildCmdScanStatus());
  const status = parseRespScanStatus(resp);
  if (status.finished) { count = status.count; break; }
}

// 3. 分批获取结果 (每次 ≤4 条,避免 BLE MTU 限制)
const results = [];
for (let i = 0; i < count; i += 4) {
  const batch = Math.min(4, count - i);
  const resp = await sendEncrypted(charScan, buildCmdScanResult(i, batch));
  results.push(...parseRespScanResult(resp));
}

WiFi 扫描结果字段

字段Protobuf 编号类型说明
ssid1bytes网络名称
channel2varint信道号
rssi3int32信号强度 (dBm)
bssid4bytesMAC 地址
auth5varint认证模式

WifiAuthMode: 0=Open, 1=WEP, 2=WPA_PSK, 3=WPA2_PSK, 4=WPA_WPA2_PSK, 5=WPA2_Enterprise, 6=WPA3_PSK, 7=WPA2_WPA3_PSK

3.7 Protobuf 字段号完整参考

本项目使用手工 Protobuf 编解码(无依赖库),以下为所有消息的字段号。

通用编解码

// Protobuf varint 编码
function pbVarint(val) {
  const bytes = [];
  while (val > 0x7f) { bytes.push((val & 0x7f) | 0x80); val >>>= 7; }
  bytes.push(val & 0x7f);
  return new Uint8Array(bytes);
}

// 编码字段: tag = (fieldNum << 3) | wireType
// wireType 0 = varint, 2 = length-delimited (bytes/string/子消息)
function pbField(fieldNum, wireType, data) {
  const tag = pbVarint((fieldNum << 3) | wireType);
  if (wireType === 0) return concat(tag, pbVarint(data));
  if (wireType === 2) {
    const d = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
    return concat(tag, pbVarint(d.length), d);
  }
  return tag;
}

SessionData / Sec1Payload (prov-session)

消息字段编号Wire说明
SessionDatasec_ver2varint固定 = 1
SessionDatasec111bytesSec1Payload 子消息
Sec1Payloadmsg1varintSec1MsgType
Sec1Payloadsc020bytesSessionCmd0
Sec1Payloadsr021bytesSessionResp0
Sec1Payloadsc122bytesSessionCmd1
Sec1Payloadsr123bytesSessionResp1
Sec1MsgType
Session_Command00
Session_Response01
Session_Command12
Session_Response13

SessionCmd/Resp 内部字段

消息字段编号类型
SessionCmd0client_pubkey1bytes
SessionResp0status1varint
SessionResp0device_pubkey2bytes
SessionResp0device_random3bytes
SessionCmd1client_verify_data2bytes
SessionResp1status1varint
SessionResp1device_verify_data3bytes

NetworkConfigPayload (prov-config)

字段编号类型说明
msg_type1varintNetworkConfigMsgType
cmd_get_status10bytes空消息
resp_get_status11bytesRespGetStatus
cmd_set_config12bytesCmdSetConfig
resp_set_config13bytesRespSetConfig
cmd_apply_config14bytes空消息
resp_apply_config15bytesRespApplyConfig
NetworkConfigMsgType
TypeCmdGetStatus0
TypeRespGetStatus1
TypeCmdSetConfig2
TypeRespSetConfig3
TypeCmdApplyConfig4
TypeRespApplyConfig5

CmdSetConfig 内部: ssid(1, bytes), passphrase(2, bytes)

RespGetStatus 内部: status(1, varint), sta_state(2, varint: 0=Connected 1=Connecting 2=Disconnected), fail_reason(10, varint)

WiFiScanPayload (prov-scan)

字段编号类型说明
msg1varintWiFiScanMsgType
status2varint状态码
cmd_scan_start10bytesCmdScanStart
resp_scan_start11bytesRespScanStart
cmd_scan_status12bytes空消息
resp_scan_status13bytesRespScanStatus
cmd_scan_result14bytesCmdScanResult
resp_scan_result15bytesRespScanResult

CmdScanStart: blocking(1, bool) | CmdScanResult: start_index(1), count(2)

RespScanStatus: scan_finished(1, bool), result_count(2, varint)

4. 常见问题

PoP 验证失败

握手 Step 4 解密比对不通过。检查 PoP 输入是否与设备标签一致(大小写敏感)。

WiFi 连接失败

GetStatus 返回 staState=2 (Disconnected)。fail_reason 对应 ESP-IDF wifi_err_reason_t:201=AUTH_FAIL(密码错误),15=4WAY_HANDSHAKE_TIMEOUT。

BLE 通信超时

write 后 read 返回空数据:增加 sleep 到 300-500ms。部分设备 ATT MTU 协商慢,可重连解决。

AES-CTR 计数器失步

加解密结果乱码:确保 ctrByteOffset 严格按 data.length 递增,每次加密和解密都推进。握手后初始值为 64。

WiFi 扫描结果截断

BLE MTU 限制:每次 CmdScanResult 的 count 建议 ≤4。

配网完成后设备无响应

设备 WiFi 连接成功后会自动重启,BLE 断开是预期行为。重启后进入正常模式,广播名称变为 OAKIOT_XXXXXX

OAKIOT ESP32 BLE 开发文档 · 基于 ESP-IDF WiFiProv / protocomm 协议