Filing this against the fork as a bench note, not a CircuitPython bug — because it looks exactly like one, and cost the best part of a session to rule out. If anyone else reports "the board advertises the Nordic UART service but doesn't expose it", this is the first thing to check.
Symptom
An ESP32-C6 running 10.3.0-alpha.4-82-g3cab908ada-dirty (branch wifi-ap-debug) advertises a ProvideServicesAdvertisement(UARTService()) correctly — the scan shows the 128-bit UUID in the advertising data — but every GATT connection from Windows enumerates only:
svc 00001800-0000-1000-8000-00805f9b34fb (GAP) [2a00, 2a01]
svc 00001801-0000-1000-8000-00805f9b34fb (GATT) [2a05, 2b3a, 2b29]
No 6e400001-…. bleak then fails with BleakCharacteristicNotFoundError: Characteristic 6e400003-b5a3-f393-e0a9-e50e24dcca9e was not found!, and Chrome's Web Bluetooth getPrimaryService() fails the same way. The link itself is fine — MTU negotiates to 256, and the hub's console logs BLE client connected.
It is not the board
Ruled out one at a time on the hardware:
-
Not the softAP, not ESP-NOW, not the application. A bare server typed straight into the REPL — no AP, no ESP-NOW, nothing else running — behaves identically:
import supervisor
supervisor.runtime.ble_workflow = False
from adafruit_ble import BLERadio
from adafruit_ble.advertising.standard import ProvideServicesAdvertisement
from adafruit_ble.services.nordic import UARTService
radio = BLERadio(); radio.name = "MINBLE"
uart = UARTService()
adv = ProvideServicesAdvertisement(uart); adv.complete_name = "MINBLE"
radio.start_advertising(adv)
# advertising: True, advert is 29 bytes (fits a legacy PDU)
-
It is the peer address. Add one line before the above and the UART service appears immediately, same firmware, same board, same Windows machine, same bleak call:
import _bleio
_bleio.adapter.address = _bleio.Address(b"\x77\x66\x55\x44\x33\xc2",
_bleio.Address.RANDOM_STATIC)
found C2:33:44:55:66:77 MINBLE2
mtu 256 services ['00001800', '00001801', '00001811', '6e400001']
NUS TX present: True
So Windows is serving a cached attribute table keyed on the BLE address (54:32:04:0c:d7:56 here), and that cache was populated at some earlier moment when the table really did lack the service.
Note the address only survives a hard reset — a soft reload (Ctrl-D) puts the original back, so the override has to go in boot.py, not code.py.
What does not clear it
-
bleak's uncached discovery. BleakClient(dev, winrt=dict(use_cached_services=False)) is honoured by the backend (bleak 3.0.2 reads it into self._use_cached_services and maps it to BluetoothCacheMode.Uncached) and still returns GAP+GATT only. Same for services=[NUS_UUID] as a discovery hint, and pair=True fails outright (Could not pair with device: FAILED).
-
Power-cycling the Bluetooth radio. No admin needed, via the WinRT Radios API:
import asyncio
from winrt.windows.devices.radios import Radio, RadioKind, RadioState
async def main():
for r in await Radio.get_radios_async():
if r.kind == RadioKind.BLUETOOTH:
await r.set_state_async(RadioState.OFF)
await asyncio.sleep(4)
await r.set_state_async(RadioState.ON)
await asyncio.sleep(6)
asyncio.run(main())
Reported success for both transitions; the stale table came back unchanged on the next connect.
-
Deleting the cached device key. Needs elevation — from a normal shell it is refused:
$p = 'HKLM:\SYSTEM\CurrentControlSet\Services\BTHPORT\Parameters\Devices\5432040cd756'
Remove-Item -Path $p -Recurse -Force
# failed: Requested registry access is not allowed.
(5432040cd756 is the BLE address with the colons stripped. Get-ChildItem on …\Parameters\Devices lists every address the machine has seen.) Even elevated, the key came straight back, and the reports are that the removal only takes effect after the Bluetooth adapter is power-cycled — so if you go this route, do both: delete the key, then cycle the radio with the snippet above, then retry. On this machine the combination still did not shift it, and there is no paired-device entry to remove either (Get-PnpDevice -Class Bluetooth shows no entry for the board, and …\Devices\<addr>\ServicesFor<adapter> holds only pairing/auth values, no service list).
Workaround
Give the board a BLE address Windows has never seen, from boot.py so it is in place before code.py brings BLE up. Cheap, reversible, and it stays fixed afterwards — the cache Windows then builds for the new address is the correct one.
Why this might still be worth something upstream
The thing that seeded the bad cache is the interesting part and I have not pinned it down. Candidates, in the order I'd look:
- CircuitPython's own BLE workflow advertising as
CIRCUITPY{mac} from the same adapter and the same address with a completely different service set. A client that enumerates the workflow's table and then meets ours has been handed two different GATT databases for one address. collector/code.py already turns the workflow off (supervisor.runtime.ble_workflow = False), but a boot before that landed — or any boot where the app's BLE init failed — advertises the workflow's set instead.
- The peripheral exposes
0x2B3A/0x2B29 (Server/Client Supported Features) in its GATT service, i.e. robust caching. Under robust caching a client is entitled to trust its cached table until the server indicates Service Changed or answers Database Out Of Sync — so if the database differs between boots and the C6 does not send Service Changed to a client with no bond, Windows is arguably behaving to spec and the board is at fault for changing its table silently.
If that second point is real, the fix would be for _bleio to send a Service Changed indication (or bump the database hash) when the service set differs from the previous boot. Worth checking against a C3/S3 to see whether this is C6-specific before taking it to adafruit/circuitpython.
Environment
|
|
| Board |
ESP32-C6-DevKitC-1-N8 (espressif_esp32c6_devkitc_1_n8), BLE address 54:32:04:0c:d7:56 |
| CircuitPython |
10.3.0-alpha.4-82-g3cab908ada-dirty, branch wifi-ap-debug |
| Library |
adafruit_ble 10.1.3 (UARTService, server RX CharacteristicBuffer = 512 B) |
| Host |
Windows 11 Pro 10.0.26200, RZ717 Bluetooth(R) Adapter |
| Client |
bleak 3.0.2 (WinRT backend); Chrome Web Bluetooth shows the same |
Found while fixing tyeth/deepsleep_espnow_wifi_and_ble_env_collector#25 (pushing a TLS certificate to the hub over the Nordic UART service).
Filing this against the fork as a bench note, not a CircuitPython bug — because it looks exactly like one, and cost the best part of a session to rule out. If anyone else reports "the board advertises the Nordic UART service but doesn't expose it", this is the first thing to check.
Symptom
An ESP32-C6 running
10.3.0-alpha.4-82-g3cab908ada-dirty(branchwifi-ap-debug) advertises aProvideServicesAdvertisement(UARTService())correctly — the scan shows the 128-bit UUID in the advertising data — but every GATT connection from Windows enumerates only:No
6e400001-…. bleak then fails withBleakCharacteristicNotFoundError: Characteristic 6e400003-b5a3-f393-e0a9-e50e24dcca9e was not found!, and Chrome's Web BluetoothgetPrimaryService()fails the same way. The link itself is fine — MTU negotiates to 256, and the hub's console logsBLE client connected.It is not the board
Ruled out one at a time on the hardware:
Not the softAP, not ESP-NOW, not the application. A bare server typed straight into the REPL — no AP, no ESP-NOW, nothing else running — behaves identically:
It is the peer address. Add one line before the above and the UART service appears immediately, same firmware, same board, same Windows machine, same bleak call:
So Windows is serving a cached attribute table keyed on the BLE address (
54:32:04:0c:d7:56here), and that cache was populated at some earlier moment when the table really did lack the service.Note the address only survives a hard reset — a soft reload (Ctrl-D) puts the original back, so the override has to go in
boot.py, notcode.py.What does not clear it
bleak's uncached discovery.
BleakClient(dev, winrt=dict(use_cached_services=False))is honoured by the backend (bleak 3.0.2 reads it intoself._use_cached_servicesand maps it toBluetoothCacheMode.Uncached) and still returns GAP+GATT only. Same forservices=[NUS_UUID]as a discovery hint, andpair=Truefails outright (Could not pair with device: FAILED).Power-cycling the Bluetooth radio. No admin needed, via the WinRT Radios API:
Reported success for both transitions; the stale table came back unchanged on the next connect.
Deleting the cached device key. Needs elevation — from a normal shell it is refused:
(
5432040cd756is the BLE address with the colons stripped.Get-ChildItemon…\Parameters\Deviceslists every address the machine has seen.) Even elevated, the key came straight back, and the reports are that the removal only takes effect after the Bluetooth adapter is power-cycled — so if you go this route, do both: delete the key, then cycle the radio with the snippet above, then retry. On this machine the combination still did not shift it, and there is no paired-device entry to remove either (Get-PnpDevice -Class Bluetoothshows no entry for the board, and…\Devices\<addr>\ServicesFor<adapter>holds only pairing/auth values, no service list).Workaround
Give the board a BLE address Windows has never seen, from
boot.pyso it is in place beforecode.pybrings BLE up. Cheap, reversible, and it stays fixed afterwards — the cache Windows then builds for the new address is the correct one.Why this might still be worth something upstream
The thing that seeded the bad cache is the interesting part and I have not pinned it down. Candidates, in the order I'd look:
CIRCUITPY{mac}from the same adapter and the same address with a completely different service set. A client that enumerates the workflow's table and then meets ours has been handed two different GATT databases for one address.collector/code.pyalready turns the workflow off (supervisor.runtime.ble_workflow = False), but a boot before that landed — or any boot where the app's BLE init failed — advertises the workflow's set instead.0x2B3A/0x2B29(Server/Client Supported Features) in its GATT service, i.e. robust caching. Under robust caching a client is entitled to trust its cached table until the server indicates Service Changed or answers Database Out Of Sync — so if the database differs between boots and the C6 does not send Service Changed to a client with no bond, Windows is arguably behaving to spec and the board is at fault for changing its table silently.If that second point is real, the fix would be for
_bleioto send a Service Changed indication (or bump the database hash) when the service set differs from the previous boot. Worth checking against a C3/S3 to see whether this is C6-specific before taking it to adafruit/circuitpython.Environment
espressif_esp32c6_devkitc_1_n8), BLE address54:32:04:0c:d7:5610.3.0-alpha.4-82-g3cab908ada-dirty, branchwifi-ap-debugadafruit_ble10.1.3 (UARTService, server RXCharacteristicBuffer= 512 B)Found while fixing tyeth/deepsleep_espnow_wifi_and_ble_env_collector#25 (pushing a TLS certificate to the hub over the Nordic UART service).