tested and updated BLE tutorial
This commit is contained in:
parent
7d8399152c
commit
043b884263
4 changed files with 188 additions and 106 deletions
94
TUTORIAL.md
94
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()
|
||||
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
|
||||
})
|
||||
}()
|
||||
// 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()`).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -38,6 +38,7 @@ var (
|
|||
var (
|
||||
display st7789.Device
|
||||
connected bool
|
||||
connChanged bool
|
||||
counter int
|
||||
)
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -39,24 +40,27 @@ var (
|
|||
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},
|
||||
}))
|
||||
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)) +
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue