Managed graphics for non-TFT displays in TinyCLR

The firmwares for TinyCLR OS on our devices like G120, G400, and the new UC5550 come with a built-in and native library for graphics that can be used with TFT displays. We also wanted to enable graphics on low-memory devices like our $10 FEZ and the BrainPad that don’t have enough resources for the full-graphics library without creating a second API to learn. To do this we added a layer of indirection in our implementation of the System.Drawing APIs that you’re already familiar with which allows those APIs to work with any draw target (much like the provider model for GPIO, SPI, and other device peripheral APIs).

To make this work you just need to call GraphicsManager.RegisterDrawTarget in the GHIElectronics.TinyCLR.Drawing library and namespace. This function takes a single parameter of type IDrawTarget and returns an IntPtr. The return value represents a handle to a device context that you pass to Graphics.FromHdc like you do for the bigger TFT displays. From there you can use many of the same display functions to perform graphical operations like DrawEllipse and DrawString.

var hdc = GraphicsManager.RegisterDrawTarget(new DrawTarget(disp));
var screen = Graphics.FromHdc(hdc);

screen.DrawEllipse(Colors.Blue, 10, 10, 10, 10);
screen.Flush();

How you implement IDrawTarget really depends on what kind of display you’re working with. The core functionality of IDrawTarget comes from SetPixel and Flush. All the drawing algorithms implemented in the library we provide eventually call SetPixel so that you can update your internal data structure and then update the display once Flush is called.

Take a SPI display on our G80 Development Board, for example. It’s a 160x128 pixel display and expects data in RGB565 format over SPI, with pixel data going left-to-right down the screen. That means that each pixel is made up of two bytes: 5 red bits, 6 green bits, and 5 blue bits, in that order. To hold the pixel data for the entire display we need to create a byte array that is width * height * bpp bytes in size. So, for this display, that comes out to 128 * 160 * 2 = 40,960 bytes. In the below image each box outlined in black represents one pixel with its constituent bytes and each number represents the byte in the order it will be sent to the display. Notice how there are 320 bytes in the first row, that is because each of the 160 pixels has two bytes.

Display Format

You’ll want to create a private field in your class that implements IDrawBuffer for that buffer with that size. Then in the SetPixel function you’re given the x and y coordinate of the pixel and the color to set it to. You can call ToArgb on this color to get the color in RGB888 format which is 8 bits each for red, green, and blue. So how to convert that to RGB565 and store it in the byte array? You’ll want to take the highest-order bits from the RGB888 color, since they have the biggest effect on color, then shift them down into the RGB565 array. To find out which byte in the array you start at for a given pixel, you do (y * width + x) * 2 so for the pixel at (159, 1) the math would come out to (1 * 160 + 159) * 2 = 638 which you can verify in the above image. The below image shows where each bit in a given pixel moves to with this conversion. Each thick black box represents one byte. Each numbered cell is one bit in the given color.

Notice how the green component is split up among the two bytes. The first byte is made up the five bits for red and the upper three bits for green. The second byte is made up of the lower three bits for green and the five bits for blue. The below code shows one way to implement this conversion. We extract just the bits we want from the source color and then we shift them right a certain amount so that we can have the colors in individual variables since the source color is one big integer. We then have to pick out the bits we want again, just five from red and blue but six from green, then shift them into the spot we need them in the result bytes, then we combine them together. Take a look at this Wikipedia article if you’re unfamiliar with bitwise operations.

public void SetPixel(int x, int y, Color color) {
    if (x < 0 || y < 0 || x >= this.Width || y >= this.Height) return;

    var idx = (y * this.Width + x) * 2;
    var clr = color.ToArgb();
    var red = (clr & 0b0000_0000_1111_1111_0000_0000_0000_0000) >> 16;
    var green = (clr & 0b0000_0000_0000_0000_1111_1111_0000_0000) >> 8;
    var blue = (clr & 0b0000_0000_0000_0000_0000_0000_1111_1111) >> 0;

    this.buffer[idx] = (byte)((red & 0b1111_1000) | ((green & 0b1110_0000) >> 5));
    this.buffer[idx + 1] = (byte)(((green & 0b0001_1100) << 3) | ((blue & 0b1111_1000) >> 3));
}

Now we have the byte array with our pixel data in it, how do we get it to the display? One way is to pass in a DisplayController (which implements IDisplayControllerProvider) to your class and then call DrawBuffer on it. You’ll want to make sure the display controller expects data in the format we’re converting it to. Since we’re storing the data in RGB565 format and that is what the display on the dev board expects there’s no problem.

public void Flush() => this.parent.DrawBuffer(0, 0, this.Width, this.Height, this.buffer, 0);

DrawBuffer does allow us to specify a custom location and size to draw but since we’re drawing the whole screen we start at (0, 0). Getting the display controller is the next part. You’ll want to make a class that implements IDisplayControllerProvider. The important function here is DrawBuffer. It’s the same function we called above. It’s responsible for taking the buffer and sending it to the display. For the display on the G80 Development Board, it’ll do this over SPI so your class will need to take in a SPI device that it can use to write to. This class should also configure the display as needed. The below code implements this function and performs the needed steps to get the data to the ST7735 display. We’re ignoring some of the parameters in this example, though more advanced applications can make use of them.

void IDisplayControllerProvider.DrawBuffer(int x, int y, int width, int height, byte[] data, int offset) {
    this.SendCommand(ST7735CommandId.RAMWR);
    this.control.Write(GpioPinValue.High);
    this.spi.Write(data, offset, data.Length);
}

You can find a complete example below that can run as-is on the G80 Development Board using the latest preview3 release. It draws a small white ball bouncing around the screen. It creates all the pins that are needed and does all the initialization of the screen. Luckily we’ve done a lot of this for you already and have made it available in the ST7735 NuGet package. We’ve also made draw targets for in-memory RGB565, RGB444, and VerticalByteStrip1Bpp available in the GHIElectronics.TinyCLR.Drawing namespace as BufferDrawTargetRgb444 (and related). You just need to derive from one of those classes and implement your custom Flush logic. You can see a complete example of this for the Adafruit display shield on our GitHub.

using GHIElectronics.TinyCLR.Devices.Display;
using GHIElectronics.TinyCLR.Devices.Display.Provider;
using GHIElectronics.TinyCLR.Devices.Gpio;
using GHIElectronics.TinyCLR.Devices.Spi;
using GHIElectronics.TinyCLR.Drawing;
using GHIElectronics.TinyCLR.Pins;
using System;
using System.Drawing;
using System.Threading;

namespace G80DevBoardDisplay {
    public static class Program {
        public static void Main() {
            var spi = SpiController.FromName(G80.SpiBus.Spi2);
            var gpio = GpioController.GetDefault();
            var st7735 = new ST7735Controller(spi.GetDevice(ST7735Controller.GetConnectionSettings(SpiChipSelectType.Gpio, G80.GpioPin.PD10)), gpio.OpenPin(G80.GpioPin.PE10), gpio.OpenPin(G80.GpioPin.PE12));

            var disp = DisplayController.FromProvider(st7735);
            disp.SetConfiguration(new SpiDisplayControllerSettings { Width = 160, Height = 128 });

            var bl = gpio.OpenPin(G80.GpioPin.PC7);
            bl.Write(GpioPinValue.High);
            bl.SetDriveMode(GpioPinDriveMode.Output);

            var hdc = GraphicsManager.RegisterDrawTarget(new DrawTarget(disp));
            var screen = Graphics.FromHdc(hdc);

            var rnd = new Random();
            var x = rnd.Next(160);
            var y = rnd.Next(128);
            var vx = rnd.Next(20) - 10;
            var vy = rnd.Next(20) - 10;
            var color = new Pen(Color.White);

            while (true) {
                x += vx;
                y += vy;

                if (x >= 160 || x < 0) vx *= -1;
                if (y >= 128 || y < 0) vy *= -1;

                screen.Clear(Color.Black);
                screen.DrawEllipse(color, x, y, 10, 10);
                screen.Flush();

                Thread.Sleep(10);
            }
        }
    }

    public sealed class DrawTarget : IDrawTarget {
        private readonly DisplayController parent;
        private readonly byte[] buffer;

        public DrawTarget(DisplayController parent) {
            this.parent = parent;

            this.Width = parent.ActiveConfiguration.Width;
            this.Height = parent.ActiveConfiguration.Height;

            this.buffer = new byte[this.Width * this.Height * 2];
        }

        public int Width { get; }
        public int Height { get; }

        public void Dispose() { }
        public byte[] GetData() => this.buffer;
        public Color GetPixel(int x, int y) => throw new NotSupportedException();

        public void Clear(Color color) => Array.Clear(this.buffer, 0, this.buffer.Length);

        public void Flush() => this.parent.DrawBuffer(0, 0, this.Width, this.Height, this.buffer, 0);

        public void SetPixel(int x, int y, Color color) {
            if (x < 0 || y < 0 || x >= this.Width || y >= this.Height) return;

            var idx = (y * this.Width + x) * 2;
            var clr = color.ToArgb();
            var red = (clr & 0b0000_0000_1111_1111_0000_0000_0000_0000) >> 16;
            var green = (clr & 0b0000_0000_0000_0000_1111_1111_0000_0000) >> 8;
            var blue = (clr & 0b0000_0000_0000_0000_0000_0000_1111_1111) >> 0;

            this.buffer[idx] = (byte)((red & 0b1111_1000) | ((green & 0b1110_0000) >> 5));
            this.buffer[idx + 1] = (byte)(((green & 0b0001_1100) << 3) | ((blue & 0b1111_1000) >> 3));
        }
    }

    public class ST7735Controller : IDisplayControllerProvider {
        private readonly byte[] buffer1 = new byte[1];
        private readonly byte[] buffer4 = new byte[4];
        private readonly SpiDevice spi;
        private readonly GpioPin control;
        private readonly GpioPin reset;

        public static SpiConnectionSettings GetConnectionSettings(SpiChipSelectType chipSelectType, int chipSelectLine) => new SpiConnectionSettings {
            Mode = SpiMode.Mode3,
            ClockFrequency = 12_000_000,
            DataBitLength = 8,
            ChipSelectType = chipSelectType,
            ChipSelectLine = chipSelectLine
        };

        public ST7735Controller(SpiDevice spi, GpioPin control, GpioPin reset) {
            this.spi = spi;

            this.control = control;
            this.control.SetDriveMode(GpioPinDriveMode.Output);

            this.reset = reset;
            this.reset.SetDriveMode(GpioPinDriveMode.Output);

            this.reset.Write(GpioPinValue.Low);
            Thread.Sleep(50);

            this.reset.Write(GpioPinValue.High);
            Thread.Sleep(200);

            this.SendCommand(ST7735CommandId.SWRESET);
            Thread.Sleep(120);

            this.SendCommand(ST7735CommandId.SLPOUT);
            Thread.Sleep(120);

            this.SendCommand(ST7735CommandId.FRMCTR1);
            this.SendData(0x01);
            this.SendData(0x2C);
            this.SendData(0x2D);

            this.SendCommand(ST7735CommandId.FRMCTR2);
            this.SendData(0x01);
            this.SendData(0x2C);
            this.SendData(0x2D);

            this.SendCommand(ST7735CommandId.FRMCTR3);
            this.SendData(0x01);
            this.SendData(0x2C);
            this.SendData(0x2D);
            this.SendData(0x01);
            this.SendData(0x2C);
            this.SendData(0x2D);

            this.SendCommand(ST7735CommandId.INVCTR);
            this.SendData(0x07);

            this.SendCommand(ST7735CommandId.PWCTR1);
            this.SendData(0xA2);
            this.SendData(0x02);
            this.SendData(0x84);

            this.SendCommand(ST7735CommandId.PWCTR2);
            this.SendData(0xC5);

            this.SendCommand(ST7735CommandId.PWCTR3);
            this.SendData(0x0A);
            this.SendData(0x00);

            this.SendCommand(ST7735CommandId.PWCTR4);
            this.SendData(0x8A);
            this.SendData(0x2A);

            this.SendCommand(ST7735CommandId.PWCTR5);
            this.SendData(0x8A);
            this.SendData(0xEE);

            this.SendCommand(ST7735CommandId.VMCTR1);
            this.SendData(0x0E);

            this.SendCommand(ST7735CommandId.GAMCTRP1);
            this.SendData(0x0F);
            this.SendData(0x1A);
            this.SendData(0x0F);
            this.SendData(0x18);
            this.SendData(0x2F);
            this.SendData(0x28);
            this.SendData(0x20);
            this.SendData(0x22);
            this.SendData(0x1F);
            this.SendData(0x1B);
            this.SendData(0x23);
            this.SendData(0x37);
            this.SendData(0x00);
            this.SendData(0x07);
            this.SendData(0x02);
            this.SendData(0x10);

            this.SendCommand(ST7735CommandId.GAMCTRN1);
            this.SendData(0x0F);
            this.SendData(0x1B);
            this.SendData(0x0F);
            this.SendData(0x17);
            this.SendData(0x33);
            this.SendData(0x2C);
            this.SendData(0x29);
            this.SendData(0x2E);
            this.SendData(0x30);
            this.SendData(0x30);
            this.SendData(0x39);
            this.SendData(0x3F);
            this.SendData(0x00);
            this.SendData(0x07);
            this.SendData(0x03);
            this.SendData(0x10);

            this.SendCommand(ST7735CommandId.COLMOD);
            this.SendData(0x05);

            this.SendCommand(ST7735CommandId.MADCTL);
            this.SendData(0b1010_0000);

            this.buffer4[1] = 0;
            this.buffer4[3] = 159;
            this.SendCommand(ST7735CommandId.CASET);
            this.SendData(this.buffer4);

            this.buffer4[1] = 0;
            this.buffer4[3] = 127;
            this.SendCommand(ST7735CommandId.RASET);
            this.SendData(this.buffer4);

            this.SendCommand(ST7735CommandId.DISPON);
        }

        private void SendCommand(ST7735CommandId command) {
            this.buffer1[0] = (byte)command;
            this.control.Write(GpioPinValue.Low);
            this.spi.Write(this.buffer1);
        }

        private void SendData(byte data) {
            this.buffer1[0] = data;
            this.control.Write(GpioPinValue.High);
            this.spi.Write(this.buffer1);
        }

        private void SendData(byte[] data) {
            this.control.Write(GpioPinValue.High);
            this.spi.Write(data);
        }

        void IDisplayControllerProvider.DrawBuffer(int x, int y, int width, int height, byte[] data, int offset) {
            this.SendCommand(ST7735CommandId.RAMWR);
            this.control.Write(GpioPinValue.High);
            this.spi.Write(data, offset, data.Length);
        }

        DisplayInterface IDisplayControllerProvider.Interface => DisplayInterface.Spi;
        DisplayDataFormat[] IDisplayControllerProvider.SupportedDataFormats => new[] { DisplayDataFormat.Rgb565 };

        void IDisposable.Dispose() { }
        void IDisplayControllerProvider.Enable() { }
        void IDisplayControllerProvider.Disable() { }
        void IDisplayControllerProvider.SetConfiguration(DisplayControllerSettings configuration) { }
        void IDisplayControllerProvider.DrawString(string value) { }
        void IDisplayControllerProvider.DrawPixel(int x, int y, long color) { }
    }

    public enum ST7735CommandId : byte {
        //System
        NOP = 0x00,
        SWRESET = 0x01,
        RDDID = 0x04,
        RDDST = 0x09,
        RDDPM = 0x0A,
        RDDMADCTL = 0x0B,
        RDDCOLMOD = 0x0C,
        RDDIM = 0x0D,
        RDDSM = 0x0E,
        SLPIN = 0x10,
        SLPOUT = 0x11,
        PTLON = 0x12,
        NORON = 0x13,
        INVOFF = 0x20,
        INVON = 0x21,
        GAMSET = 0x26,
        DISPOFF = 0x28,
        DISPON = 0x29,
        CASET = 0x2A,
        RASET = 0x2B,
        RAMWR = 0x2C,
        RAMRD = 0x2E,
        PTLAR = 0x30,
        TEOFF = 0x34,
        TEON = 0x35,
        MADCTL = 0x36,
        IDMOFF = 0x38,
        IDMON = 0x39,
        COLMOD = 0x3A,
        RDID1 = 0xDA,
        RDID2 = 0xDB,
        RDID3 = 0xDC,

        //Panel
        FRMCTR1 = 0xB1,
        FRMCTR2 = 0xB2,
        FRMCTR3 = 0xB3,
        INVCTR = 0xB4,
        DISSET5 = 0xB6,
        PWCTR1 = 0xC0,
        PWCTR2 = 0xC1,
        PWCTR3 = 0xC2,
        PWCTR4 = 0xC3,
        PWCTR5 = 0xC4,
        VMCTR1 = 0xC5,
        VMOFCTR = 0xC7,
        WRID2 = 0xD1,
        WRID3 = 0xD2,
        NVCTR1 = 0xD9,
        NVCTR2 = 0xDE,
        NVCTR3 = 0xDF,
        GAMCTRP1 = 0xE0,
        GAMCTRN1 = 0xE1,
    }
}
11 Likes

Thank’s for this work :slight_smile:
I test 1,8" TFT Shield with Joystick and microSD on SPI1 :v:

Two lines to edit

var spi = SpiController.FromName(FEZPandaIII.SpiBus.Spi1);
var st7735 = new ST7735Controller(spi.GetDevice(ST7735Controller.GetConnectionSettings(SpiChipSelectType.Gpio, FEZPandaIII.GpioPin.D10)), gpio.OpenPin(FEZPandaIII.GpioPin.D8), gpio.OpenPin(FEZPandaIII.GpioPin.D9));

1

2

6 Likes

I need a proof. Attach a photo please :wink:

3 Likes

You are like Saint Thomas the Apostle :slight_smile:

2 Likes

Nice work @MNO

But Saint Gus?

Photo: Saint Gustav the Hermit. Patron saint of programmers who spend too much time alone in front of the keyboard.

5 Likes

What a great way to end the year :angel:

3 Likes