Skip to content

ESP32-C6 (_bleio): discover_remote_services returns empty services or NimBLE OOM; disconnect state inconsistent #10801

@berkut0

Description

@berkut0

Summary

On Seeed XIAO ESP32-C6 running CircuitPython 10.0.3 and 10.1.0-beta1, BLE central operations using _bleio show failures when connecting to a BLE peripheral that advertises 16-bit service UUIDs 0xAE30 / 0xAF30: service discovery either raises MemoryError: Nimble out of memory or returns zero services. Additionally, BLE connection state/teardown appears inconsistent across soft reloads (see “Disconnect behavior” below).

Environment

  • Board: Seeed XIAO ESP32-C6 (4MB Flash, 512KB SRAM); board_id: seeed_xiao_esp32c6
  • CircuitPython: 10.0.3 (e.g. Adafruit CircuitPython 10.0.3 on 2025-10-17; Seeed Xiao ESP32-C6 4MB Flash 512KB SRAM with ESP32-C6FH4) and 10.1.0-beta1
  • Host: Thonny over serial (no USB mass storage)
  • BLE peripheral: A device advertising 16-bit service UUID 0xAE30 or 0xAF30 (and optionally a local name). Model/type noted in "Tested peripheral" below.

Steps to reproduce

  1. Power-cycle the ESP32-C6 to start from a clean BLE state (adapter.connected should typically be False at start). Some BLE state/teardown behavior appears inconsistent across soft reloads (see Actual behavior).

  2. Ensure the target BLE peripheral is powered on and nearby.

  3. Copy ble_mx06_probe.py to the device and run from REPL (to avoid module cache):

    exec(open("ble_mx06_probe.py").read())
  4. Observe: on 10.0.3, discovery typically fails with NimBLE OOM; on 10.1.0-beta1, discovery often returns 0 services.

Adapting the probe to another BLE peripheral (optional)

  • Run once and look at the probe output scan: merged devices ... to see candidate devices and their name and uuids16.
  • If your device advertises a local name, set TARGET_NAME_SUBSTR to a unique substring of that name.
  • If the name is missing but it advertises a 16-bit service UUID, set UUID16_CANDIDATES to include that UUID (common examples: Battery 0x180F, Heart Rate 0x180D, Device Information 0x180A).
  • If your device only advertises 128-bit service UUIDs, you can still match by name; otherwise you’ll need to tweak the probe to target a specific addr= from the scan output (or add 128-bit UUID parsing).

Expected behavior

  • After a successful connection, conn.discover_remote_services([uuid]) should return the advertised service(s) or fail with a clear Python exception.
  • After calling disconnect(), adapter.connected / adapter.connections should reflect the disconnection in a reasonable amount of time.

Actual behavior

  • CP 10.0.3: Scan and connect succeed. discover_remote_services([0xAE30]) raises MemoryError: Nimble out of memory.
  • CP 10.1.0-beta1: Scan shows the peripheral (e.g. uuids16=[0xAF30]), connect succeeds. discover_remote_services([0xAF30]) returns 0 services (no exception). In some runs a second discovery attempt triggers MemoryError: Nimble out of memory.
  • Disconnect behavior (10.1.0): After a supervisor.reload() with a live connection, the next run starts with adapter.connected=True and adapter.connections=1. Calling disconnect() on that connection can return without an exception, but adapter.connected and adapter.connections may remain unchanged (connection appears to persist).
  • Unconfirmed / historical note (possibly 10.0.3): In some debugging sessions, after interrupting a hanging _bleio.adapter.connect(timeout=...) with Ctrl+C and then doing del sys.modules[...] + re-import of the test module, a hard fault was observed. We are currently unable to reproduce this reliably on 10.1.0, so this is included only as an anecdotal symptom.

Minimal logs

10.0.3 — discover fails with OOM:

connect: ok
post-connect mem_free= 178064
discover: AE30 ...
pre-discover mem_free= 178064
discover: MemoryError: Nimble out of memory

10.1.0-beta1 — discover returns 0 services:

scan: selected addr= ... uuids16= [0xAF30] match_uuid16= 0xAF30
connect: ok
discover: uuid16 0xaf30 -> services=0
discover: WARN no services discovered for candidates

Disconnect returns but state stays connected (10.1.0, after soft reload):

adapter.connected = True
adapter.connections = 1
check: disconnect conn 0
check: disconnect returned for conn 0
== after disconnect attempts ==
adapter.connected = True
adapter.connections = 1

Repro script

Save as ble_mx06_probe.py on the device and run from REPL: exec(open("ble_mx06_probe.py").read())

Full script (click to expand)
# BLE probe: scan -> connect -> discover (0xAE30/0xAF30). Run from REPL: exec(open("ble_mx06_probe.py").read())

import gc
import time
import _bleio
import sys

PROBE_VERSION = "10"
TARGET_NAME_SUBSTR = "MX"
UUID16_CANDIDATES = (0xAE30, 0xAF30)
DISCOVER_TRY_ALL = False

def _ad_structures(ad_bytes):
    i = 0
    n = len(ad_bytes)
    while i < n:
        length = ad_bytes[i]
        if length == 0:
            break
        if i + 1 >= n:
            break
        end = i + 1 + length
        if end > n:
            break
        ad_type = ad_bytes[i + 1]
        yield ad_type, ad_bytes[i + 2 : end]
        i += 1 + length
    return

def _parse_name(ad_bytes):
    for ad_type, data in _ad_structures(ad_bytes):
        if ad_type in (0x08, 0x09):
            try:
                return data.decode("utf-8", "ignore")
            except Exception:
                return None
    return None

def _list_uuid16(ad_bytes):
    out = []
    for ad_type, data in _ad_structures(ad_bytes):
        if ad_type in (0x02, 0x03):
            for j in range(0, len(data) - 1, 2):
                out.append(data[j] | (data[j + 1] << 8))
    return out

def mem(tag):
    try:
        print(tag, "mem_free=", gc.mem_free())
    except Exception:
        print(tag)

print("--- ble_mx06_probe: start v" + PROBE_VERSION + " ---")
try:
    print("sys.implementation =", sys.implementation)
except Exception:
    pass
try:
    import supervisor
    print("safe_mode =", getattr(supervisor.runtime, "safe_mode", None))
except Exception:
    pass
mem("start")

entry_match = None
match_uuid16 = None
match_addr = None
by_addr = {}

print("adapter.enabled =", getattr(_bleio.adapter, "enabled", None))
print("adapter.connected =", getattr(_bleio.adapter, "connected", None))
print("adapter.connections =", len(getattr(_bleio.adapter, "connections", ()) or ()))

try:
    _bleio.adapter.enabled = True
except Exception:
    pass

if getattr(_bleio.adapter, "connected", False):
    print("WARNING: adapter.connected=True at start; skipping disconnect (can hard-fault).")
    print("ACTION: hard reset / power-cycle the board, and ensure printer is OFF, then retry.")
    print("--- ble_mx06_probe: done (early) ---")
    raise SystemExit

try:
    _bleio.adapter.enabled = False
    time.sleep(0.2)
    _bleio.adapter.enabled = True
    time.sleep(0.2)
    print("adapter toggled, connected =", getattr(_bleio.adapter, "connected", None))
except Exception as e:
    print("adapter toggle skipped:", e)

print("scan: ensure stopped (defensive)")
try:
    _bleio.adapter.stop_scan()
except Exception:
    pass

SCAN_TIMEOUT_S = 10
print("scan: calling _bleio.adapter.start_scan(timeout=" + str(SCAN_TIMEOUT_S) + ", active=True)")
mem("pre-scan")
try:
    scan_iter = _bleio.adapter.start_scan(timeout=SCAN_TIMEOUT_S, active=True, minimum_rssi=-127, buffer_size=1024)
    print("scan: iterator ok")
    t0 = time.monotonic()
    seen = 0
    named = 0
    uuid_hits = {u: 0 for u in UUID16_CANDIDATES}
    for entry in scan_iter:
        seen += 1
        ad = entry.advertisement_bytes
        name = _parse_name(ad)
        uuids16 = _list_uuid16(ad)
        k = str(entry.address)
        st = by_addr.get(k)
        if st is None:
            st = {"addr": entry.address, "best_rssi": entry.rssi, "connectable_any": bool(entry.connectable),
                  "scan_response_any": bool(entry.scan_response), "name": None, "uuids16": set(), "packets": 0}
            by_addr[k] = st
        st["packets"] += 1
        if entry.rssi > st["best_rssi"]:
            st["best_rssi"] = entry.rssi
            st["addr"] = entry.address
        st["connectable_any"] = st["connectable_any"] or bool(entry.connectable)
        st["scan_response_any"] = st["scan_response_any"] or bool(entry.scan_response)
        if name:
            st["name"] = name
        for u in uuids16:
            st["uuids16"].add(u)
        for u in UUID16_CANDIDATES:
            if u in uuids16:
                uuid_hits[u] += 1
        if name:
            named += 1
        if seen <= 8:
            try:
                print("  entry", seen, "addr=", str(entry.address), "rssi=", entry.rssi, "connectable=", entry.connectable,
                      "scan_response=", entry.scan_response, "len=", len(ad), "name=", name, "uuids16=", uuids16)
                if seen <= 2:
                    print("    raw_hex=", ad.hex())
            except Exception:
                print("  entry", seen, "name=", name)
    print("scan: loop ended after", round(time.monotonic() - t0, 2), "s; seen=", seen, "named=", named, "uuid16_hits=", uuid_hits)
finally:
    try:
        _bleio.adapter.stop_scan()
    except Exception:
        pass

print("scan: merged devices (top 6 by RSSI):")
top = sorted(by_addr.items(), key=lambda kv: kv[1]["best_rssi"], reverse=True)[:6]
for k, st in top:
    print("  addr=", k, "rssi=", st["best_rssi"], "packets=", st["packets"], "conn_any=", st["connectable_any"],
          "sr_any=", st["scan_response_any"], "name=", st["name"], "uuids16=", sorted(st["uuids16"]))

best = None
for k, st in sorted(by_addr.items(), key=lambda kv: kv[1]["best_rssi"], reverse=True):
    if st["name"] and TARGET_NAME_SUBSTR in st["name"]:
        best = (k, st)
        break
if best is None:
    for k, st in sorted(by_addr.items(), key=lambda kv: kv[1]["best_rssi"], reverse=True):
        if any(u in st["uuids16"] for u in UUID16_CANDIDATES):
            best = (k, st)
            break

if best:
    match_addr, st = best
    if 0xAF30 in st["uuids16"]:
        match_uuid16 = 0xAF30
    elif 0xAE30 in st["uuids16"]:
        match_uuid16 = 0xAE30
    else:
        match_uuid16 = None
    print("scan: selected addr=", match_addr, "name=", st["name"], "uuids16=", sorted(st["uuids16"]), "match_uuid16=", match_uuid16)
    entry_match = st["addr"]
else:
    entry_match = None

mem("post-scan")

if not entry_match:
    print("FAIL: no MX*/AE30/AF30 candidate seen in", SCAN_TIMEOUT_S, "s")
    print("--- ble_mx06_probe: done ---")
    raise SystemExit

print("connect: ...")
mem("pre-connect")
try:
    conn = _bleio.adapter.connect(entry_match, timeout=10)
except Exception as e:
    print("connect: error:", e)
    print("--- ble_mx06_probe: done ---")
    raise SystemExit
print("connect: ok")
mem("post-connect")

try:
    order = [match_uuid16] if match_uuid16 else [0xAE30]
    if DISCOVER_TRY_ALL:
        for u in UUID16_CANDIDATES:
            if u not in order:
                order.append(u)
    print("discover: trying uuid16 in order:", [hex(u) for u in order], "; DISCOVER_TRY_ALL=", DISCOVER_TRY_ALL)
    mem("pre-discover")
    any_found = False
    for u in order:
        svc_uuid = _bleio.UUID(u)
        try:
            services = conn.discover_remote_services([svc_uuid])
        except MemoryError as e:
            print("discover: uuid16 0x%04X -> MemoryError: %s" % (u, e))
            raise
        print("discover: uuid16 0x%04X -> services=%d" % (u, len(services)))
        if services:
            any_found = True
            break
    if not any_found:
        print("discover: WARN no services discovered for candidates")
    mem("post-discover")
    time.sleep(0.1)
except MemoryError as e:
    print("discover: MemoryError:", e)
    mem("post-discover(MemoryError)")
finally:
    print("disconnect")
    conn.disconnect()
    mem("post-disconnect")

print("--- ble_mx06_probe: done ---")

Tested peripheral (context)

  • Name: Often advertised as MX06 (in scan response).
  • Advertising: 16-bit service UUID seen as 0xAF30 (e.g. raw 020106030330af) or 0xAE30; address type varies (e.g. random).
  • Device: MX06-style thermal printer used as a concrete BLE peripheral for testing; the issue is reproduced at the _bleio/NimBLE level, not printer-specific.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions