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
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()`).
---

View file

@ -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)
}
}

View file

@ -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)) +

View file

@ -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