Step-by-step Tutorial
Step-by-step guide to TinyGo programming on the NiceBadge.
What you need
Setup
Install dependencies
From inside the tutorial/ directory, fetch all Go module dependencies once:
cd tutorial
go mod tidy
Flash a program
Every example is flashed the same way. From inside tutorial/:
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, 0–65535 |
| Joystick Y | P0_29 | ADC, 0–65535 |
| 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!”.
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)
}
}
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.
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)
}
}
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.
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)
}
}
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.
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
cvariable.
step3b — Rainbow LEDs
Goal: generate smooth color transitions (hue wheel) and let buttons scroll through them.
The getRainbowRGB function maps a uint8 (0–255) to a point on the RGB color wheel, dividing it into three 85-step segments: red→green, green→blue, blue→red.
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.
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})
}
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,yare 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.
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 fromtinygo.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 (0–65535). The center resting position is approximately 32767.
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)
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 / 65535avoids integer overflow during the mapping (a plainint16multiplication 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.
enc := encoders.NewQuadratureViaInterrupt(machine.P1_00, machine.P0_24)
enc.Configure(encoders.QuadratureConfig{Precision: 4})
// in the loop:
k = uint8(enc.Position())
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 signedint32; casting touint8wraps 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.
// 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 |
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.
// 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)
}
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
printlnis a TinyGo built-in that writes directly to the USB serial port with no imports needed — prefer it overfmt.Print*in embedded code.-monitorkeeps the serial connection open after flashing — equivalent to runningtinygo monitorright 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.
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)
tinygo flash -target nicenano ./basics/step10
Open any online MIDI player (e.g. 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” (0–127).
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.
const DEADZONE = 5000
rawX := int(ax.Get()) - 32767
var dx int
if rawX > DEADZONE || rawX < -DEADZONE {
dx = rawX / 2048
}
mouseDevice.Move(dx, dy)
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 / 2048scales 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 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.
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.
tinygo flash -target nicenano ./ble/step1
How to test
- Flash the badge. The display shows
BLE: Advertising.... - Open nRF Connect → SCANNER → search for
NiceBadge. - Once connected the display shows
BLE: Connectedand the counter appears in the terminal. - Type
resetand 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.AddServicemust be called beforeadv.Start()on nRF52840 — the GATT table is frozen once advertising starts.adapter.SetConnectHandler— the correct way to track connection state;txChar.Write()always returnsnilon 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.
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)
},
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)
}
tinygo flash -target nicenano ./ble/step2
How to test
- Flash and open nRF Connect (or LightBlue).
- Connect to
NiceBadgeand expand the custom service (BADA5501…). - Write to the color characteristic. In nRF Connect, enter raw bytes in hex:
FF0000= red,00FF00= green,0000FF= blue,FF0080= pink. - The LEDs and display update instantly.
Key concepts
- Custom 128-bit UUIDs let you define entirely private services not shared with any standard profile.
WriteEventcan call SPI and GPIO safely — it only avoids re-entering the SoftDevice (e.g.adv.Start()).- Always validate
len(value)inWriteEvent— 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:
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 |
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.Scanruns its own internalsd_app_evt_waitloop — it never yields to TinyGo’s cooperative scheduler. A goroutine that callsStopScan()after atime.Sleepwill never execute whileScanis running.- On nRF52840, never run SPI and
adapter.Scanat 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]bytearrays andcopy()in callbacks; convert tostringonly 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/. - 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.