tested and updated BLE tutorial

This commit is contained in:
Daniel Esteban 2026-04-20 23:47:00 +02:00
parent 7d8399152c
commit 043b884263
4 changed files with 188 additions and 106 deletions

View file

@ -520,7 +520,7 @@ Connect the badge to a computer. The joystick moves the mouse cursor; button A i
## BLE ## 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** **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 | | RX | `6E400002-…` | Write, WriteWithoutResponse | Central → Badge |
| TX | `6E400003-…` | Notify, Read | Badge → Central | | 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 ```go
_, err := txChar.Write([]byte(strconv.Itoa(counter) + "\n")) adapter.SetConnectHandler(func(device bluetooth.Device, c bool) {
connected = err == nil 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 ```sh
tinygo flash -target nicenano ./ble/step1 tinygo flash -target nicenano ./ble/step1
``` ```
**How to test** **How to test**
1. Flash the badge. The display shows `BLE: Advertising...`. 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. 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. 4. Type `reset` and send it — the counter resets to zero.
**Key concepts** **Key concepts**
- `adapter.Enable()` — starts the BLE stack (SoftDevice on nRF52840). Must be called before anything else. - `adapter.Enable()` — starts the BLE stack (SoftDevice on nRF52840). Must be called before anything else.
- `adapter.AddService` — registers the GATT service and its characteristics. - `adapter.AddService` must be called **before** `adv.Start()` on nRF52840 — the GATT table is frozen once advertising starts.
- `adv.Start()` — begins advertising; the badge is now discoverable. - `adapter.SetConnectHandler` — the correct way to track connection state; `txChar.Write()` always returns `nil` on nRF52840 regardless of whether a central is connected.
- The `WriteEvent` callback runs when the central writes to a characteristic. It runs in a BLE interrupt context — keep it short. - 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 | | 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 ```go
WriteEvent: func(client bluetooth.Connection, offset int, value []byte) { WriteEvent: func(client bluetooth.Connection, offset int, value []byte) {
if len(value) < 3 { 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 ```sh
tinygo flash -target nicenano ./ble/step2 tinygo flash -target nicenano ./ble/step2
@ -619,7 +661,7 @@ tinygo flash -target nicenano ./ble/step2
**Key concepts** **Key concepts**
- Custom 128-bit UUIDs let you define entirely private services not shared with any standard profile. - 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. - 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. **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
go func() { scanStart := time.Now()
adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) { adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) {
name := result.LocalName() if time.Since(scanStart) >= scanWindow {
if name == "" { adapter.StopScan() // causes adapter.Scan to return
name = result.Address.String() return
} }
// deduplicate by address, update RSSI // 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 (Received Signal Strength Indicator) is expressed in dBm — closer to 0 is stronger. The display colors devices by signal quality:
| RSSI | Color | Meaning | | 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. Up to 5 nearby devices are listed by name and signal strength. Press **button A** to clear the list and start fresh.
**Key concepts** **Key concepts**
- `result.LocalName()` returns the advertised name, if any. Devices that don't advertise a name are identified by their MAC address. - `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.
- 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. - 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()`).
--- ---

View file

@ -11,7 +11,7 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/tinygo-org/bluetooth" "tinygo.org/x/bluetooth"
"tinygo.org/x/drivers/st7789" "tinygo.org/x/drivers/st7789"
"tinygo.org/x/tinyfont" "tinygo.org/x/tinyfont"
"tinygo.org/x/tinyfont/freesans" "tinygo.org/x/tinyfont/freesans"
@ -38,6 +38,7 @@ var (
var ( var (
display st7789.Device display st7789.Device
connected bool connected bool
connChanged bool
counter int counter int
) )
@ -49,12 +50,21 @@ var (
) )
func main() { func main() {
// sometimes, a little wait is needed to initialize it at hardware level
time.Sleep(2 * time.Second)
initDisplay() initDisplay()
drawStatus("Advertising...") drawStatus("Advertising...")
drawCounter(0) drawCounter(0)
must("enable BLE", adapter.Enable()) 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 var txChar bluetooth.Characteristic
must("add service", adapter.AddService(&bluetooth.Service{ must("add service", adapter.AddService(&bluetooth.Service{
@ -78,32 +88,31 @@ func main() {
}, },
})) }))
adv := adapter.DefaultAdvertisement() // Only set flags here — calling adv.Start() or display ops from within
must("configure adv", adv.Configure(bluetooth.AdvertisementOptions{ // the SoftDevice event callback causes a deadlock on nRF52840.
LocalName: "NiceBadge", adapter.SetConnectHandler(func(device bluetooth.Device, c bool) {
ServiceUUIDs: []bluetooth.UUID{serviceUUID}, connected = c
})) connChanged = true
})
must("start adv", adv.Start()) must("start adv", adv.Start())
for { for {
counter++ if connChanged {
drawCounter(counter) connChanged = false
// 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 connected { if connected {
drawStatus("Connected ") drawStatus("Connected ")
} else { } else {
drawStatus("Advertising...") 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) time.Sleep(time.Second)
} }
} }

View file

@ -14,8 +14,9 @@ import (
"image/color" "image/color"
"machine" "machine"
"strconv" "strconv"
"time"
"github.com/tinygo-org/bluetooth" "tinygo.org/x/bluetooth"
"tinygo.org/x/drivers/st7789" "tinygo.org/x/drivers/st7789"
"tinygo.org/x/drivers/ws2812" "tinygo.org/x/drivers/ws2812"
"tinygo.org/x/tinyfont" "tinygo.org/x/tinyfont"
@ -39,24 +40,27 @@ var (
display st7789.Device display st7789.Device
leds ws2812.Device leds ws2812.Device
connected bool connected bool
connChanged bool
ledColor color.RGBA ledColor color.RGBA
colorChanged bool
) )
var ( var (
black = color.RGBA{0, 0, 0, 255} black = color.RGBA{0, 0, 0, 255}
cyan = color.RGBA{0, 200, 255, 255} cyan = color.RGBA{0, 200, 255, 255}
white = color.RGBA{255, 255, 255, 255} white = color.RGBA{255, 255, 255, 255}
gray = color.RGBA{150, 150, 150, 255}
) )
func main() { func main() {
time.Sleep(2 * time.Second)
initDisplay() initDisplay()
initLEDs() initLEDs()
drawStatus("Advertising...") drawStatus("Advertising...")
drawColor(color.RGBA{0, 0, 0, 255}) drawColor(color.RGBA{})
must("enable BLE", adapter.Enable()) must("enable BLE", adapter.Enable())
// AddService must be called before adv.Start() on nRF52840 SoftDevice.
must("add service", adapter.AddService(&bluetooth.Service{ must("add service", adapter.AddService(&bluetooth.Service{
UUID: ledServiceUUID, UUID: ledServiceUUID,
Characteristics: []bluetooth.CharacteristicConfig{ Characteristics: []bluetooth.CharacteristicConfig{
@ -67,29 +71,45 @@ func main() {
if len(value) < 3 { if len(value) < 3 {
return 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} ledColor = color.RGBA{value[0], value[1], value[2], 255}
setLEDs(ledColor) colorChanged = true
drawColor(ledColor)
if !connected {
connected = true
drawStatus("Connected ")
}
}, },
}, },
}, },
})) }))
// 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() adv := adapter.DefaultAdvertisement()
must("configure adv", adv.Configure(bluetooth.AdvertisementOptions{ must("configure adv", adv.Configure(bluetooth.AdvertisementOptions{
LocalName: "NiceBadge", LocalName: "NiceBadge",
ServiceUUIDs: []bluetooth.UUID{ledServiceUUID},
})) }))
must("start adv", adv.Start()) must("start adv", adv.Start())
// wait forever; all logic is driven by the BLE WriteEvent callback for {
select {} 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() { func initDisplay() {
@ -127,7 +147,6 @@ func drawStatus(status string) {
} }
func drawColor(c color.RGBA) { 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, 38, 240, 60, color.RGBA{c.R, c.G, c.B, 255})
display.FillRectangle(0, 100, 240, 35, black) display.FillRectangle(0, 100, 240, 35, black)
rgb := "R:" + strconv.Itoa(int(c.R)) + rgb := "R:" + strconv.Itoa(int(c.R)) +

View file

@ -1,8 +1,8 @@
package main package main
// BLE scanner example. // BLE scanner example.
// Scans for nearby BLE devices and shows their names, addresses and signal // Scans for nearby BLE devices and shows their names and signal strength
// strength (RSSI) on the display. Press button A to clear the list. // (RSSI) on the display. Press button A to clear the list.
import ( import (
"image/color" "image/color"
@ -10,17 +10,21 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/tinygo-org/bluetooth" "tinygo.org/x/bluetooth"
"tinygo.org/x/drivers/st7789" "tinygo.org/x/drivers/st7789"
"tinygo.org/x/tinyfont" "tinygo.org/x/tinyfont"
"tinygo.org/x/tinyfont/freesans" "tinygo.org/x/tinyfont/freesans"
) )
const maxDevices = 5 const maxDevices = 5
const maxNameLen = 16
const scanWindow = 500 * time.Millisecond
// Fixed-size byte arrays avoid dangling pointers into SoftDevice buffers.
type bleDevice struct { type bleDevice struct {
name string name [maxNameLen]byte
addr string nLen uint8
addr bluetooth.Address
rssi int16 rssi int16
} }
@ -41,53 +45,56 @@ var (
) )
func main() { func main() {
time.Sleep(2 * time.Second)
initDisplay() initDisplay()
btnA := machine.P1_06 btnA := machine.P1_06
btnA.Configure(machine.PinConfig{Mode: machine.PinInputPullup}) btnA.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
must("enable BLE", adapter.Enable()) must("enable BLE", adapter.Enable())
drawHeader() 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 { for {
// button A clears the list
if !btnA.Get() { if !btnA.Get() {
count = 0 count = 0
display.FillRectangle(0, 32, 240, 103, black)
time.Sleep(200 * time.Millisecond) 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() drawDevices()
time.Sleep(500 * time.Millisecond)
} }
} }
@ -111,11 +118,10 @@ func initDisplay() {
} }
func drawHeader() { func drawHeader() {
tinyfont.WriteLine(&display, &freesans.Bold12pt7b, 10, 24, "BLE Scanner", cyan) tinyfont.WriteLine(&display, &freesans.Regular9pt7b, 10, 24, "BLE Scanner", cyan)
} }
func drawDevices() { func drawDevices() {
// clear device list area
display.FillRectangle(0, 32, 240, 103, black) display.FillRectangle(0, 32, 240, 103, black)
if count == 0 { if count == 0 {
@ -125,13 +131,9 @@ func drawDevices() {
for i := 0; i < count; i++ { for i := 0; i < count; i++ {
y := int16(52 + i*20) y := int16(52 + i*20)
name := devices[i].name name := string(devices[i].name[:devices[i].nLen])
if len(name) > 16 {
name = name[:16]
}
rssi := " " + strconv.Itoa(int(devices[i].rssi)) + "dB" rssi := " " + strconv.Itoa(int(devices[i].rssi)) + "dB"
// color by signal strength
c := white c := white
if devices[i].rssi > -60 { if devices[i].rssi > -60 {
c = green c = green