nicebadge/TUTORIAL.md
2026-04-20 23:47:00 +02:00

721 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# NiceBadge Tutorial
Step-by-step guide to TinyGo programming on the NiceBadge.
---
## What you need
- A **NiceBadge** board (nice!nano + display + peripherals assembled)
- A USB-C cable
- [Go](https://go.dev/dl/) ≥ 1.22
- [TinyGo](https://tinygo.org/getting-started/) ≥ 0.32
---
## Setup
### Install dependencies
From inside the `tutorial/` directory, fetch all Go module dependencies once:
```sh
cd tutorial
go mod tidy
```
### Flash a program
Every example is flashed the same way. From inside `tutorial/`:
```sh
tinygo flash -target nicenano ./basics/step0
```
Replace `./basics/step0` with the path to any step you want to run.
### NiceBadge hardware map
| Peripheral | Pin(s) | Notes |
|---|---|---|
| Button A | P1_06 | Active LOW, internal pull-up |
| Button B | P1_04 | Active LOW, internal pull-up |
| Rotary push | P0_22 | Active LOW, internal pull-up |
| Rotary encoder | P1_00 (A), P0_24 (B) | Quadrature via interrupt |
| WS2812 LEDs (×2) | P1_11 | Data signal |
| Buzzer | P0_31 | Passive, toggled at audio frequency |
| Joystick X | P0_02 | ADC, 065535 |
| Joystick Y | P0_29 | ADC, 065535 |
| Display (SPI) | SCK=P1_01, SDO=P1_02 | ST7789, 240×135 px |
| Display control | RST=P1_15, DC=P1_13, CS=P0_10, BL=P0_09 | |
| I2C (StemmQT) | SDA=P0_17, SCL=P0_20 | I2C1, 3.3 V |
---
## Basics
---
### step0 — Blink the built-in LED
**Goal:** confirm the toolchain works and the badge can be flashed.
The nice!nano has a small LED soldered directly on the microcontroller board. Blinking it is the embedded equivalent of "Hello, World!".
```go
package main
import (
"machine"
"time"
)
func main() {
led := machine.LED
led.Configure(machine.PinConfig{Mode: machine.PinOutput})
for {
led.Low()
time.Sleep(time.Millisecond * 500)
led.High()
time.Sleep(time.Millisecond * 500)
}
}
```
```sh
tinygo flash -target nicenano ./basics/step0
```
The LED on the nice!nano blinks once per second.
**Key concepts**
- `machine.PinOutput` — configure a pin so your program can drive it HIGH or LOW.
- `led.High()` / `led.Low()` — set the pin voltage.
- `time.Sleep` — pause execution without busy-waiting.
---
### step1 — LED + button A
**Goal:** read a digital input and use it to control an output.
```go
package main
import (
"machine"
"time"
)
func main() {
led := machine.LED
led.Configure(machine.PinConfig{Mode: machine.PinOutput})
btnA := machine.P1_06
btnA.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
for {
if !btnA.Get() { // LOW = pressed (active LOW with pull-up)
led.High()
} else {
led.Low()
}
time.Sleep(time.Millisecond * 10)
}
}
```
```sh
tinygo flash -target nicenano ./basics/step1
```
Hold button A — the LED turns on. Release it — the LED turns off.
**Key concepts**
- `machine.PinInputPullup` — the pin floats HIGH internally; pressing the button connects it to GND → LOW.
- `!btnA.Get()` — because the logic is inverted (active LOW), we negate the reading.
**Challenge:** modify the code so that the LED turns on when button **B** is pressed instead.
---
### step2 — WS2812 RGB LEDs
**Goal:** drive the two addressable RGB LEDs.
WS2812 (SK6812MINI-E) LEDs are controlled with a single data wire using a timed pulse protocol. The `ws2812` driver handles the timing; you just provide colors.
```go
package main
import (
"image/color"
"machine"
"time"
"tinygo.org/x/drivers/ws2812"
)
func main() {
neo := machine.P1_11
neo.Configure(machine.PinConfig{Mode: machine.PinOutput})
leds := ws2812.New(neo)
ledColors := make([]color.RGBA, 2)
red := color.RGBA{255, 0, 0, 255}
green := color.RGBA{0, 255, 0, 255}
rg := false
for {
for i := 0; i < 2; i++ {
if rg {
ledColors[i] = red
} else {
ledColors[i] = green
}
rg = !rg
}
leds.WriteColors(ledColors)
rg = !rg
time.Sleep(time.Millisecond * 300)
}
}
```
```sh
tinygo flash -target nicenano ./basics/step2
```
The two LEDs alternate between red and green every 300 ms.
**Key concepts**
- `color.RGBA{R, G, B, A}` — standard Go color type; A (alpha) is always 255 for LEDs.
- `leds.WriteColors(slice)` — pushes the entire color slice to the strip in one call.
**Challenge:** add a third color (blue) and cycle through all three.
---
### step3 — WS2812 LEDs + buttons
**Goal:** combine inputs and outputs — each button sets a different LED color.
```sh
tinygo flash -target nicenano ./basics/step3
```
- Press **A** → both LEDs turn red.
- Press **B** → both LEDs turn blue.
- Press the **rotary button** → both LEDs turn green.
- Release all → LEDs stay on the last color.
**Key concepts**
- Multiple inputs read in the same loop.
- State is kept across loop iterations with the `c` variable.
---
### step3b — Rainbow LEDs
**Goal:** generate smooth color transitions (hue wheel) and let buttons scroll through them.
The `getRainbowRGB` function maps a `uint8` (0255) to a point on the RGB color wheel, dividing it into three 85-step segments: red→green, green→blue, blue→red.
```sh
tinygo flash -target nicenano ./basics/step3b
```
- Hold **A** → hue advances (warm colors).
- Hold **B** → hue recedes (cool colors).
- The two LEDs are always offset by 10 hue steps apart.
**Challenge:** increase the offset between the two LEDs to 128 (opposite colors on the wheel).
---
### step4 — Display: text
**Goal:** initialize the ST7789 display and render text.
The display talks over SPI and needs explicit pin configuration on the nice!nano. After configuration, the drawable area is **240 × 135 pixels** in landscape orientation.
```go
package main
import (
"image/color"
"machine"
"tinygo.org/x/drivers/st7789"
"tinygo.org/x/tinyfont"
"tinygo.org/x/tinyfont/freesans"
)
func main() {
machine.SPI0.Configure(machine.SPIConfig{
SCK: machine.P1_01,
SDO: machine.P1_02,
Frequency: 8000000,
Mode: 0,
})
display := st7789.New(machine.SPI0,
machine.P1_15, // RST
machine.P1_13, // DC
machine.P0_10, // CS
machine.P0_09) // backlight
display.Configure(st7789.Config{
Rotation: st7789.ROTATION_90,
Width: 135,
Height: 240,
RowOffset: 40,
ColumnOffset: 53,
})
display.FillScreen(color.RGBA{0, 0, 0, 255})
tinyfont.WriteLine(&display, &freesans.Bold12pt7b, 10, 50,
"Hello", color.RGBA{255, 255, 0, 255})
tinyfont.WriteLine(&display, &freesans.Bold12pt7b, 10, 90,
"Gophers!", color.RGBA{255, 0, 255, 255})
}
```
```sh
tinygo flash -target nicenano ./basics/step4
```
"Hello" and "Gophers!" appear on the display in yellow and magenta.
**Key concepts**
- `RowOffset` / `ColumnOffset` — the ST7789 physical memory does not always start at (0,0); these offsets align the driver to the actual pixel grid of this display module.
- `tinyfont.WriteLine(&display, &font, x, y, text, color)``x`, `y` are the **baseline** position of the text (not the top-left corner).
**Challenge:** change the font to `freesans.Regular9pt7b` and add a third line.
---
### step5 — Display + buttons
**Goal:** update the display in real time based on button state.
Three filled circles represent the three buttons. When a button is pressed a ring appears around its circle.
```sh
tinygo flash -target nicenano ./basics/step5
```
- Press **B** (left circle), **rotary** (center), or **A** (right circle) to see the corresponding ring.
**Key concepts**
- `tinydraw.FilledCircle` / `tinydraw.Circle` — drawing primitives from `tinygo.org/x/tinydraw`.
- Re-drawing a shape in the background color erases it — no need to clear the whole screen.
---
### step6 — Analog joystick
**Goal:** read the joystick's two analog axes and visualize the position on the display.
The joystick outputs a voltage proportional to its position on each axis. The ADC converts that voltage to a 16-bit integer (065535). The center resting position is approximately 32767.
```go
machine.InitADC()
ax := machine.ADC{Pin: machine.P0_02} // X axis
ay := machine.ADC{Pin: machine.P0_29} // Y axis
ax.Configure(machine.ADCConfig{})
ay.Configure(machine.ADCConfig{})
// map 0-65535 → display width/height
dotX := int16(uint32(ax.Get()) * 240 / 65535)
dotY := int16(uint32(ay.Get()) * 135 / 65535)
```
```sh
tinygo flash -target nicenano ./basics/step6
```
A green dot follows the joystick across the display.
**Key concepts**
- `machine.InitADC()` — must be called once before using any ADC pin.
- The cast chain `uint32(ax.Get()) * 240 / 65535` avoids integer overflow during the mapping (a plain `int16` multiplication would overflow).
---
### step7 — Rotary encoder
**Goal:** use the quadrature encoder to cycle through hue values on the LEDs.
A rotary encoder outputs two square waves (A and B) 90° out of phase. The `encoders` driver decodes the direction and accumulates a signed position value using interrupts.
```go
enc := encoders.NewQuadratureViaInterrupt(machine.P1_00, machine.P0_24)
enc.Configure(encoders.QuadratureConfig{Precision: 4})
// in the loop:
k = uint8(enc.Position())
```
```sh
tinygo flash -target nicenano ./basics/step7
```
- Turn the encoder knob → LED hue shifts smoothly.
- Press the encoder knob → resets position to zero (both LEDs snap back to the same starting hue).
**Key concepts**
- `Precision: 4` — number of encoder pulses per detent; adjust if your encoder feels too coarse or too fine.
- `enc.Position()` returns a signed `int32`; casting to `uint8` wraps around naturally (256 → 0), which is exactly what we want for the hue wheel.
---
### step8 — Buzzer
**Goal:** generate audio tones by toggling the buzzer pin at audio frequencies.
The buzzer is passive: it only makes sound when driven with an alternating signal. We create a tone by toggling the pin HIGH/LOW at the target frequency.
```go
// tone at 1046 Hz ≈ C6
func tone(freq int) {
for i := 0; i < 10; i++ {
bzrPin.High()
time.Sleep(time.Duration(freq) * time.Microsecond)
bzrPin.Low()
time.Sleep(time.Duration(freq) * time.Microsecond)
}
}
```
The half-period in microseconds equals `1_000_000 / (2 * freq_Hz)`, but here `freq` is passed directly as the half-period value in microseconds, so `freq=1046` means a half-period of 1046 µs ≈ 478 Hz. Use this table to pick a value:
| Note | Approx. half-period (µs) |
|------|--------------------------|
| C5 | 523 |
| F#5 | 739 |
| C6 | 1046 |
```sh
tinygo flash -target nicenano ./basics/step8
```
Each button plays a different note while held.
**Challenge:** compose a short melody by chaining `tone()` calls with `time.Sleep` pauses between them.
---
### step9 — Serial monitor
**Goal:** use `fmt.Println` and `fmt.Printf` to send human-readable events from the badge to your computer over USB serial.
The `-monitor` flag tells TinyGo to open the serial port immediately after flashing, so you see the output without extra steps.
```go
// button press (falling-edge detection)
a := btnA.Get()
b := btnB.Get()
c := btnC.Get() // rotary encoder push button
if !a && prevA {
println("button A pressed")
}
if !b && prevB {
println("button B pressed")
}
if !c && prevC {
println("encoder button pressed")
}
// rotary encoder — print on every position change
pos := enc.Position()
if pos != prevPos {
println("encoder:", pos)
prevPos = pos
}
// joystick — print while outside the dead zone
rawX := int(ax.Get()) - 32767
rawY := int(ay.Get()) - 32767
if rawX > deadzone || rawX < -deadzone || rawY > deadzone || rawY < -deadzone {
println("joystick:", "x=", rawX, "y=", rawY)
}
```
```sh
tinygo flash -target nicenano -monitor ./basics/step9
```
Move the joystick, turn the encoder knob, or press A, B, or the encoder button. Each event prints to your terminal in real time.
**Key concepts**
- `println` is a TinyGo built-in that writes directly to the USB serial port with no imports needed — prefer it over `fmt.Print*` in embedded code.
- `-monitor` keeps the serial connection open after flashing — equivalent to running `tinygo monitor` right after.
- Falling-edge detection (`!a && prevA`) prints once per press instead of flooding the terminal while the button is held.
- A dead zone (`const deadzone = 5000`) suppresses joystick noise around the center resting position.
---
### step10 — USB MIDI
**Goal:** make the badge appear as a MIDI instrument over USB.
When flashed with this program the badge enumerates as a standard USB MIDI device. Any app or DAW that supports USB MIDI will detect it automatically.
```go
notes := []midi.Note{midi.C4, midi.E4, midi.G4}
midichannel := uint8(1)
// on button press:
midi.Midi.NoteOn(0, midichannel, notes[note], 50)
// on button release:
midi.Midi.NoteOff(0, midichannel, notes[oldNote], 50)
```
```sh
tinygo flash -target nicenano ./basics/step10
```
Open any online MIDI player (e.g. [muted.io/piano](https://muted.io/piano/)) or connect to a DAW. The three buttons play C4, E4, and G4 (a C major triad).
**Key concepts**
- MIDI NoteOn/NoteOff must be paired: always send NoteOff for the previous note before sending a new NoteOn, otherwise notes get stuck.
- Velocity (last parameter, `50`) controls how hard the note is "hit" (0127).
**Challenge:** map the rotary encoder position to an octave shift (transpose all notes up or down by 12 semitones per detent).
---
### step11 — USB HID mouse
**Goal:** use the joystick as a mouse pointer and buttons as mouse clicks.
The ADC center resting position (~32767) produces a raw offset of 0. A dead zone filters out the natural jitter around center so the cursor stays still when you're not touching the stick.
```go
const DEADZONE = 5000
rawX := int(ax.Get()) - 32767
var dx int
if rawX > DEADZONE || rawX < -DEADZONE {
dx = rawX / 2048
}
mouseDevice.Move(dx, dy)
```
```sh
tinygo flash -target nicenano ./basics/step11
```
Connect the badge to a computer. The joystick moves the mouse cursor; button A is left click, button B is right click.
**Key concepts**
- The dead zone prevents cursor drift when the joystick is at rest.
- `rawX / 2048` scales down the ±32767 range to ±16, giving a comfortable cursor speed. Decrease the divisor for faster movement.
---
## BLE
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**
| App | Platform | Best for |
|-----|----------|----------|
| nRF Connect | iOS / Android | Inspecting services, reading/writing characteristics |
| nRF Toolbox | iOS / Android | Nordic UART Service (NUS) terminal |
| Serial Bluetooth Terminal | Android | NUS text terminal |
| LightBlue | iOS / Android | Browsing and writing custom characteristics |
---
### BLE concepts
Before diving in, a few terms:
- **Peripheral** — the badge; it advertises its presence and waits for connections.
- **Central** — the mobile phone or computer that initiates the connection.
- **Service** — a logical grouping of related data (identified by a UUID).
- **Characteristic** — a single data value within a service. Can be readable, writable, and/or notify-able.
- **Notification** — the peripheral pushes a new value to the central without the central polling.
- **UUID** — 128-bit identifier for services and characteristics. Custom UUIDs are usually 128-bit; standard Bluetooth ones are 16-bit.
---
### BLE step1 — Counter with display
**Goal:** advertise a BLE service, send periodic notifications, and display connection status.
This example implements the **Nordic UART Service (NUS)** — a de-facto standard for sending text over BLE, supported by many apps out of the box.
**Service:** `6E400001-B5A3-F393-E0A9-E50E24DCCA9E`
| Characteristic | UUID | Properties | Role |
|---|---|---|---|
| RX | `6E400002-…` | Write, WriteWithoutResponse | Central → Badge |
| TX | `6E400003-…` | Notify, Read | Badge → Central |
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
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 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` 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.
---
### BLE step2 — LED color control
**Goal:** receive data from a mobile app and use it to set the LED color.
A custom service exposes a single writable characteristic. The central writes 3 bytes `[R, G, B]`; the badge lights both WS2812 LEDs immediately and shows the color + RGB values on the display.
**Service:** `BADA5501-B5A3-F393-E0A9-E50E24DCCA9E`
| Characteristic | UUID | Properties | Role |
|---|---|---|---|
| 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 {
return
}
ledColor = color.RGBA{value[0], value[1], value[2], 255}
setLEDs(ledColor)
drawColor(ledColor)
},
```
```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
```
**How to test**
1. Flash and open **nRF Connect** (or LightBlue).
2. Connect to `NiceBadge` and expand the custom service (`BADA5501…`).
3. Write to the color characteristic. In nRF Connect, enter raw bytes in hex: `FF0000` = red, `00FF00` = green, `0000FF` = blue, `FF0080` = pink.
4. The LEDs and display update instantly.
**Key concepts**
- Custom 128-bit UUIDs let you define entirely private services not shared with any standard profile.
- `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.
---
### BLE step3 — Scanner
**Goal:** put the radio in observer mode and display nearby BLE devices.
`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
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 |
|------|-------|---------|
| > 60 dBm | green | Strong (< ~3 m) |
| 60 to 80 dBm | yellow | Medium |
| < 80 dBm | white | Weak |
```sh
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**
- `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()`).
---
## Next steps
- **Snake game tutorial** — coming soon.
- **Examples** (thermal camera, CO2 sensor, rubber duck) — see [`tutorial/examples/`](examples/).
- Combine what you learned: use the rotary encoder to scroll through a BLE device list, or send joystick data over NUS to a web app.