OAKIOT 灯控设备 — BLE GATT 服务 & WiFi 安全配网协议参考
设备有两种互斥的 BLE 运行模式:
| 条件 | 模式 | BLE 广播名 | 可用服务 |
|---|---|---|---|
| 无 WiFi 凭据 | 配网模式 | PROV_OAKIOT_XXXXXX | WiFiProv protocomm(安全配网) |
| 已存 WiFi 凭据 | 正常模式 | OAKIOT_XXXXXX | LED / WiFi / Time / CTS GATT 服务 |
XXXXXX = eFuse MAC 后 3 字节十六进制
正常模式下注册 4 个 GATT 服务:
| 服务 | UUID | 特征值数 |
|---|---|---|
| LED 灯光 | 0000ff00-...-00805f9b34fb | 5 |
| WiFi 配网 | 0000ff10-...-00805f9b34fb | 2 |
| 自定义时间 | 0000ff20-...-00805f9b34fb | 1 |
| 标准 CTS | 0x1805 | 2 |
0000ff000000ff01 R/W/Notify 3字节| 字节 | 字段 | 范围 | 说明 |
|---|---|---|---|
| 0 | H | 0-255 | 色相(映射 0°-360°) |
| 1 | S | 0-255 | 饱和度(0=白色) |
| 2 | V | 0-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)
0000ff04 R/W/Notify 3字节| 字节 | 字段 | 默认 | 说明 |
|---|---|---|---|
| 0 | Speed | 128 | 灯效速度 |
| 1 | Param1 | 128 | 含义因灯效而异 |
| 2 | Param2 | 128 | 含义因灯效而异 |
0000ff05 R/W/Notify写入 14 字节:
| 偏移 | 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|---|
| 0 | hue | uint8 | 206 | 色相 |
| 1 | saturation | uint8 | 0 | 饱和度 |
| 2 | maxBrightness | uint8 | 255 | 最大亮度 |
| 3 | nightBrightness | uint8 | 30 | 夜灯亮度 |
| 4-5 | startTime | int16 LE | -1 | 渐亮开始(分钟,-1=日落) |
| 6-7 | peakTime | int16 LE | 1260 | 最亮时间(21:00=1260) |
| 8-9 | nightTime | int16 LE | 1290 | 夜灯时间(21:30=1290) |
| 10-11 | offTime | int16 LE | -1 | 关闭时间(-1=日出前30分) |
| 12 | fadeUpDuration | uint8 | 0 | 渐亮时长分钟(0=自动) |
| 13 | fadeDownDuration | uint8 | 30 | 渐暗时长分钟 |
读取 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;
0000ff10 非安全0000ff11 R/W写入: UTF-8 字符串 SSID\nPASSWORD(换行分隔)
读取: 返回当前存储的 SSID(不含密码)
特殊指令: 写入单字节 0x00 → 清除凭据并重启进入安全配网模式
// 设置 WiFi
write(encode("MyWiFi\n12345678"))
// 重置 WiFi → 进入配网模式
write([0x00])
0000ff12 R/Notify 1字节| 值 | 说明 |
|---|---|
| 0x00 | 未连接 |
| 0x01 | 正在连接 |
| 0x02 | 已连接 |
| 0x03 | 连接失败 |
0000ff200000ff21 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);
0x18050x2A2B R/W/Notify 10字节| 偏移 | 字段 | 类型 | 说明 |
|---|---|---|---|
| 0-1 | Year | uint16 LE | 年份 |
| 2 | Month | uint8 | 1-12 |
| 3 | Day | uint8 | 1-31 |
| 4 | Hours | uint8 | 0-23 |
| 5 | Minutes | uint8 | 0-59 |
| 6 | Seconds | uint8 | 0-59 |
| 7 | DayOfWeek | uint8 | 1=周一...7=周日 |
| 8 | Fractions256 | uint8 | 1/256 秒 |
| 9 | AdjustReason | uint8 | 调整原因 |
写入至少 7 字节可设时间,字节 7-9 可选。
0x2A0F Read 2字节| 偏移 | 字段 | 说明 |
|---|---|---|
| 0 | TimeZone | int8, 单位15分钟(UTC+8=32) |
| 1 | DSTOffset | 0=标准, 4=+1h, 255=未知 |
配网模式使用 ESP-IDF protocomm 协议,与正常模式的 GATT 服务完全不同。
| 参数 | 值 |
|---|---|
| Service UUID (128-bit) | 021a9004-0382-4aea-bff4-6b3f1c5adfb4 |
| 安全等级 | Security1(X25519 + AES-256-CTR) |
| 端点 | UUID | 用途 |
|---|---|---|
| prov-ctrl | 0xFF4F | 控制端点 |
| prov-scan | 0xFF50 | WiFi 扫描 |
| prov-session | 0xFF51 | Security1 安全握手 |
| prov-config | 0xFF52 | WiFi 凭据配置 |
| proto-ver | 0xFF53 | 协议版本(JSON) |
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 加解密。
每台设备 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 位十六进制字符串。印刷在设备标签或串口输出。
// ESP32_LED_TEST.ino 中修改 makeDevicePoP()
static String makeDevicePoP() {
return "12345678"; // 固定 8 位
}
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();
}
握手通过 prov-session (0xFF51) 端点完成,共 2 轮请求:
// 生成 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();
// 解析 protobuf → SessionResp0 { status, device_pubkey(32B), device_random(16B) }
const { devicePubKey, deviceRandom } = parseSessionResp0(resp);
// deviceRandom 即为 AES-CTR 的 IV
// 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];
// 客户端加密 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('验证失败');
握手完成后,所有 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;
}
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;
}
安全会话建立后,通过 prov-config (0xFF52) 端点发送 WiFi 凭据:
// 构建 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; // 超时
}
安全会话建立后,可通过 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));
}
| 字段 | Protobuf 编号 | 类型 | 说明 |
|---|---|---|---|
| ssid | 1 | bytes | 网络名称 |
| channel | 2 | varint | 信道号 |
| rssi | 3 | int32 | 信号强度 (dBm) |
| bssid | 4 | bytes | MAC 地址 |
| auth | 5 | varint | 认证模式 |
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
本项目使用手工 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;
}
| 消息 | 字段 | 编号 | Wire | 说明 |
|---|---|---|---|---|
| SessionData | sec_ver | 2 | varint | 固定 = 1 |
| SessionData | sec1 | 11 | bytes | Sec1Payload 子消息 |
| Sec1Payload | msg | 1 | varint | Sec1MsgType |
| Sec1Payload | sc0 | 20 | bytes | SessionCmd0 |
| Sec1Payload | sr0 | 21 | bytes | SessionResp0 |
| Sec1Payload | sc1 | 22 | bytes | SessionCmd1 |
| Sec1Payload | sr1 | 23 | bytes | SessionResp1 |
| Sec1MsgType | 值 |
|---|---|
| Session_Command0 | 0 |
| Session_Response0 | 1 |
| Session_Command1 | 2 |
| Session_Response1 | 3 |
| 消息 | 字段 | 编号 | 类型 |
|---|---|---|---|
| SessionCmd0 | client_pubkey | 1 | bytes |
| SessionResp0 | status | 1 | varint |
| SessionResp0 | device_pubkey | 2 | bytes |
| SessionResp0 | device_random | 3 | bytes |
| SessionCmd1 | client_verify_data | 2 | bytes |
| SessionResp1 | status | 1 | varint |
| SessionResp1 | device_verify_data | 3 | bytes |
| 字段 | 编号 | 类型 | 说明 |
|---|---|---|---|
| msg_type | 1 | varint | NetworkConfigMsgType |
| cmd_get_status | 10 | bytes | 空消息 |
| resp_get_status | 11 | bytes | RespGetStatus |
| cmd_set_config | 12 | bytes | CmdSetConfig |
| resp_set_config | 13 | bytes | RespSetConfig |
| cmd_apply_config | 14 | bytes | 空消息 |
| resp_apply_config | 15 | bytes | RespApplyConfig |
| NetworkConfigMsgType | 值 |
|---|---|
| TypeCmdGetStatus | 0 |
| TypeRespGetStatus | 1 |
| TypeCmdSetConfig | 2 |
| TypeRespSetConfig | 3 |
| TypeCmdApplyConfig | 4 |
| TypeRespApplyConfig | 5 |
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)
| 字段 | 编号 | 类型 | 说明 |
|---|---|---|---|
| msg | 1 | varint | WiFiScanMsgType |
| status | 2 | varint | 状态码 |
| cmd_scan_start | 10 | bytes | CmdScanStart |
| resp_scan_start | 11 | bytes | RespScanStart |
| cmd_scan_status | 12 | bytes | 空消息 |
| resp_scan_status | 13 | bytes | RespScanStatus |
| cmd_scan_result | 14 | bytes | CmdScanResult |
| resp_scan_result | 15 | bytes | RespScanResult |
CmdScanStart: blocking(1, bool) | CmdScanResult: start_index(1), count(2)
RespScanStatus: scan_finished(1, bool), result_count(2, varint)
握手 Step 4 解密比对不通过。检查 PoP 输入是否与设备标签一致(大小写敏感)。
GetStatus 返回 staState=2 (Disconnected)。fail_reason 对应 ESP-IDF wifi_err_reason_t:201=AUTH_FAIL(密码错误),15=4WAY_HANDSHAKE_TIMEOUT。
write 后 read 返回空数据:增加 sleep 到 300-500ms。部分设备 ATT MTU 协商慢,可重连解决。
加解密结果乱码:确保 ctrByteOffset 严格按 data.length 递增,每次加密和解密都推进。握手后初始值为 64。
BLE MTU 限制:每次 CmdScanResult 的 count 建议 ≤4。
设备 WiFi 连接成功后会自动重启,BLE 断开是预期行为。重启后进入正常模式,广播名称变为 OAKIOT_XXXXXX。