diff --git a/TUTORIAL.md b/TUTORIAL.md new file mode 100644 index 0000000..4880088 --- /dev/null +++ b/TUTORIAL.md @@ -0,0 +1,619 @@ +# 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, 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!". + +```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` (0–255) 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 (0–65535). 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 — 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/step9 +``` + +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" (0–127). + +**Challenge:** map the rotary encoder position to an octave shift (transpose all notes up or down by 12 semitones per detent). + +--- + +### step10 — 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/step10 +``` + +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`](https://github.com/tinygo-org/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 | + +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. + +```go +_, err := txChar.Write([]byte(strconv.Itoa(counter) + "\n")) +connected = err == nil +``` + +```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`. +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` +| Characteristic | UUID | Properties | Role | +|---|---|---|---| +| LED Color | `BADA5502-…` | Write, WriteWithoutResponse | Central → Badge | + +```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) +}, +``` + +After all services are registered the main goroutine simply blocks with `select {}` — all activity is driven by the BLE callback. + +```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. +- `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 +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: + +| 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** +- `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/`](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.