diff --git a/examples/unoqmatrix/main.go b/examples/unoqmatrix/main.go new file mode 100644 index 0000000..89695f8 --- /dev/null +++ b/examples/unoqmatrix/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "machine" + + "image/color" + "math/rand" + + "tinygo.org/x/drivers/unoqmatrix" +) + +var on = color.RGBA{255, 255, 255, 255} + +func main() { + display := unoqmatrix.NewFromBasePin(machine.PF0) + display.ClearDisplay() + + w, h := display.Size() + x := int16(0) + y := int16(0) + deltaX := int16(1) + deltaY := int16(1) + + for { + pixel := display.GetPixel(x, y) + if pixel.R != 0 || pixel.G != 0 || pixel.B != 0 { + display.ClearDisplay() + x = 1 + int16(rand.Int31n(3)) + y = 1 + int16(rand.Int31n(3)) + deltaX = 1 + deltaY = 1 + if rand.Int31n(2) == 0 { + deltaX = -1 + } + if rand.Int31n(2) == 0 { + deltaY = -1 + } + } + display.SetPixel(x, y, on) + + x += deltaX + y += deltaY + + if x == 0 || x == w-1 { + deltaX = -deltaX + } + + if y == 0 || y == h-1 { + deltaY = -deltaY + } + + display.Display() + } +} diff --git a/unoqmatrix/matrix.go b/unoqmatrix/matrix.go new file mode 100644 index 0000000..7430c08 --- /dev/null +++ b/unoqmatrix/matrix.go @@ -0,0 +1,290 @@ +// Package unoqmatrix provides a driver for the UnoQMatrix LED matrix display. +// +// The UnoQMatrix is an 8x13 LED matrix display that can be controlled using a single pin. +// It uses a multiplexing technique to control the LEDs, which allows for a large number of LEDs to be controlled with fewer pins. +// +// This driver provides basic functionality to set individual pixels, clear the display, and refresh the display. +// +// Note: The UnoQMatrix does not support brightness control or color depth. Each pixel can only be turned on or off. +// Could it suppport brightness control by using PWM on the pin? To be investigated. +package unoqmatrix + +import ( + "image/color" + "time" + + pin "tinygo.org/x/drivers/internal/pin" +) + +type Config struct { + // Rotation of the LED matrix. + Rotation uint8 +} + +// Valid values: +// +// 0: regular orientation, (0 degree rotation) +// 1: 90 degree rotation clockwise +// 2: 180 degree rotation clockwise +// 3: 270 degree rotation clockwise +const ( + RotationNormal = 0 + Rotation90 = 1 + Rotation180 = 2 + Rotation270 = 3 +) + +const ( + ledRows = 8 + ledCols = 13 + + pixelRefreshDelay = 10 * time.Microsecond +) + +// CharlieplexPin represents a pin used for charlieplexing. +// It must be able to drive high/low (output mode) and float (high-impedance/input mode). +// +// Example construction from a machine.Pin using the pin HAL pattern: +// +// var isOutput bool +// cp := unoqmatrix.CharlieplexPin{ +// Set: pin.OutputFunc(func(level bool) { +// if !isOutput { +// p.Configure(machine.PinConfig{Mode: machine.PinOutput}) +// isOutput = true +// } +// p.Set(level) +// }), +// Float: func() { +// if isOutput { +// p.Configure(machine.PinConfig{Mode: machine.PinInput}) +// isOutput = false +// } +// }, +// } +type CharlieplexPin struct { + Set pin.OutputFunc // Drive pin high (true) or low (false); auto-configures to output mode. + Float func() // Put pin into high-impedance (input) mode. +} + +const numPins = 11 + +// Device represents the UnoQMatrix LED matrix display. +type Device struct { + pins [numPins]CharlieplexPin + buffer [ledRows][ledCols]color.RGBA + rotation uint8 +} + +// New returns a new unoqmatrix driver. +// The provided pins are the 11 charlieplex pins used to control the LED matrix. +func New(pins [numPins]CharlieplexPin) Device { + return Device{pins: pins} +} + +// Configure sets up the device. +func (d *Device) Configure(cfg Config) { + d.SetRotation(cfg.Rotation) +} + +// SetRotation changes the rotation of the LED matrix. +// +// Valid values for rotation: +// +// 0: regular orientation, (0 degree rotation) +// 1: 90 degree rotation clockwise +// 2: 180 degree rotation clockwise +// 3: 270 degree rotation clockwise +func (d *Device) SetRotation(rotation uint8) { + d.rotation = rotation % 4 +} + +// SetPixel sets the color of a specific pixel. +func (d *Device) SetPixel(x int16, y int16, c color.RGBA) { + d.buffer[y][x] = c +} + +// GetPixel returns the color of a specific pixel. +func (d *Device) GetPixel(x int16, y int16) color.RGBA { + return d.buffer[y][x] +} + +// Display sends the buffer (if any) to the screen. +// Only lights active (non-black) pixels, and resets only the 2 previously +// driven pins between LEDs instead of all 11, making each refresh cycle +// proportional to the number of lit LEDs. +func (d *Device) Display() error { + d.clearDisplay() + + var lastIdx0, lastIdx1 uint8 + hasLast := false + + for row := 0; row < ledRows; row++ { + for col := 0; col < ledCols; col++ { + c := d.buffer[row][col] + if c.R == 0 && c.G == 0 && c.B == 0 { + continue + } + + idx := row*ledCols + col + if idx < 0 || idx >= len(pinMapping) { + continue + } + + // Float only the two pins that were driving the previous LED. + if hasLast { + d.pins[lastIdx0].Float() + d.pins[lastIdx1].Float() + } + hasLast = true + + idx0 := pinMapping[idx][0] + idx1 := pinMapping[idx][1] + d.pins[idx0].Set.High() + d.pins[idx1].Set.Low() + lastIdx0 = idx0 + lastIdx1 = idx1 + + time.Sleep(pixelRefreshDelay) + } + } + + // Float the last driven LED. + if hasLast { + d.pins[lastIdx0].Float() + d.pins[lastIdx1].Float() + } + + return nil +} + +// ClearDisplay turns off all the LEDs on the display. +func (d *Device) ClearDisplay() { + for row := 0; row < ledRows; row++ { + for col := 0; col < ledCols; col++ { + d.buffer[row][col] = color.RGBA{0, 0, 0, 255} + } + } +} + +// Size returns the current size of the display. +func (d *Device) Size() (w, h int16) { + return ledCols, ledRows +} + +// pinMapping defines the mapping of LED indices to pin pairs. Each entry corresponds +// to an LED index (0-104) and contains the two pin numbers that need to be set to turn on that LED. +// based on https://github.com/arduino/ArduinoCore-zephyr/blob/main/loader/matrix.inc#L13 +var pinMapping = [][2]uint8{ + {0, 1}, // 0 + {1, 0}, + {0, 2}, + {2, 0}, + {1, 2}, + {2, 1}, + {0, 3}, + {3, 0}, + {1, 3}, + {3, 1}, + {2, 3}, // 10 + {3, 2}, + {0, 4}, + {4, 0}, + {1, 4}, + {4, 1}, + {2, 4}, + {4, 2}, + {3, 4}, + {4, 3}, + {0, 5}, // 20 + {5, 0}, + {1, 5}, + {5, 1}, + {2, 5}, + {5, 2}, + {3, 5}, + {5, 3}, + {4, 5}, + {5, 4}, + {0, 6}, // 30 + {6, 0}, + {1, 6}, + {6, 1}, + {2, 6}, + {6, 2}, + {3, 6}, + {6, 3}, + {4, 6}, + {6, 4}, + {5, 6}, // 40 + {6, 5}, + {0, 7}, + {7, 0}, + {1, 7}, + {7, 1}, + {2, 7}, + {7, 2}, + {3, 7}, + {7, 3}, + {4, 7}, // 50 + {7, 4}, + {5, 7}, + {7, 5}, + {6, 7}, + {7, 6}, + {0, 8}, + {8, 0}, + {1, 8}, + {8, 1}, + {2, 8}, // 60 + {8, 2}, + {3, 8}, + {8, 3}, + {4, 8}, + {8, 4}, + {5, 8}, + {8, 5}, + {6, 8}, + {8, 6}, + {7, 8}, // 70 + {8, 7}, + {0, 9}, + {9, 0}, + {1, 9}, + {9, 1}, + {2, 9}, + {9, 2}, + {3, 9}, + {9, 3}, + {4, 9}, // 80 + {9, 4}, + {5, 9}, + {9, 5}, + {6, 9}, + {9, 6}, + {7, 9}, + {9, 7}, + {8, 9}, + {9, 8}, + {0, 10}, // 90 + {10, 0}, + {1, 10}, + {10, 1}, + {2, 10}, + {10, 2}, + {3, 10}, + {10, 3}, + {4, 10}, + {10, 4}, + {5, 10}, // 100 + {10, 5}, + {6, 10}, + {10, 6}, +} + +// clearDisplay turns off all the LEDs on the display by floating all pins. +func (d *Device) clearDisplay() { + for i := range d.pins { + d.pins[i].Float() + } +} diff --git a/unoqmatrix/matrix_test.go b/unoqmatrix/matrix_test.go new file mode 100644 index 0000000..4e29958 --- /dev/null +++ b/unoqmatrix/matrix_test.go @@ -0,0 +1,325 @@ +package unoqmatrix + +import ( + "image/color" + "testing" + + pin "tinygo.org/x/drivers/internal/pin" +) + +// pinState tracks the state of a mock charlieplex pin. +type pinState struct { + level bool // true=high, false=low + isOutput bool // true=output mode, false=floating (high-Z) +} + +// mockPins creates 11 mock CharlieplexPins and returns them along with their observable state. +func mockPins() ([numPins]CharlieplexPin, *[numPins]pinState) { + var pins [numPins]CharlieplexPin + var states [numPins]pinState + for i := range pins { + idx := i // capture + pins[i] = CharlieplexPin{ + Set: pin.OutputFunc(func(level bool) { + states[idx].isOutput = true + states[idx].level = level + }), + Float: func() { + states[idx].isOutput = false + states[idx].level = false + }, + } + } + return pins, &states +} + +func newTestDevice() (Device, *[numPins]pinState) { + pins, states := mockPins() + d := New(pins) + return d, states +} + +func TestNew(t *testing.T) { + d, _ := newTestDevice() + w, h := d.Size() + if w != ledCols || h != ledRows { + t.Errorf("Size() = (%d, %d), want (%d, %d)", w, h, ledCols, ledRows) + } +} + +func TestSize(t *testing.T) { + d, _ := newTestDevice() + w, h := d.Size() + if w != 13 { + t.Errorf("width = %d, want 13", w) + } + if h != 8 { + t.Errorf("height = %d, want 8", h) + } +} + +func TestSetGetPixel(t *testing.T) { + d, _ := newTestDevice() + c := color.RGBA{R: 255, G: 128, B: 64, A: 255} + + d.SetPixel(3, 2, c) + got := d.GetPixel(3, 2) + if got != c { + t.Errorf("GetPixel(3,2) = %v, want %v", got, c) + } + + // Unset pixel should be zero-value. + got = d.GetPixel(0, 0) + if got != (color.RGBA{}) { + t.Errorf("GetPixel(0,0) = %v, want zero", got) + } +} + +func TestClearDisplay(t *testing.T) { + d, _ := newTestDevice() + on := color.RGBA{R: 255, G: 255, B: 255, A: 255} + off := color.RGBA{A: 255} + + d.SetPixel(0, 0, on) + d.SetPixel(5, 3, on) + d.ClearDisplay() + + for y := int16(0); y < ledRows; y++ { + for x := int16(0); x < ledCols; x++ { + got := d.GetPixel(x, y) + if got != off { + t.Errorf("after ClearDisplay, GetPixel(%d,%d) = %v, want %v", x, y, got, off) + } + } + } +} + +func TestSetRotation(t *testing.T) { + d, _ := newTestDevice() + + tests := []struct { + input uint8 + want uint8 + }{ + {0, 0}, + {1, 1}, + {2, 2}, + {3, 3}, + {4, 0}, // wraps + {7, 3}, // wraps + } + for _, tt := range tests { + d.SetRotation(tt.input) + if d.rotation != tt.want { + t.Errorf("SetRotation(%d): rotation = %d, want %d", tt.input, d.rotation, tt.want) + } + } +} + +func TestConfigure(t *testing.T) { + d, _ := newTestDevice() + d.Configure(Config{Rotation: 2}) + if d.rotation != 2 { + t.Errorf("Configure(Rotation:2): rotation = %d, want 2", d.rotation) + } +} + +func TestDisplayEmptyBuffer(t *testing.T) { + d, states := newTestDevice() + + err := d.Display() + if err != nil { + t.Fatalf("Display() error: %v", err) + } + + // All pins should be floating after displaying an empty buffer. + for i, s := range states { + if s.isOutput { + t.Errorf("pin %d still in output mode after empty Display()", i) + } + } +} + +func TestDisplaySinglePixel(t *testing.T) { + d, states := newTestDevice() + on := color.RGBA{R: 255, G: 255, B: 255, A: 255} + + // LED index 0 -> pinMapping[0] = {0, 1}: pin 0 high, pin 1 low. + d.SetPixel(0, 0, on) + err := d.Display() + if err != nil { + t.Fatalf("Display() error: %v", err) + } + + // After Display completes, all pins should be floating (last LED turned off). + for i, s := range states { + if s.isOutput { + t.Errorf("pin %d still in output mode after Display()", i) + } + } +} + +func TestDisplayMultiplePixels(t *testing.T) { + d, states := newTestDevice() + on := color.RGBA{R: 255, G: 255, B: 255, A: 255} + + d.SetPixel(0, 0, on) // idx 0 -> pins {0,1} + d.SetPixel(1, 0, on) // idx 1 -> pins {1,0} + d.SetPixel(2, 0, on) // idx 2 -> pins {0,2} + + err := d.Display() + if err != nil { + t.Fatalf("Display() error: %v", err) + } + + // All pins floating after display completes. + for i, s := range states { + if s.isOutput { + t.Errorf("pin %d still in output mode after Display()", i) + } + } +} + +// pinEvent records a single pin action during Display(). +type pinEvent struct { + pinIdx int + action string // "high", "low", or "float" +} + +// traceDevice creates a device that records every pin event for verification. +func traceDevice() (Device, *[]pinEvent) { + var pins [numPins]CharlieplexPin + events := &[]pinEvent{} + for i := range pins { + idx := i + pins[i] = CharlieplexPin{ + Set: pin.OutputFunc(func(level bool) { + action := "low" + if level { + action = "high" + } + *events = append(*events, pinEvent{pinIdx: idx, action: action}) + }), + Float: func() { + *events = append(*events, pinEvent{pinIdx: idx, action: "float"}) + }, + } + } + d := New(pins) + return d, events +} + +func TestDisplayDrivesCorrectPins(t *testing.T) { + d, events := traceDevice() + on := color.RGBA{R: 255, A: 255} + + // Set pixel at (0,0) -> LED index 0 -> pinMapping[0] = {0, 1}. + d.SetPixel(0, 0, on) + d.Display() + + // Expected sequence: + // 1. clearDisplay: float pins 0..10 + // 2. Drive LED 0: pin 0 high, pin 1 low + // 3. Cleanup: float pin 0, float pin 1 + + // Find the high/low events (skip initial floats from clearDisplay). + var driveEvents []pinEvent + for _, e := range *events { + if e.action == "high" || e.action == "low" { + driveEvents = append(driveEvents, e) + } + } + + if len(driveEvents) != 2 { + t.Fatalf("expected 2 drive events, got %d: %v", len(driveEvents), driveEvents) + } + if driveEvents[0].pinIdx != 0 || driveEvents[0].action != "high" { + t.Errorf("first drive event = %v, want pin 0 high", driveEvents[0]) + } + if driveEvents[1].pinIdx != 1 || driveEvents[1].action != "low" { + t.Errorf("second drive event = %v, want pin 1 low", driveEvents[1]) + } +} + +func TestDisplaySkipsBlackPixels(t *testing.T) { + d, events := traceDevice() + on := color.RGBA{R: 255, A: 255} + + // Only set one pixel in the middle of the matrix. + d.SetPixel(4, 1, on) // idx = 1*13+4 = 17 -> pinMapping[17] = {4,2} + d.Display() + + var driveEvents []pinEvent + for _, e := range *events { + if e.action == "high" || e.action == "low" { + driveEvents = append(driveEvents, e) + } + } + + // Should only drive one LED's worth of pin events. + if len(driveEvents) != 2 { + t.Fatalf("expected 2 drive events for 1 lit pixel, got %d", len(driveEvents)) + } + if driveEvents[0].pinIdx != 4 || driveEvents[0].action != "high" { + t.Errorf("expected pin 4 high, got %v", driveEvents[0]) + } + if driveEvents[1].pinIdx != 2 || driveEvents[1].action != "low" { + t.Errorf("expected pin 2 low, got %v", driveEvents[1]) + } +} + +func TestDisplayFloatsBetweenLEDs(t *testing.T) { + d, events := traceDevice() + on := color.RGBA{R: 255, A: 255} + + d.SetPixel(0, 0, on) // idx 0 -> {0,1} + d.SetPixel(1, 0, on) // idx 1 -> {1,0} + d.Display() + + // After the initial clearDisplay floats, the sequence for two LEDs should be: + // drive LED0 (pin0 high, pin1 low) + // float pin0, float pin1 (between LEDs) + // drive LED1 (pin1 high, pin0 low) + // float pin1, float pin0 (cleanup) + + // Skip the initial 11 float events from clearDisplay. + postClear := (*events)[numPins:] + + // Verify pin 0 and 1 are floated between the two LEDs. + foundFloatBetween := false + driveCount := 0 + for _, e := range postClear { + if e.action == "high" || e.action == "low" { + driveCount++ + } + // After the first pair of drive events, we should see floats before the next pair. + if driveCount == 2 && e.action == "float" { + foundFloatBetween = true + break + } + } + if !foundFloatBetween { + t.Error("expected float events between LED drives, found none") + } +} + +func TestPinMappingLength(t *testing.T) { + expected := 104 // 8x13 matrix = 104 LEDs + if len(pinMapping) != expected { + t.Errorf("pinMapping has %d entries, want %d", len(pinMapping), expected) + } +} + +func TestPinMappingIndicesInRange(t *testing.T) { + for i, pair := range pinMapping { + if pair[0] >= numPins { + t.Errorf("pinMapping[%d][0] = %d, exceeds numPins (%d)", i, pair[0], numPins) + } + if pair[1] >= numPins { + t.Errorf("pinMapping[%d][1] = %d, exceeds numPins (%d)", i, pair[1], numPins) + } + if pair[0] == pair[1] { + t.Errorf("pinMapping[%d] has same pin for both: %d", i, pair[0]) + } + } +} diff --git a/unoqmatrix/matrix_tinygo.go b/unoqmatrix/matrix_tinygo.go new file mode 100644 index 0000000..148e6a5 --- /dev/null +++ b/unoqmatrix/matrix_tinygo.go @@ -0,0 +1,36 @@ +//go:build baremetal + +package unoqmatrix + +import ( + "machine" + + pin "tinygo.org/x/drivers/internal/pin" +) + +// NewFromBasePin creates a Device from a base machine.Pin. +// It constructs 11 CharlieplexPin values from consecutive pins starting at basePin. +// Each pin lazily switches between output and input mode as needed. +func NewFromBasePin(basePin machine.Pin) Device { + var pins [numPins]CharlieplexPin + for i := range pins { + p := basePin + machine.Pin(i) + var isOutput bool + pins[i] = CharlieplexPin{ + Set: pin.OutputFunc(func(level bool) { + if !isOutput { + p.Configure(machine.PinConfig{Mode: machine.PinOutput}) + isOutput = true + } + p.Set(level) + }), + Float: func() { + if isOutput { + p.Configure(machine.PinConfig{Mode: machine.PinInput}) + isOutput = false + } + }, + } + } + return New(pins) +}