NiceBadge Tutorial

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 ≥ 1.22
  • TinyGo ≥ 0.32

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

PeripheralPin(s)Notes
Button AP1_06Active LOW, internal pull-up
Button BP1_04Active LOW, internal pull-up
Rotary pushP0_22Active LOW, internal pull-up
Rotary encoderP1_00 (A), P0_24 (B)Quadrature via interrupt
WS2812 LEDs (×2)P1_11Data signal
BuzzerP0_31Passive, toggled at audio frequency
Joystick XP0_02ADC, 0–65535
Joystick YP0_29ADC, 0–65535
Display (SPI)SCK=P1_01, SDO=P1_02ST7789, 240×135 px
Display controlRST=P1_15, DC=P1_13, CS=P0_10, BL=P0_09
I2C (StemmQT)SDA=P0_17, SCL=P0_20I2C1, 3.3 V

Basics


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 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 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, 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.

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 (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 / 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.

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

// 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:

NoteApprox. half-period (µs)
C5523
F#5739
C61046
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

  • 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.

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 / 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 github.com/tinygo-org/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 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

CharacteristicUUIDPropertiesRole
RX6E400002-…Write, WriteWithoutResponseCentral → Badge
TX6E400003-…Notify, ReadBadge → 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.

_, err := txChar.Write([]byte(strconv.Itoa(counter) + "\n"))
connected = err == nil
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.
  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.

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

CharacteristicUUIDPropertiesRole
LED ColorBADA5502-…Write, WriteWithoutResponseCentral → Badge
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)
},

After all services are registered the main goroutine simply blocks with select {} — all activity is driven by the BLE callback.

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.
  • select {} is idiomatic Go for blocking forever; it is more explicit than for {} with a sleep.
  • 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.

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.

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

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

  • 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.

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.
docs