OAKIOT 灯控设备 — BLE GATT 服务 & WiFi 安全配网协议参考
设备默认始终进入正常模式,安全配网模式需由 Web 端主动触发:
| 条件 | 模式 | BLE 广播名 | 可用服务 |
|---|---|---|---|
| 默认启动 | 正常模式 | OAKIOT_XXXXXX | LED / WiFi / Time / CTS GATT 服务 |
| Web 端触发重置 | 配网模式 | PROV_OAKIOT_XXXXXX | WiFiProv protocomm(安全配网) |
XXXXXX = eFuse MAC 后 3 字节十六进制
正常模式下注册 5 个 GATT 服务:
| 服务 | UUID | 特征值数 |
|---|---|---|
| LED 灯光 | 0000ff00-...-00805f9b34fb | 5 |
| WiFi 配网 | 0000ff10-...-00805f9b34fb | 2 |
| 自定义时间 | 0000ff20-...-00805f9b34fb | 1 |
| 标准 CTS | 0x1805 | 2 |
| OTA 更新 | 0000ff30-...-00805f9b34fb | 3 |
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=未知 |
0000ff30BLE OTA 服务用于远程固件更新的交互控制。设备定期自动检查更新,发现新版本后通知客户端,由用户确认后下载升级。
0000ff31 Write/Notify 1字节写入命令:
| 值 | 命令 | 说明 |
|---|---|---|
0x01 | 检查更新 | 手动触发服务器检查 |
0x02 | 确认升级 | 开始下载固件 |
0x03 | 取消 | 取消当前操作 |
Notify:状态变化时推送 1 字节状态码。
| 值 | 名称 | 说明 |
|---|---|---|
| 0x00 | IDLE | 空闲 |
| 0x01 | CHECKING | 正在检查更新 |
| 0x02 | AVAILABLE | 有新版本可用 |
| 0x03 | NO_UPDATE | 已是最新版本 |
| 0x04 | DOWNLOADING | 正在下载固件 |
| 0x05 | VERIFYING | 正在校验 |
| 0x06 | READY | 准备重启 |
| 0x07 | REBOOTING | 即将重启 |
| 0xE0-0xE5 | ERROR | 各类错误 |
0000ff32 Read/Notify JSON返回 UTF-8 JSON 字符串,包含版本信息和更新详情:
// 有可用更新
{"cur":"1.0.0","new":"1.2.0","size":483200,"log":"修复BLE断连","force":false,"state":2}
// 无更新
{"cur":"1.0.0","new":"","state":0}
0000ff33 Read/Notify 2字节| 字节 | 说明 | 范围 |
|---|---|---|
| [0] | 下载进度百分比 | 0-100 |
| [1] | 当前状态码 | 见状态码表 |
// JavaScript OTA 交互示例
const service = await server.getPrimaryService('0000ff30-0000-1000-8000-00805f9b34fb');
const charCtrl = await service.getCharacteristic('0000ff31-0000-1000-8000-00805f9b34fb');
const charInfo = await service.getCharacteristic('0000ff32-0000-1000-8000-00805f9b34fb');
const charProg = await service.getCharacteristic('0000ff33-0000-1000-8000-00805f9b34fb');
// 订阅通知
await charCtrl.startNotifications();
charCtrl.addEventListener('characteristicvaluechanged', e => {
const state = new Uint8Array(e.target.value.buffer)[0];
console.log('OTA state:', state);
});
await charProg.startNotifications();
charProg.addEventListener('characteristicvaluechanged', e => {
const data = new Uint8Array(e.target.value.buffer);
console.log(`Progress: ${data[0]}%, state: 0x${data[1].toString(16)}`);
});
// 检查更新
await charCtrl.writeValueWithResponse(new Uint8Array([0x01]));
// 读取更新信息
const infoData = await charInfo.readValue();
const info = JSON.parse(new TextDecoder().decode(infoData.buffer));
// 确认升级
if (info.new) {
await charCtrl.writeValueWithResponse(new Uint8Array([0x02]));
}
配网模式使用 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('Verify failed');
握手完成后,所有 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。
设备支持通过 HTTP RESTful API 在局域网内进行配置和控制,默认关闭,需通过串口或配置命令主动开启。
// 串口命令
{"cmd":"set_http","enabled":true,"port":80}
// 或通过 set_config
{"cmd":"set_config","http_enabled":true}
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/status | 设备综合状态 |
| GET | /api/config | 运行时配置 |
| PUT | /api/config | 修改配置 |
| GET | /api/led | LED 灯光状态 |
| PUT | /api/led | 设置灯光 |
| GET | /api/effect | 灯效模式与参数 |
| PUT | /api/effect | 设置灯效 |
| GET | /api/timefx | 时间灯效配置 |
| PUT | /api/timefx | 设置时间灯效 |
| GET | /api/wifi | WiFi 连接状态 |
| GET | /api/ota | OTA 状态 |
| POST | /api/ota/check | 检查更新 |
| POST | /api/ota/update | 确认升级 |
| POST | /api/ota/cancel | 取消更新 |
// 查询设备状态
curl http://192.168.1.100/api/status
// 开灯设为暖白
curl -X PUT http://192.168.1.100/api/led \
-H "Content-Type: application/json" \
-d '{"power":true,"h":30,"s":180,"v":255}'
// 设置呼吸灯效
curl -X PUT http://192.168.1.100/api/effect \
-H "Content-Type: application/json" \
-d '{"effect_mode":100,"effect_speed":80}'
// 修改NTP配置
curl -X PUT http://192.168.1.100/api/config \
-H "Content-Type: application/json" \
-d '{"ntp_server1":"pool.ntp.org","ntp_gmt_offset":32400}'
通过 USB 串口(115200, 8N1)发送 JSON 命令,可配置所有运行时参数。
| 命令 | 说明 |
|---|---|
get_config | 获取全部运行时配置(NTP、OTA URL/间隔、HTTP开关等) |
set_config | 修改运行时配置(仅传要改的字段) |
get_status | 设备综合状态(MAC、版本、WiFi、时间、堆内存等) |
get_led | LED 灯光状态 |
set_effect | 设置灯效模式/参数 |
get_wifi | WiFi 连接状态 |
set_wifi | 设置 WiFi 凭据 |
get_timefx | 时间灯效配置 |
set_timefx | 修改时间灯效参数 |
set_http | 开启/关闭 HTTP 服务 |
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| ntp_server1 | string | ntp.aliyun.com | NTP 服务器 1 |
| ntp_server2 | string | time.nist.gov | NTP 服务器 2 |
| ntp_gmt_offset | number | 28800 | UTC 偏移 (秒) |
| ntp_daylight_offset | number | 0 | 夏令时偏移 (秒) |
| device_prefix | string | OAKIOT | BLE 广播名前缀 |
| save_check_interval | number | 300000 | NVS 保存间隔 (ms) |
| ota_base_url | string | https://ota.iot.oakiot.cc | OTA 服务器地址 |
| ota_check_interval | number | 86400000 | OTA 检查间隔 (ms) |
| ota_first_delay | number | 60000 | OTA 首次延迟 (ms) |
| http_enabled | boolean | false | HTTP 服务开关 |
| http_port | number | 80 | HTTP 服务端口 |
// 获取运行时配置
{"cmd":"get_config"}
// 修改NTP和OTA配置
{"cmd":"set_config","ntp_server1":"pool.ntp.org","ota_check_interval":43200000}
// 开启HTTP服务
{"cmd":"set_http","enabled":true,"port":80}
// 查看综合状态
{"cmd":"get_status"}
| 配置项 | BLE | 串口 | HTTP |
|---|---|---|---|
| LED HSV/开关 | 0xff01/02 | set_led | PUT /api/led |
| 灯效模式/参数 | 0xff03/04 | set_effect | PUT /api/effect |
| 时间灯效 | 0xff05 | set_timefx | PUT /api/timefx |
| WiFi 凭据 | 0xff11 | set_wifi | — |
| WiFi 状态 | 0xff12 | get_wifi | GET /api/wifi |
| 时间同步 | 0xff21 / 0x2A2B | — | — |
| NTP/时区配置 | — | set_config | PUT /api/config |
| 设备前缀 | — | set_config | PUT /api/config |
| OTA URL/间隔 | — | set_config | PUT /api/config |
| OTA 检查/升级 | 0xff31 | ota_check等 | POST /api/ota/* |
| HTTP 服务开关 | — | set_http | PUT /api/config |
| 设备信息 | OTA Info | get_device_info | GET /api/status |