diff --git a/TUTORIAL.md b/TUTORIAL.md index 13ee469..91b6dba 100644 --- a/TUTORIAL.md +++ b/TUTORIAL.md @@ -520,7 +520,7 @@ Connect the badge to a computer. The joystick moves the mouse cursor; button A i ## BLE -The nice!nano's nRF52840 chip has built-in Bluetooth Low Energy. These examples use the [`github.com/tinygo-org/bluetooth`](https://github.com/tinygo-org/bluetooth) library. +The nice!nano's nRF52840 chip has built-in Bluetooth Low Energy. These examples use the [`tinygo.org/x/bluetooth`](https://tinygo.org/x/bluetooth) library. **Recommended mobile apps** @@ -558,28 +558,50 @@ This example implements the **Nordic UART Service (NUS)** — a de-facto standar | RX | `6E400002-…` | Write, WriteWithoutResponse | Central → Badge | | TX | `6E400003-…` | Notify, Read | Badge → Central | -The counter increments every second. `txChar.Write()` sends a notification to any subscribed central. If `Write` returns an error no device is listening — that is how connection state is tracked. +Connection state is tracked via `adapter.SetConnectHandler`, which receives real events from the nRF52840 SoftDevice. The handler only sets flags — display and advertising calls happen in the main loop to avoid re-entering the SoftDevice from its own event callback. ```go -_, err := txChar.Write([]byte(strconv.Itoa(counter) + "\n")) -connected = err == nil +adapter.SetConnectHandler(func(device bluetooth.Device, c bool) { + connected = c + connChanged = true +}) + +for { + if connChanged { + connChanged = false + if connected { + drawStatus("Connected ") + } else { + drawStatus("Advertising...") + adv.Start() + } + } + counter++ + drawCounter(counter) + if connected { + txChar.Write([]byte(strconv.Itoa(counter) + "\n")) + } + time.Sleep(time.Second) +} ``` +`AddService` must be called before `adv.Start()` — the nRF52840 SoftDevice needs the complete GATT table before advertising begins. + ```sh tinygo flash -target nicenano ./ble/step1 ``` **How to test** 1. Flash the badge. The display shows `BLE: Advertising...`. -2. Open **nRF Toolbox** → UART → Connect → search for `NiceBadge`. +2. Open **nRF Connect** → SCANNER → search for `NiceBadge`. 3. Once connected the display shows `BLE: Connected` and the counter appears in the terminal. 4. Type `reset` and send it — the counter resets to zero. **Key concepts** - `adapter.Enable()` — starts the BLE stack (SoftDevice on nRF52840). Must be called before anything else. -- `adapter.AddService` — registers the GATT service and its characteristics. -- `adv.Start()` — begins advertising; the badge is now discoverable. -- The `WriteEvent` callback runs when the central writes to a characteristic. It runs in a BLE interrupt context — keep it short. +- `adapter.AddService` must be called **before** `adv.Start()` on nRF52840 — the GATT table is frozen once advertising starts. +- `adapter.SetConnectHandler` — the correct way to track connection state; `txChar.Write()` always returns `nil` on nRF52840 regardless of whether a central is connected. +- Keep BLE callbacks short and flag-only — calling SoftDevice functions (like `adv.Start()`) or SPI ops from within a SoftDevice event handler causes a deadlock. --- @@ -594,6 +616,8 @@ A custom service exposes a single writable characteristic. The central writes 3 |---|---|---|---| | LED Color | `BADA5502-…` | Write, WriteWithoutResponse | Central → Badge | +The `WriteEvent` callback handles the color update directly (SPI and GPIO — no SoftDevice re-entry). Connection tracking uses the same flag pattern as step1. + ```go WriteEvent: func(client bluetooth.Connection, offset int, value []byte) { if len(value) < 3 { @@ -605,7 +629,25 @@ WriteEvent: func(client bluetooth.Connection, offset int, value []byte) { }, ``` -After all services are registered the main goroutine simply blocks with `select {}` — all activity is driven by the BLE callback. +```go +adapter.SetConnectHandler(func(device bluetooth.Device, c bool) { + connected = c + connChanged = true +}) +// ... +for { + if connChanged { + connChanged = false + if connected { + drawStatus("Connected ") + } else { + drawStatus("Advertising...") + adv.Start() + } + } + time.Sleep(100 * time.Millisecond) +} +``` ```sh tinygo flash -target nicenano ./ble/step2 @@ -619,7 +661,7 @@ tinygo flash -target nicenano ./ble/step2 **Key concepts** - Custom 128-bit UUIDs let you define entirely private services not shared with any standard profile. -- `select {}` is idiomatic Go for blocking forever; it is more explicit than `for {}` with a sleep. +- `WriteEvent` can call SPI and GPIO safely — it only avoids re-entering the SoftDevice (e.g. `adv.Start()`). - Always validate `len(value)` in `WriteEvent` — a malformed write should not panic. --- @@ -628,20 +670,28 @@ tinygo flash -target nicenano ./ble/step2 **Goal:** put the radio in observer mode and display nearby BLE devices. -In scanner mode the badge does not advertise — it only listens. `adapter.Scan` is a blocking call, so it runs in a goroutine while the main loop updates the display every 500 ms. +`adapter.Scan` is a blocking call that drives its own internal event loop via `sd_app_evt_wait` — the nRF52840 SoftDevice primitive for waiting on BLE events. While inside this loop, **TinyGo's cooperative scheduler never gets CPU time**, so goroutines spawned to call `adapter.StopScan()` after a timeout never run. + +Running `adapter.Scan` and SPI display operations concurrently causes a second problem: the SoftDevice can hold interrupts during event processing, and TinyGo's SPI driver waits for a DMA-completion interrupt using `wfe` (Wait For Event). If the SoftDevice consumes that wake-up event, the SPI transfer hangs forever. + +The solution for both problems is to call `adapter.StopScan()` **from inside the scan callback** using `time.Since`: ```go -go func() { - adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) { - name := result.LocalName() - if name == "" { - name = result.Address.String() - } - // deduplicate by address, update RSSI - }) -}() +scanStart := time.Now() +adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) { + if time.Since(scanStart) >= scanWindow { + adapter.StopScan() // causes adapter.Scan to return + return + } + // deduplicate by address, update RSSI, copy name bytes +}) + +// adapter.Scan has returned — SPI is safe to use now +drawDevices() ``` +Device names and addresses are stored as fixed-size byte arrays, not `string` fields. Strings returned by `result.LocalName()` and `result.Address.String()` may point into SoftDevice-managed buffers that are recycled after the callback returns; accessing them later from the main loop causes memory corruption. + RSSI (Received Signal Strength Indicator) is expressed in dBm — closer to 0 is stronger. The display colors devices by signal quality: | RSSI | Color | Meaning | @@ -657,8 +707,10 @@ tinygo flash -target nicenano ./ble/step3 Up to 5 nearby devices are listed by name and signal strength. Press **button A** to clear the list and start fresh. **Key concepts** -- `result.LocalName()` returns the advertised name, if any. Devices that don't advertise a name are identified by their MAC address. -- TinyGo uses a cooperative scheduler — goroutines yield at blocking calls (`Scan`, `Sleep`, channel ops). Shared variables accessed from both the goroutine and the main loop are safe here because the scheduler is cooperative, but in general you should use channels or atomics. +- `adapter.Scan` runs its own internal `sd_app_evt_wait` loop — it never yields to TinyGo's cooperative scheduler. A goroutine that calls `StopScan()` after a `time.Sleep` will never execute while `Scan` is running. +- On nRF52840, **never run SPI and `adapter.Scan` at the same time**. The SoftDevice can consume the WFE wake-up that TinyGo's SPI driver needs to detect DMA completion, causing the SPI bus to hang indefinitely. Always stop the scan before doing any display update. +- **BLE callbacks must not store strings** that point into SoftDevice buffers. Use fixed-size `[N]byte` arrays and `copy()` in callbacks; convert to `string` only in the main loop. +- `result.LocalName()` returns the advertised name, if any. Devices that don't advertise a name are shown by their MAC address (`result.Address.String()`). --- diff --git a/tutorial/ble/step1/main.go b/tutorial/ble/step1/main.go index 30db9fa..1c1af5f 100644 --- a/tutorial/ble/step1/main.go +++ b/tutorial/ble/step1/main.go @@ -11,7 +11,7 @@ import ( "strconv" "time" - "github.com/tinygo-org/bluetooth" + "tinygo.org/x/bluetooth" "tinygo.org/x/drivers/st7789" "tinygo.org/x/tinyfont" "tinygo.org/x/tinyfont/freesans" @@ -36,9 +36,10 @@ var ( ) var ( - display st7789.Device - connected bool - counter int + display st7789.Device + connected bool + connChanged bool + counter int ) var ( @@ -49,12 +50,21 @@ var ( ) func main() { + // sometimes, a little wait is needed to initialize it at hardware level + time.Sleep(2 * time.Second) initDisplay() drawStatus("Advertising...") drawCounter(0) must("enable BLE", adapter.Enable()) + adv := adapter.DefaultAdvertisement() + + must("configure adv", adv.Configure(bluetooth.AdvertisementOptions{ + LocalName: "NiceBadge", + })) + + // AddService must be called before adv.Start() on nRF52840 SoftDevice. var txChar bluetooth.Characteristic must("add service", adapter.AddService(&bluetooth.Service{ @@ -78,32 +88,31 @@ func main() { }, })) - adv := adapter.DefaultAdvertisement() - must("configure adv", adv.Configure(bluetooth.AdvertisementOptions{ - LocalName: "NiceBadge", - ServiceUUIDs: []bluetooth.UUID{serviceUUID}, - })) + // Only set flags here — calling adv.Start() or display ops from within + // the SoftDevice event callback causes a deadlock on nRF52840. + adapter.SetConnectHandler(func(device bluetooth.Device, c bool) { + connected = c + connChanged = true + }) + must("start adv", adv.Start()) for { - counter++ - drawCounter(counter) - - // Write sends a BLE notification to subscribed centrals. - // If no device is subscribed, Write returns an error. - _, err := txChar.Write([]byte(strconv.Itoa(counter) + "\n")) - - wasConnected := connected - connected = err == nil - if wasConnected != connected { + if connChanged { + connChanged = false if connected { drawStatus("Connected ") } else { drawStatus("Advertising...") - must("restart adv", adv.Start()) + adv.Start() } } + counter++ + drawCounter(counter) + if connected { + txChar.Write([]byte(strconv.Itoa(counter) + "\n")) + } time.Sleep(time.Second) } } diff --git a/tutorial/ble/step2/main.go b/tutorial/ble/step2/main.go index 688dce5..4583668 100644 --- a/tutorial/ble/step2/main.go +++ b/tutorial/ble/step2/main.go @@ -14,8 +14,9 @@ import ( "image/color" "machine" "strconv" + "time" - "github.com/tinygo-org/bluetooth" + "tinygo.org/x/bluetooth" "tinygo.org/x/drivers/st7789" "tinygo.org/x/drivers/ws2812" "tinygo.org/x/tinyfont" @@ -36,27 +37,30 @@ var ( ) var ( - display st7789.Device - leds ws2812.Device - connected bool - ledColor color.RGBA + display st7789.Device + leds ws2812.Device + connected bool + connChanged bool + ledColor color.RGBA + colorChanged bool ) var ( black = color.RGBA{0, 0, 0, 255} cyan = color.RGBA{0, 200, 255, 255} white = color.RGBA{255, 255, 255, 255} - gray = color.RGBA{150, 150, 150, 255} ) func main() { + time.Sleep(2 * time.Second) initDisplay() initLEDs() drawStatus("Advertising...") - drawColor(color.RGBA{0, 0, 0, 255}) + drawColor(color.RGBA{}) must("enable BLE", adapter.Enable()) + // AddService must be called before adv.Start() on nRF52840 SoftDevice. must("add service", adapter.AddService(&bluetooth.Service{ UUID: ledServiceUUID, Characteristics: []bluetooth.CharacteristicConfig{ @@ -67,29 +71,45 @@ func main() { if len(value) < 3 { return } - // value[0]=R, value[1]=G, value[2]=B + // Only assign values — no heap allocs (strconv, string concat) + // allowed in interrupt context on nRF52840. ledColor = color.RGBA{value[0], value[1], value[2], 255} - setLEDs(ledColor) - drawColor(ledColor) - - if !connected { - connected = true - drawStatus("Connected ") - } + colorChanged = true }, }, }, })) + // Only set flags here — calling adv.Start() or display ops from within + // the SoftDevice event callback causes a deadlock on nRF52840. + adapter.SetConnectHandler(func(device bluetooth.Device, c bool) { + connected = c + connChanged = true + }) + adv := adapter.DefaultAdvertisement() must("configure adv", adv.Configure(bluetooth.AdvertisementOptions{ - LocalName: "NiceBadge", - ServiceUUIDs: []bluetooth.UUID{ledServiceUUID}, + LocalName: "NiceBadge", })) must("start adv", adv.Start()) - // wait forever; all logic is driven by the BLE WriteEvent callback - select {} + for { + if connChanged { + connChanged = false + if connected { + drawStatus("Connected ") + } else { + drawStatus("Advertising...") + adv.Start() + } + } + if colorChanged { + colorChanged = false + setLEDs(ledColor) + drawColor(ledColor) + } + time.Sleep(100 * time.Millisecond) + } } func initDisplay() { @@ -127,7 +147,6 @@ func drawStatus(status string) { } func drawColor(c color.RGBA) { - // show a filled rectangle with the current color and its RGB values below display.FillRectangle(0, 38, 240, 60, color.RGBA{c.R, c.G, c.B, 255}) display.FillRectangle(0, 100, 240, 35, black) rgb := "R:" + strconv.Itoa(int(c.R)) + diff --git a/tutorial/ble/step3/main.go b/tutorial/ble/step3/main.go index efbffd0..981a8c8 100644 --- a/tutorial/ble/step3/main.go +++ b/tutorial/ble/step3/main.go @@ -1,8 +1,8 @@ package main // BLE scanner example. -// Scans for nearby BLE devices and shows their names, addresses and signal -// strength (RSSI) on the display. Press button A to clear the list. +// Scans for nearby BLE devices and shows their names and signal strength +// (RSSI) on the display. Press button A to clear the list. import ( "image/color" @@ -10,17 +10,21 @@ import ( "strconv" "time" - "github.com/tinygo-org/bluetooth" + "tinygo.org/x/bluetooth" "tinygo.org/x/drivers/st7789" "tinygo.org/x/tinyfont" "tinygo.org/x/tinyfont/freesans" ) const maxDevices = 5 +const maxNameLen = 16 +const scanWindow = 500 * time.Millisecond +// Fixed-size byte arrays avoid dangling pointers into SoftDevice buffers. type bleDevice struct { - name string - addr string + name [maxNameLen]byte + nLen uint8 + addr bluetooth.Address rssi int16 } @@ -41,53 +45,56 @@ var ( ) func main() { + time.Sleep(2 * time.Second) initDisplay() btnA := machine.P1_06 btnA.Configure(machine.PinConfig{Mode: machine.PinInputPullup}) must("enable BLE", adapter.Enable()) - drawHeader() - // scan runs in a goroutine; found devices are stored and displayed in the main loop - go func() { - adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) { - name := result.LocalName() - if name == "" { - name = result.Address.String() - } - - // update existing entry if already seen, otherwise add new - found := false - for i := 0; i < count; i++ { - if devices[i].addr == result.Address.String() { - devices[i].rssi = result.RSSI - found = true - break - } - } - if !found && count < maxDevices { - devices[count] = bleDevice{ - name: name, - addr: result.Address.String(), - rssi: result.RSSI, - } - count++ - } - }) - }() - for { - // button A clears the list if !btnA.Get() { count = 0 - display.FillRectangle(0, 32, 240, 103, black) time.Sleep(200 * time.Millisecond) } + // adapter.Scan never yields to TinyGo's scheduler, so a goroutine + // calling StopScan() never gets CPU time. + // Fix: check elapsed time inside the callback and stop from there. + scanStart := time.Now() + adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) { + if time.Since(scanStart) >= scanWindow { + adapter.StopScan() + return + } + for i := 0; i < count; i++ { + if devices[i].addr == result.Address { + devices[i].rssi = result.RSSI + return + } + } + if count >= maxDevices { + return + } + n := result.LocalName() + if n == "" { + n = result.Address.String() + } + nLen := len(n) + if nLen > maxNameLen { + nLen = maxNameLen + } + copy(devices[count].name[:], n) + devices[count].nLen = uint8(nLen) + devices[count].addr = result.Address + devices[count].rssi = result.RSSI + count++ + }) + + // Scan has stopped — safe to use SPI now. drawDevices() - time.Sleep(500 * time.Millisecond) } } @@ -111,11 +118,10 @@ func initDisplay() { } func drawHeader() { - tinyfont.WriteLine(&display, &freesans.Bold12pt7b, 10, 24, "BLE Scanner", cyan) + tinyfont.WriteLine(&display, &freesans.Regular9pt7b, 10, 24, "BLE Scanner", cyan) } func drawDevices() { - // clear device list area display.FillRectangle(0, 32, 240, 103, black) if count == 0 { @@ -125,13 +131,9 @@ func drawDevices() { for i := 0; i < count; i++ { y := int16(52 + i*20) - name := devices[i].name - if len(name) > 16 { - name = name[:16] - } + name := string(devices[i].name[:devices[i].nLen]) rssi := " " + strconv.Itoa(int(devices[i].rssi)) + "dB" - // color by signal strength c := white if devices[i].rssi > -60 { c = green