diff --git a/examples/unor4matrix/checkerboard/main.go b/examples/unor4matrix/checkerboard/main.go new file mode 100644 index 000000000..009758e4c --- /dev/null +++ b/examples/unor4matrix/checkerboard/main.go @@ -0,0 +1,31 @@ +//go:build arduino_uno_r4_wifi + +package main + +import ( + "image/color" + + "tinygo.org/x/drivers/unor4matrix" +) + +func main() { + display := unor4matrix.NewOnboard() + if err := display.Configure(); err != nil { + println(err.Error()) + return + } + width, height := display.Size() + for y := int16(0); y < height; y++ { + for x := int16(0); x < width; x++ { + if (x+y)%2 == 0 { + display.SetPixel(x, y, color.RGBA{R: 255}) + } + } + } + for { + if err := display.Display(); err != nil { + println(err.Error()) + return + } + } +} diff --git a/examples/unor4matrix/life/life.go b/examples/unor4matrix/life/life.go new file mode 100644 index 000000000..d0b736de9 --- /dev/null +++ b/examples/unor4matrix/life/life.go @@ -0,0 +1,40 @@ +//go:build arduino_uno_r4_wifi + +package main + +const ( + width = 12 + height = 8 +) + +type board [height][width]bool + +func nextGeneration(current board) (next board) { + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + neighbors := 0 + for dy := -1; dy <= 1; dy++ { + for dx := -1; dx <= 1; dx++ { + if dx == 0 && dy == 0 { + continue + } + if current[(y+dy+height)%height][(x+dx+width)%width] { + neighbors++ + } + } + } + next[y][x] = neighbors == 3 || current[y][x] && neighbors == 2 + } + } + return next +} + +func seed() board { + var cells board + cells[0][1] = true + cells[1][2] = true + cells[2][0] = true + cells[2][1] = true + cells[2][2] = true + return cells +} diff --git a/examples/unor4matrix/life/main.go b/examples/unor4matrix/life/main.go new file mode 100644 index 000000000..a92b98d31 --- /dev/null +++ b/examples/unor4matrix/life/main.go @@ -0,0 +1,44 @@ +//go:build arduino_uno_r4_wifi + +package main + +import ( + "image/color" + "time" + + "tinygo.org/x/drivers/unor4matrix" +) + +const generationInterval = 300 * time.Millisecond + +func main() { + display := unor4matrix.NewOnboard() + if err := display.Configure(); err != nil { + println(err.Error()) + return + } + cells := seed() + draw := func() { + display.ClearDisplay() + for y, row := range cells { + for x, alive := range row { + if alive { + display.SetPixel(int16(x), int16(y), color.RGBA{G: 255}) + } + } + } + } + draw() + nextUpdate := time.Now().Add(generationInterval) + for { + if err := display.Display(); err != nil { + println(err.Error()) + return + } + if now := time.Now(); !now.Before(nextUpdate) { + cells = nextGeneration(cells) + draw() + nextUpdate = now.Add(generationInterval) + } + } +} diff --git a/smoketest.sh b/smoketest.sh index b9a5636ba..9fc95f586 100755 --- a/smoketest.sh +++ b/smoketest.sh @@ -194,3 +194,7 @@ tinygo build -size short -o ./build/test.hex -target=wioterminal -stack-size 8kb # network examples (comboat) tinygo build -size short -o ./build/test.hex -target=elecrow-rp2040 -stack-size 8kb ./examples/net/tlsclient/ tinygo build -size short -o ./build/test.hex -target=elecrow-rp2350 -stack-size 8kb ./examples/net/ntpclient/ + +# Arduino UNO R4 WiFi LED matrix +tinygo build -size short -o ./build/test.hex -target=arduino-uno-r4-wifi ./examples/unor4matrix/checkerboard/ +tinygo build -size short -o ./build/test.hex -target=arduino-uno-r4-wifi ./examples/unor4matrix/life/ diff --git a/unor4matrix/README.md b/unor4matrix/README.md new file mode 100644 index 000000000..18c45d2c9 --- /dev/null +++ b/unor4matrix/README.md @@ -0,0 +1,67 @@ +# Arduino UNO R4 WiFi LED matrix + +Driver for the built-in 12x8 monochrome matrix. Import +`tinygo.org/x/drivers/unor4matrix`. The driver implements `drivers.Displayer`. + +## Usage + +```go +display := unor4matrix.NewOnboard() +if err := display.Configure(); err != nil { + return +} +display.SetPixel(0, 0, color.RGBA{R: 255}) +for { + if err := display.Display(); err != nil { + return + } +} +``` + +`NewOnboard` is available with `arduino_uno_r4_wifi`. `New` accepts eleven +`CharlieplexPin` values in this order: + +```text +P0_03 P0_04 P0_11 P0_12 P0_13 P0_15 P2_04 P2_05 P2_06 P2_12 P2_13 +``` + +Set must drive the requested level. Float must initialize a high-impedance input +without pull-ups. Configure returns `ErrMissingPinFunc` if either function is +missing, otherwise it floats every line. Configure may be repeated and keeps +the image. Give the device exclusive access to its pins. + +## Refresh model + +The image occupies 12 bytes. SetPixel edits it, ClearDisplay clears it, and +LoadFrame replaces it with Arduino-format words. Words are row-major, MSB first: +word 0 bit 31 is (0,0), word 2 bit 0 is (11,7). Any nonzero RGB is on, alpha is +ignored, and writes outside the drawing area are ignored. + +Display scans all 96 slots directly from the image and floats each active pair +after its slot. Call it repeatedly to keep an image visible. Every slot sleeps +for 100 us, including dark pixels. A frame takes at least 9.6 ms plus GPIO and +scheduler overhead. Before successful Configure, Display returns ErrNotConfigured. + +An active LED stays driven during time.Sleep. Other goroutines can prolong its on-time. + +## Examples and limitations + +```sh +tinygo flash -target=arduino-uno-r4-wifi ./examples/unor4matrix/checkerboard +tinygo flash -target=arduino-uno-r4-wifi ./examples/unor4matrix/life +``` + +Life runs a glider on a wrapping 12x8 grid with 300 ms generations. Display leaves +the lines floating before Life computes the next generation. + +Use the default `tasks` scheduler. With TinyGo 0.44 and `-scheduler=none`, short +sleeps can wait for a 1 ms SysTick, reducing refresh to about 10 Hz. Display +blocks and foreground work pauses refresh. Call the driver from one application +goroutine, never from an interrupt handler. Rotation and brightness control are +unsupported. UNO R4 Minima has no built-in matrix. UNO Q uses `unoqmatrix`. + +## Sources + +- [Arduino mapping and 10 kHz timer](https://github.com/arduino/ArduinoCore-renesas/blob/424e86eff92d37f72123c2b641dd8bbf06a38b47/libraries/Arduino_LED_Matrix/src/Arduino_LED_Matrix.h). +- [UNO R4 WiFi line order](https://github.com/arduino/ArduinoCore-renesas/blob/424e86eff92d37f72123c2b641dd8bbf06a38b47/variants/UNOWIFIR4/variant.cpp). +- [RA4M1 hardware manual](https://www.renesas.com/en/document/mah/renesas-ra4m1-group-users-manual-hardware?language=en), R01UH0887EJ0110 Rev.1.10, sections 19.2.1, 19.2.3 and 19.2.5, pp. 357, 359 and 361-362. PODR holds the output level, POSR/PORR update it, and PDR enables output. diff --git a/unor4matrix/framebuffer.go b/unor4matrix/framebuffer.go new file mode 100644 index 000000000..135538210 --- /dev/null +++ b/unor4matrix/framebuffer.go @@ -0,0 +1,27 @@ +package unor4matrix + +const ( + // Width is the number of columns in the matrix. + Width = 12 + // Height is the number of rows in the matrix. + Height = 8 + + framebufferSize = (Width*Height + 7) / 8 +) + +type framebuffer struct{ pixels [framebufferSize]byte } + +func (f *framebuffer) get(index int) bool { + return f.pixels[index/8]&(byte(1)<<(index%8)) != 0 +} + +func (f *framebuffer) set(index int, on bool) { + mask := byte(1) << (index % 8) + if on { + f.pixels[index/8] |= mask + } else { + f.pixels[index/8] &^= mask + } +} + +func (f *framebuffer) clear() { f.pixels = [framebufferSize]byte{} } diff --git a/unor4matrix/mapping.go b/unor4matrix/mapping.go new file mode 100644 index 000000000..b0afaf9d8 --- /dev/null +++ b/unor4matrix/mapping.go @@ -0,0 +1,116 @@ +package unor4matrix + +// pinPair holds matrix-line indices in the line order defined by NewOnboard in matrix_unor4.go. +type pinPair struct { + anode uint8 + cathode uint8 +} + +// ledMapping is row-major wiring from ArduinoCore-renesas. +// https://github.com/arduino/ArduinoCore-renesas/blob/424e86eff92d37f72123c2b641dd8bbf06a38b47/libraries/Arduino_LED_Matrix/src/Arduino_LED_Matrix.h +var ledMapping = [...]pinPair{ + // Row y = 0. + {anode: 7, cathode: 3}, + {anode: 3, cathode: 7}, + {anode: 7, cathode: 4}, + {anode: 4, cathode: 7}, + {anode: 3, cathode: 4}, + {anode: 4, cathode: 3}, + {anode: 7, cathode: 8}, + {anode: 8, cathode: 7}, + {anode: 3, cathode: 8}, + {anode: 8, cathode: 3}, + {anode: 4, cathode: 8}, + {anode: 8, cathode: 4}, + // Row y = 1. + {anode: 7, cathode: 0}, + {anode: 0, cathode: 7}, + {anode: 3, cathode: 0}, + {anode: 0, cathode: 3}, + {anode: 4, cathode: 0}, + {anode: 0, cathode: 4}, + {anode: 8, cathode: 0}, + {anode: 0, cathode: 8}, + {anode: 7, cathode: 6}, + {anode: 6, cathode: 7}, + {anode: 3, cathode: 6}, + {anode: 6, cathode: 3}, + // Row y = 2. + {anode: 4, cathode: 6}, + {anode: 6, cathode: 4}, + {anode: 8, cathode: 6}, + {anode: 6, cathode: 8}, + {anode: 0, cathode: 6}, + {anode: 6, cathode: 0}, + {anode: 7, cathode: 5}, + {anode: 5, cathode: 7}, + {anode: 3, cathode: 5}, + {anode: 5, cathode: 3}, + {anode: 4, cathode: 5}, + {anode: 5, cathode: 4}, + // Row y = 3. + {anode: 8, cathode: 5}, + {anode: 5, cathode: 8}, + {anode: 0, cathode: 5}, + {anode: 5, cathode: 0}, + {anode: 6, cathode: 5}, + {anode: 5, cathode: 6}, + {anode: 7, cathode: 1}, + {anode: 1, cathode: 7}, + {anode: 3, cathode: 1}, + {anode: 1, cathode: 3}, + {anode: 4, cathode: 1}, + {anode: 1, cathode: 4}, + // Row y = 4. + {anode: 8, cathode: 1}, + {anode: 1, cathode: 8}, + {anode: 0, cathode: 1}, + {anode: 1, cathode: 0}, + {anode: 6, cathode: 1}, + {anode: 1, cathode: 6}, + {anode: 5, cathode: 1}, + {anode: 1, cathode: 5}, + {anode: 7, cathode: 2}, + {anode: 2, cathode: 7}, + {anode: 3, cathode: 2}, + {anode: 2, cathode: 3}, + // Row y = 5. + {anode: 4, cathode: 2}, + {anode: 2, cathode: 4}, + {anode: 8, cathode: 2}, + {anode: 2, cathode: 8}, + {anode: 0, cathode: 2}, + {anode: 2, cathode: 0}, + {anode: 6, cathode: 2}, + {anode: 2, cathode: 6}, + {anode: 5, cathode: 2}, + {anode: 2, cathode: 5}, + {anode: 1, cathode: 2}, + {anode: 2, cathode: 1}, + // Row y = 6. + {anode: 7, cathode: 10}, + {anode: 10, cathode: 7}, + {anode: 3, cathode: 10}, + {anode: 10, cathode: 3}, + {anode: 4, cathode: 10}, + {anode: 10, cathode: 4}, + {anode: 8, cathode: 10}, + {anode: 10, cathode: 8}, + {anode: 0, cathode: 10}, + {anode: 10, cathode: 0}, + {anode: 6, cathode: 10}, + {anode: 10, cathode: 6}, + // Row y = 7. + {anode: 5, cathode: 10}, + {anode: 10, cathode: 5}, + {anode: 1, cathode: 10}, + {anode: 10, cathode: 1}, + {anode: 2, cathode: 10}, + {anode: 10, cathode: 2}, + {anode: 7, cathode: 9}, + {anode: 9, cathode: 7}, + {anode: 3, cathode: 9}, + {anode: 9, cathode: 3}, + {anode: 4, cathode: 9}, + {anode: 9, cathode: 4}, +} diff --git a/unor4matrix/matrix.go b/unor4matrix/matrix.go new file mode 100644 index 000000000..686a2e024 --- /dev/null +++ b/unor4matrix/matrix.go @@ -0,0 +1,90 @@ +// Package unor4matrix drives the UNO R4 WiFi's built-in 12x8 LED matrix. +// Display scans a complete frame and blanks the lines before returning. +package unor4matrix + +import ( + "errors" + "image/color" + "time" +) + +// Device stores one drawing buffer. Use it from one application goroutine. +type Device struct { + pins [numPins]CharlieplexPin + frame framebuffer + configured bool +} + +// refreshPeriod matches the Arduino matrix driver's 10 kHz scan timer. +// https://github.com/arduino/ArduinoCore-renesas/blob/424e86eff92d37f72123c2b641dd8bbf06a38b47/libraries/Arduino_LED_Matrix/src/Arduino_LED_Matrix.h#L172 +const refreshPeriod = 100 * time.Microsecond + +// ErrNotConfigured reports Display called before successful Configure. +var ErrNotConfigured = errors.New("unor4matrix: not configured") + +// ErrMissingPinFunc reports a missing Set or Float function. +var ErrMissingPinFunc = errors.New("unor4matrix: all eleven lines require Set and Float") + +// Configure floats all lines and retains the stored image. +// The caller exclusively owns the lines and serializes display operations. +func (d *Device) Configure() error { + for _, pin := range d.pins { + if pin.Set == nil || pin.Float == nil { + return ErrMissingPinFunc + } + } + for _, pin := range d.pins { + pin.Float() + } + d.configured = true + return nil +} + +// SetPixel edits one logical pixel. Nonzero RGB is on. Alpha is ignored. +// Out-of-bounds writes are ignored. Call Display to show changes. +func (d *Device) SetPixel(x, y int16, c color.RGBA) { + if x < 0 || x >= Width || y < 0 || y >= Height { + return + } + d.frame.set(int(y)*Width+int(x), c.R != 0 || c.G != 0 || c.B != 0) +} + +// Size returns the fixed 12 by 8 drawing area. +func (d *Device) Size() (width, height int16) { return Width, Height } + +// Display scans one frame and blanks the lines. Call repeatedly to refresh. +// On RA4M1, use tasks. With -scheduler=none, refresh may fall to about 10 Hz. +func (d *Device) Display() error { + if !d.configured { + return ErrNotConfigured + } + for index, pair := range ledMapping { + on := d.frame.get(index) + if on { + d.pins[pair.cathode].Set.Low() + d.pins[pair.anode].Set.High() + } + // Yield between slots so other goroutines can run. + // They may extend the active LED's on-time. + time.Sleep(refreshPeriod) + if on { + d.pins[pair.anode].Float() + d.pins[pair.cathode].Float() + } + } + return nil +} + +// ClearDisplay clears the drawing buffer. Call Display to show the change. +func (d *Device) ClearDisplay() { d.frame.clear() } + +// LoadFrame edits the drawing buffer using Arduino row-major, MSB-first words. +// Call Display to show the image. +func (d *Device) LoadFrame(words [3]uint32) { + d.frame.clear() + for index := 0; index < Width*Height; index++ { + if words[index/32]&(uint32(1)<<(31-index%32)) != 0 { + d.frame.set(index, true) + } + } +} diff --git a/unor4matrix/matrix_test.go b/unor4matrix/matrix_test.go new file mode 100644 index 000000000..33d4d8f29 --- /dev/null +++ b/unor4matrix/matrix_test.go @@ -0,0 +1,128 @@ +package unor4matrix + +import ( + "image/color" + "testing" + + "tinygo.org/x/drivers" +) + +var _ drivers.Displayer = (*Device)(nil) + +func TestClearDisplay(t *testing.T) { + var d Device + d.LoadFrame([3]uint32{0xffffffff, 0xffffffff, 0xffffffff}) + d.ClearDisplay() + if d.frame.pixels != ([framebufferSize]byte{}) { + t.Fatalf("pixels = %v", d.frame.pixels) + } +} + +func TestLoadFrame(t *testing.T) { + allOn := [12]byte{ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + } + tests := []struct { + name string + words [3]uint32 + want [12]byte + }{ + { + name: "blank replaces existing pixels", + }, + { + name: "all pixels", + words: [3]uint32{0xffffffff, 0xffffffff, 0xffffffff}, + want: allOn, + }, + { + name: "first pixel", + words: [3]uint32{0x80000000, 0, 0}, + want: [12]byte{0: 0x01}, + }, + { + name: "last bit of first word", + words: [3]uint32{0x00000001, 0, 0}, + want: [12]byte{3: 0x80}, + }, + { + name: "first bit of second word", + words: [3]uint32{0, 0x80000000, 0}, + want: [12]byte{4: 0x01}, + }, + { + name: "last bit of second word", + words: [3]uint32{0, 0x00000001, 0}, + want: [12]byte{7: 0x80}, + }, + { + name: "first bit of third word", + words: [3]uint32{0, 0, 0x80000000}, + want: [12]byte{8: 0x01}, + }, + { + name: "last pixel", + words: [3]uint32{0, 0, 0x00000001}, + want: [12]byte{11: 0x80}, + }, + { + name: "bit order within a byte", + words: [3]uint32{0xa1000000, 0, 0}, + want: [12]byte{0: 0x85}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var d Device + d.frame.pixels = allOn + + d.LoadFrame(tt.words) + if d.frame.pixels != tt.want { + t.Fatalf("drawing buffer = %v, want %v", d.frame.pixels, tt.want) + } + }) + } +} + +func TestSizeAndColors(t *testing.T) { + var d Device + if w, h := d.Size(); w != 12 || h != 8 { + t.Fatalf("size %d,%d", w, h) + } + for _, c := range []color.RGBA{{R: 1}, {G: 1}, {B: 1}, {R: 255, A: 0}} { + d.ClearDisplay() + d.SetPixel(11, 7, c) + if d.frame.pixels != ([framebufferSize]byte{11: 0x80}) { + t.Fatalf("color %v should be on", c) + } + } + d.SetPixel(11, 7, color.RGBA{A: 255}) + if d.frame.pixels != ([framebufferSize]byte{}) { + t.Fatal("black should be off") + } + before := d.frame + for _, xy := range [][2]int16{{-1, 0}, {0, -1}, {12, 0}, {0, 8}} { + d.SetPixel(xy[0], xy[1], color.RGBA{R: 255}) + } + if d.frame != before { + t.Fatal("out-of-bounds changed drawing buffer") + } +} + +func TestMapping(t *testing.T) { + if len(ledMapping) != 96 { + t.Fatal("mapping length", len(ledMapping)) + } + seen := map[pinPair]bool{} + for i, pair := range ledMapping { + if pair.anode >= 11 || pair.cathode >= 11 || pair.anode == pair.cathode || seen[pair] { + t.Fatalf("invalid/duplicate pair %d: %v", i, pair) + } + seen[pair] = true + } + if ledMapping[0] != (pinPair{7, 3}) || ledMapping[1] != (pinPair{3, 7}) || ledMapping[95] != (pinPair{9, 4}) { + t.Fatal("known source landmarks differ") + } +} diff --git a/unor4matrix/matrix_unor4.go b/unor4matrix/matrix_unor4.go new file mode 100644 index 000000000..5b14ecd24 --- /dev/null +++ b/unor4matrix/matrix_unor4.go @@ -0,0 +1,30 @@ +//go:build arduino_uno_r4_wifi + +package unor4matrix + +import "machine" + +// NewOnboard returns a device connected to the UNO R4 WiFi's built-in matrix. +func NewOnboard() Device { + // Matrix line order from the UNO R4 WiFi variant. + // https://github.com/arduino/ArduinoCore-renesas/blob/424e86eff92d37f72123c2b641dd8bbf06a38b47/variants/UNOWIFIR4/variant.cpp + lines := [...]machine.Pin{ + machine.P0_03, machine.P0_04, machine.P0_11, + machine.P0_12, machine.P0_13, machine.P0_15, + machine.P2_04, machine.P2_05, machine.P2_06, + machine.P2_12, machine.P2_13, + } + var pins [numPins]CharlieplexPin + for i, p := range lines { + pins[i] = CharlieplexPin{ + Set: func(level bool) { + // Preload PODR before enabling PDR to avoid driving the previous level. + // RA4M1 R01UH0887EJ0110, sections 19.2.1, 19.2.3, 19.2.5, pp. 357, 359, 361-362. + p.Set(level) + p.Configure(machine.PinConfig{Mode: machine.PinOutput}) + }, + Float: func() { p.Configure(machine.PinConfig{Mode: machine.PinInput}) }, + } + } + return New(pins) +} diff --git a/unor4matrix/pins.go b/unor4matrix/pins.go new file mode 100644 index 000000000..00211fc3c --- /dev/null +++ b/unor4matrix/pins.go @@ -0,0 +1,18 @@ +package unor4matrix + +import pin "tinygo.org/x/drivers/internal/pin" + +const numPins = 11 + +// CharlieplexPin can drive a line or put it in high-impedance input mode. +// Set and Float must not block or allocate. Float must also initialize the pin. +type CharlieplexPin struct { + Set pin.OutputFunc + Float func() +} + +// New returns a device for P0_03, P0_04, P0_11, P0_12, P0_13, P0_15, +// P2_04, P2_05, P2_06, P2_12 and P2_13, in that order. +func New(pins [numPins]CharlieplexPin) Device { + return Device{pins: pins} +} diff --git a/unor4matrix/scan_test.go b/unor4matrix/scan_test.go new file mode 100644 index 000000000..4f3100c8c --- /dev/null +++ b/unor4matrix/scan_test.go @@ -0,0 +1,119 @@ +package unor4matrix + +import ( + "errors" + "image/color" + "reflect" + "testing" + "time" +) + +type event struct { + line int + action string +} + +func traceDevice() (Device, *[]event) { + var pins [numPins]CharlieplexPin + events := new([]event) + for i := range pins { + pins[i] = CharlieplexPin{ + Set: func(level bool) { + action := "low" + if level { + action = "high" + } + *events = append(*events, event{i, action}) + }, + Float: func() { *events = append(*events, event{i, "float"}) }, + } + } + return New(pins), events +} + +func TestDisplayBeforeConfigure(t *testing.T) { + d, events := traceDevice() + d.LoadFrame([3]uint32{0x80000000, 0, 1}) + if err := d.Display(); !errors.Is(err, ErrNotConfigured) { + t.Fatalf("unconfigured Display = %v", err) + } + if len(*events) != 0 { + t.Fatal("unconfigured display touched GPIO") + } +} + +func TestDisplayScansCurrentPixelsAndBlanks(t *testing.T) { + d, events := traceDevice() + d.LoadFrame([3]uint32{0x80000000, 0, 1}) + if err := d.Configure(); err != nil { + t.Fatal(err) + } + var floats []event + for i := 0; i < 11; i++ { + floats = append(floats, event{i, "float"}) + } + if !reflect.DeepEqual(*events, floats) { + t.Fatalf("configure = %v", *events) + } + *events = nil + if err := d.Display(); err != nil { + t.Fatal(err) + } + want := []event{{3, "low"}, {7, "high"}, {7, "float"}, {3, "float"}, {4, "low"}, {9, "high"}, {9, "float"}, {4, "float"}} + if !reflect.DeepEqual(*events, want) { + t.Fatalf("display = %v, want %v", *events, want) + } + d.ClearDisplay() + d.SetPixel(1, 0, color.RGBA{R: 255}) + if err := d.Configure(); err != nil { + t.Fatal(err) + } + *events = nil + if err := d.Display(); err != nil { + t.Fatal(err) + } + want = []event{{7, "low"}, {3, "high"}, {3, "float"}, {7, "float"}} + if !reflect.DeepEqual(*events, want) { + t.Fatalf("next frame = %v, want %v", *events, want) + } +} + +func TestDarkFrameKeepsAllSlotDelays(t *testing.T) { + d, events := traceDevice() + if err := d.Configure(); err != nil { + t.Fatal(err) + } + *events = nil + start := time.Now() + if err := d.Display(); err != nil { + t.Fatal(err) + } + if elapsed := time.Since(start); elapsed < 96*100*time.Microsecond { + t.Fatalf("dark frame returned too early: %v", elapsed) + } + if len(*events) != 0 { + t.Fatalf("dark frame drove GPIO: %v", *events) + } +} + +func TestIncompletePinsDoNotTouchHardware(t *testing.T) { + for _, missingSet := range []bool{true, false} { + var pins [numPins]CharlieplexPin + touched := false + for i := range pins { + pins[i] = CharlieplexPin{Set: func(bool) { touched = true }, Float: func() { touched = true }} + } + if missingSet { + pins[10].Set = nil + } else { + pins[10].Float = nil + } + d := New(pins) + if err := d.Configure(); !errors.Is(err, ErrMissingPinFunc) { + t.Fatalf("configure = %v", err) + } + if touched { + t.Fatal("invalid device touched GPIO") + } + } +}