Step-by-step Tutorial

Step-by-step Tutorial

Visit the official repository to download the example code and get the latest version.

Step-by-step guide to TinyGo programming on nicectrlr.

If you’ve been through the NiceBadge tutorial, most of this will feel very familiar — same nice!nano brain, same display, same TinyGo APIs. The differences all come from the hardware: nicectrlr trades NiceBadge’s rotary encoder and single joystick for 6 buttons and two analog sticks read over I2C instead of local ADC pins.


What you need

  • A nicectrlr board (nice!nano + display + joysticks + buttons + LEDs assembled)
  • A USB-C cable
  • Go ≥ 1.22
  • TinyGo ≥ 0.32

Setup

Install dependencies

From inside the tutorial/ directory, fetch all Go module dependencies once:

cd tutorial
go mod tidy

Do the same inside examples/ if you’ll be building those too.

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.

nicectrlr hardware map

PeripheralPin(s)Notes
Button AP1_06Active LOW, internal pull-up
Button BP1_04Active LOW, internal pull-up
Button XP0_11Active LOW, internal pull-up
Button YP1_00Active LOW, internal pull-up
Button LP0_29Active LOW, internal pull-up
Button RP0_02Active LOW, internal pull-up
WS2812 LEDs (×4)P1_11Data signal — same pin as NiceBadge
BuzzerP0_31Passive, toggled at audio frequency
I2C1 (joysticks + StemmQT)SDA=P0_17, SCL=P0_20Shared bus for the onboard ADS1015 and external StemmQT sensors
ADS1015 ADDRP0_22Output pin, drive LOW to select I2C address 0x48
ADS1015 ALRT/RDYP0_24Optional conversion-ready interrupt (unused by these tutorials)
Display (SPI)SCK=P1_01, SDO=P1_02ST7789, 240×135 px — identical wiring to NiceBadge
Display controlRST=P1_15, DC=P1_13, CS=P0_10, BL=P0_09Same pin mapping as NiceBadge

Basics


Goal: confirm the toolchain works and the board 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.

btnA := machine.P1_06
btnA.Configure(machine.PinConfig{Mode: machine.PinInputPullup})

if !btnA.Get() { // LOW = pressed (active LOW with pull-up)
    led.High()
} else {
    led.Low()
}
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 (P1_04) is pressed instead.


step2 — WS2812 RGB LEDs

Goal: drive the 4 addressable RGB LEDs.

WS2812 (SK6812MINI-E) LEDs are controlled with a single data wire using a timed pulse protocol — P1_11, the same pin NiceBadge uses for its 2 LEDs. The ws2812 driver handles the pulse timing; you just provide colors.

neo := machine.P1_11
neo.Configure(machine.PinConfig{Mode: machine.PinOutput})

leds := ws2812.New(neo)
ledColors := make([]color.RGBA, 4)

red := color.RGBA{255, 0, 0, 255}
green := color.RGBA{0, 255, 0, 255}
tinygo flash -target nicenano ./basics/step2

The four 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 + all 6 buttons

Goal: combine inputs and outputs — each of the 6 face buttons sets a different LED color.

tinygo flash -target nicenano ./basics/step3
  • Press A → red. B → blue. X → green. Y → yellow. L → cyan. R → purple.
  • Release all → LEDs stay on the last color.

Key concepts

  • Multiple inputs read in the same loop — with 6 buttons instead of NiceBadge’s 3, this step is a good place to get comfortable reading several digital pins per iteration.
  • 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 (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 L → hue advances (warm colors).
  • Hold R → hue recedes (cool colors).
  • The four LEDs are staggered 20 hue steps apart from each other.

Challenge: try mapping a joystick axis from step6 to the stagger amount once you’ve done that step.


step4 — Display: text

Goal: initialize the ST7789 display and render text.

nicectrlr’s display uses the exact same SPI wiring as NiceBadge. 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, 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.

Six filled circles represent the six buttons, laid out in two rows of three. When a button is pressed, a ring appears around its circle.

tinygo flash -target nicenano ./basics/step5
  • Top row: Y, X, B. Bottom row: A, L, R. Press any to see its ring appear.

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.
  • A []buttonDot{pin, x, y} slice plus a loop keeps 6 buttons from turning into 6 copy-pasted if blocks.

step6 — Dual analog joysticks (ADS1015)

Goal: read both analog sticks through the onboard I2C ADC and visualize their position on the display.

This is the step where nicectrlr diverges most from NiceBadge. NiceBadge’s joystick wires straight into two of the nice!nano’s own ADC pins; nicectrlr’s two sticks (4 analog axes total) go through an ADS1015, a 4-channel 12-bit ADC reached over I2C. That frees up two GPIO pins for buttons L/R instead, at the cost of one extra step: initializing the I2C bus and the ADS1015 driver before you can read anything.

machine.I2C1.Configure(machine.I2CConfig{
    SDA:       machine.P0_17,
    SCL:       machine.P0_20,
    Frequency: 400 * machine.KHz,
})

// ADS1015 ADDR pin: tie it LOW so the chip answers at its default
// I2C address (ads1015.Address, 0x48).
addr := machine.P0_22
addr.Configure(machine.PinConfig{Mode: machine.PinOutput})
addr.Low()

adc := ads1015.New(machine.I2C1)
config := ads1015.DefaultConfig
config.Gain = ads1015.Gain4096mV // matches the 0-3.3V joystick output range
adc.Configure(config)

// later, in the loop:
lx, _ := adc.ReadADC(0) // left stick X
ly, _ := adc.ReadADC(1) // left stick Y
rx, _ := adc.ReadADC(2) // right stick X
ry, _ := adc.ReadADC(3) // right stick Y
tinygo flash -target nicenano ./basics/step6

A green dot follows the left stick, a cyan dot follows the right stick — both on the same 240×135 display, mapping the ADC’s 0–2047 single-ended range straight onto screen coordinates.

Key concepts

  • ads1015.New(bus) / Configure — same pattern as other TinyGo I2C drivers: pass the bus, then a Config struct.
  • ReadADC(channel uint8) performs one conversion and returns a signed 12-bit result (0–2047 for a single-ended read); it blocks until the conversion completes, so no interrupt wiring is needed for basic polling.
  • Gain4096mV sets the ADS1015’s full-scale input range to ±4.096V, comfortably covering the joystick’s 0–3.3V swing, but the max value of the sticks should eb around 1640-1650.

Challenge: add a dead zone around the center so the dots don’t jitter when the sticks are resting.


step7 — Buzzer

Goal: generate audio tones by toggling the buzzer pin at audio frequencies, one note per button.

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.

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. With 6 buttons instead of NiceBadge’s 3, this step plays a full 6-note scale:

ButtonNoteHalf-period (µs)
YC41911
XD41703
BE41517
AF41432
LG41276
RA41136
tinygo flash -target nicenano ./basics/step7

Each button plays a different note while held.

Challenge: compose a short melody by chaining tone() calls with time.Sleep pauses between them.


step8 — Serial monitor

Goal: use println to send human-readable events from the board 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()
if !a && prevA {
    println("button A pressed")
}

// joystick — print while outside the dead zone
lx, _ := adc.ReadADC(0)
ly, _ := adc.ReadADC(1)
if outside(lx) || outside(ly) {
    println("left stick:", "x=", int(lx)-center, "y=", int(ly)-center)
}
tinygo flash -target nicenano -monitor ./basics/step8

Move either joystick or press any of the 6 buttons. 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 = 150) suppresses joystick noise around the center resting position.

step9 — USB MIDI

Goal: make the board appear as a MIDI instrument over USB.

When flashed with this program the board 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.D4, midi.E4, midi.F4, midi.G4, midi.A4}
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/step9

Open any online MIDI player (e.g. muted.io/piano) or connect to a DAW. Y, X, B, A, L, R play a C major scale from C4 to A4 — six buttons, six adjacent notes, instead of NiceBadge’s three-button 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: use one joystick axis (see step6) to add pitch bend or velocity control while a note is held.


step10 — USB HID mouse

Goal: use the left joystick as a mouse pointer, the right joystick as a scroll wheel, and buttons as mouse clicks.

const DEADZONE = 150
const center = 1024

lx, _ := adc.ReadADC(0)
d := int(lx) - center
var dx int
if d > DEADZONE || d < -DEADZONE {
    dx = d / 24
}
mouseDevice.Move(dx, dy)
tinygo flash -target nicenano ./basics/step10

Connect the board to a computer. The left stick moves the cursor; the right stick’s Y axis scrolls; button A is left click, button B is right click.

Key concepts

  • Having a second stick free is one of the perks of nicectrlr’s dual-stick layout — NiceBadge’s single joystick has to choose between movement and scrolling, but nicectrlr doesn’t.
  • The dead zone prevents cursor drift when a joystick is at rest.
  • d / 24 scales the ADC range down to a comfortable cursor speed; d / 96 (used for the scroll wheel) moves the wheel more slowly. Adjust the divisors to taste.

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

AppPlatformBest for
nRF ConnectiOS / AndroidInspecting services, reading/writing characteristics
nRF ToolboxiOS / AndroidNordic UART Service (NUS) terminal
Serial Bluetooth TerminalAndroidNUS text terminal
LightBlueiOS / AndroidBrowsing and writing custom characteristics

BLE concepts

Before diving in, a few terms:

  • Peripheral — the board; 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

CharacteristicUUIDPropertiesRole
RX6E400002-…Write, WriteWithoutResponseCentral → Board
TX6E400003-…Notify, ReadBoard → 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

  1. Flash the board. The display shows BLE: Advertising....
  2. Open nRF Connect → SCANNER → search for nicectrlr.
  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 board lights all 4 WS2812 LEDs immediately and shows the color + RGB values on the display.

Service: BADA5501-B5A3-F393-E0A9-E50E24DCCA9E

CharacteristicUUIDPropertiesRole
LED ColorBADA5502-…Write, WriteWithoutResponseCentral → Board

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)
},
tinygo flash -target nicenano ./ble/step2

How to test

  1. Flash and open nRF Connect (or LightBlue).
  2. Connect to nicectrlr 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:

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:

RSSIColorMeaning
> −60 dBmgreenStrong (< ~3 m)
−60 to −80 dBmyellowMedium
< −80 dBmwhiteWeak
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

  • Examples (thermal camera, CO2 sensor, rubber duck) — see examples/.
  • Combine what you learned: show both joysticks’ positions on the display at once, or send joystick data over NUS to a web app.
docs