The forum was quiet this week (0 new threads), so this digest delivers the follow-up promised in Week 43: a complete BLE 5.3 provisioning walkthrough that pairs a CH32V208 peripheral with a SpacemiT K1 or K3 Linux host, including the GATT session protocol, bonding model, throughput math, and the field failures that actually bite in production.
This is the pattern you need whenever a RISC-V SBC has no keyboard and no screen on first boot. The board needs Wi-Fi credentials, and the cheapest reliable channel is the BLE radio already sitting next to the SoC. This article covers the wire protocol, the security model, the throughput arithmetic, and the mistakes we keep seeing.
Every provisioning design is a tradeoff between cost, user experience, and attack surface. For a RISC-V gateway or appliance, the three realistic options compare like this:
| Approach | Extra BOM | Phone friction | Attack surface | Works when Wi-Fi is not yet configured |
|---|---|---|---|---|
| Serial console / UART header | 0 | Requires a technician | Low | Yes |
| SoftAP + captive portal | 0 | Manual Wi-Fi switch, captive portal quirks | Medium (open AP during window) | Yes |
| BLE GATT provisioning | 0 if a BLE part is present | Native app, stays on own network | Small if scoped and time-boxed | Yes |
| Wi-Fi Easy Connect (DPP) | 0 | Good, but poor Android/iOS coverage | Small | Only if you can bootstrap the QR flow |
Do not invent one giant characteristic. The session protocol below survives disconnects, retries, and phones that rotate MAC addresses.
The state machine must be explicit on the device, not implied by the order of the writes:
typedef enum { PROV_IDLE = 0, PROV_OPEN, / token verified, window running / PROV_RECEIVING, / offsets accumulating in RAM / PROV_COMMIT, / SSID/PSK applied, joining / PROV_DONE, / IPv4 assigned, notify sent / PROV_FAILED / error code latched in PROV_STATUS / } prov_state_t;The attribute table itself follows the standard gattAttribute_t layout used by the WCH BLE LIB peripheral sample (the stack is intentionally TI-BLE-shaped, so the same mental model transfers). API names differ slightly across SDK drops, so verify against the version in your tree:
static gattAttribute_t provAttrTbl[] = { { { ATT_BT_UUID_SIZE, primaryServiceUUID }, GATT_PERMIT_READ, 0, (uint8_t *)&provSvc }, { { ATT_BT_UUID_SIZE, characterUUID }, GATT_PERMIT_READ, 0, &provCtrlProps }, { { ATT_UUID_SIZE, provCtrlUUID }, GATT_PERMIT_WRITE, 0, provCtrlValue }, { { ATT_UUID_SIZE, provDataUUID }, GATT_PERMIT_WRITE, 0, provDataValue }, { { ATT_UUID_SIZE, provStatusUUID }, GATT_PERMIT_READ, 0, provStatusValue } };Bulk reassembly belongs in the write callback, and the offset field is not optional. A phone that reconnects mid-transfer will happily restart at offset 0, and without offsets you will silently concatenate half an SSID onto a full PSK.
Chunking rule of thumb: negotiate MTU 247 first, then send 240-byte writes. A 64-byte SSID and 64-byte PSK then fit in a single packet, so a typical provisioning exchange completes in under 2 seconds of connection time.Command-line first pass, useful when you are debugging a single unit:
bluetoothctl --timeout 15 scan on bluetoothctl connect AA:BB:CC:DD:EE:FF bluetoothctl menu gatt bluetoothctl select-attribute /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service000e/char000f bluetoothctl write "0x01 0x11 0x22 0x33 0x44"For factory or batch work, script it. Python with bleak is the shortest path and it runs unmodified on riscv64, which is worth noting given the ecosystem's Python packaging gaps:
import asyncio from bleak import BleakClient SVC = "0000fff0-0000-1000-8000-00805f9b34fb" CTRL = "0000fff1-0000-1000-8000-00805f9b34fb" DATA = "0000fff2-0000-1000-8000-00805f9b34fb" async def provision(mac, token, ssid, psk): async with BleakClient(mac, timeout=20) as cli: await cli.write_gatt_char(CTRL, bytes([0x01]) + token, response=True) blob = ssid.encode() + b"\x00" + psk.encode() for off in range(0, len(blob), 240): frame = off.to_bytes(2, "little") + blob[off:off + 240] await cli.write_gatt_char(DATA, frame, response=False) await cli.write_gatt_char(CTRL, bytes([0x02]), response=True) Two operational notes from real deployments. First, deduplicate by advertised name plus service UUID, not by MAC - modern phones and some Linux controllers rotate resolvable private addresses. Second, if you provision more than one board on the same bench, keep them powered one at a time or add a unit index to the advertised name, otherwise you will provision the wrong unit and only notice at the customer site.For provisioning (a few hundred bytes) that is irrelevant. For OTA firmware over BLE it is everything: a 200 KB image takes 20 to 35 seconds at best, and any design that pushes a full application image over BLE should budget a resume mechanism with a CRC-checked block index, not a single monolithic transfer.
2.4 GHz coexistence is the real enemy. BLE advertising channels sit at 2402, 2426, and 2480 MHz. Wi-Fi channel 1 is centered at 2412 MHz and channel 11 at 2462 MHz, so both overlap the BLE advertising band. A K1 board with an active Wi-Fi link will lose provisioning packets if the radio shares an antenna without proper coexistence signalling - which is exactly when people blame the BLE stack. Fix it at the hardware and driver level, not by lowering the advertising interval.The same GATT service and session protocol port directly across the WCH RISC-V line, which is the practical argument for standardising on it:
| Part | Radio / IO relevant to provisioning | Typical role |
|---|---|---|
| CH32V003 | No radio, RV32EC at 48 MHz | Sensor/peripheral behind a bridge |
| CH32V208 | BLE 5.3 + 10M Ethernet + USB 2.0 FS | Provisioning endpoint + wired fallback |
| CH32V307 | Gigabit Ethernet, no radio | Wired-only gateway MCU |
| CH32V317 | USB 480 Mbps high-speed | Host-side bridge / debug probe |
| CH32H417 | USB 3.0 | High-throughput data path |
In a K1 or K3 product the split is clean: the SBC runs the application, the AI stack, and the network; the CH32V208 owns the always-on radio, the pairing window, and the credential handoff. That keeps the credential-handling attack surface on a $1 part with 64 KB of SRAM and a deterministic state machine, rather than on a Linux userspace process with a full network stack.
A K1 plus K3 heterogeneous deployment walkthrough - which workload belongs on the 2 TOPS K1 versus the 60 TOPS K3, how to split an inference pipeline across them, and what the interconnect actually costs. If the forum picks up, the weekly roundup returns as usual.