OLED Driver Class (Solution)

A class for the OLED display
from time import sleep
import framebuf

##################################################################
class OLED :
##################################################################
    def __init__( self, i2c, i2c_adr, buf = None ):
        '''We have 2 possibilities to use this class. If we do not 
        provide a buffer (which needs to be a byte-array) then the
        constructor creates a framebuffer with the associated buffer.
        the framebuffer can then be retrieved by the application 
        with the "getFramebuffer" call and then the application can
        use the api of the framebuffer to draw things.
        The second way to use the class is by providing the buffer
        to the constructor. Then no framebuffer is created and the 
        class assumes that the user has created the framebuffer 
        himself. 
        The framebuffer itself is not needed by this class. (It is 
        only created as a convenience for the user with parameters
        which match the display). The buffer, however, is used when 
        the contents is copied into the memory of the display. The 
        corresponding method ('copyFramebuffer') has an optional 
        parameter "buf" which can be used by the user to provide 
        a buffer from the appliction. If no buffer is provided the
        buffer provided by the user in this constructor is used or
        the one which was create by the constructor.

        This architecture should be convenient for the user and in 
        addition allows the user to play with multiple framebuffers
        if he wants to.'''

        if buf:
            self.buf = buf
            self.framebuffer = None
        else:
            self.buf = bytearray( 128*16)
            self.framebuffer = framebuf.FrameBuffer( self.buf, 128,128, framebuf.MONO_VLSB )
        self.i2c = i2c
        self.oled_adr = i2c_adr
        self.landscape = False
        self.bufr = None

    def _wr_cmd( self, cmd ):
        '''This function writes a single command byte to the display controller.
    (There are one byte and two byte commands in for the controller (see 
    datasheet. Both bytes are considered "command bytes" here. Hence this
    function can be used for both types of command bytes. 

    Try to understand from the data sheet what has to be transferred to the 
    display in order that it is interpreted as a command byte. (Hint: it will
    be two bytes in total which you will need to transfer)

    It is important to note tht the i2c.writeto function expects the python
    type 'bytearray' and NOT a series of integers. Fortunately there is a 
    function in python which we can use to convert an integer (which in python
    is an object) to a series of bytes: the function is called ".to_bytes(...)"
    and is called directly on the variable holding the integer we want to convert.
    You can look up the documentation of the function in the python documentation.
    The signature is "int.to_bytes(length=1, byteorder='big')" (with default parameters).

        Btw: the esp32 is "little endian" computer (but this is not important here.)'''

        self.i2c.writeto( self.oled_adr, b'\x80'+ cmd.to_bytes(1,'little')  )

    def init(self):
        '''Initialize the display. To do this we need to write configuration 
        parameters to some registers in the controller chip. What we need to write
        depends on the design of our display. We need to study the data sheet to
        retrieve the correct values. We write the values with a sequence of commands.
        The sequence is very similar for many different controller chips on the 
        market. Hence if you get one working it is easy to adapt to another display.
        Sometimes some offsets have to be adjusted since this depends on how the 
        display is soldered to the controller chip.
        Btw: most of the parameters are correctly set after a Power On Reset (POR)
        Hence many of the commands below are strictly speaking not necessary. 

        To have this a bit readable and compact we define an array with the 
        commands and then pass this array to the loop which writes one byte 
        after the other to the controller. As you can see in the data sheet
        some commands have 2 bytes and others only one byte.'''

        commands = [ 0xae,       # Turn display off during initialisation
                     0x00,       # Set column address to '0' (lower 4 bits)
                     0x10,       # Set column address to '0' (higher 3 bits)
                     0xb0,       # Set page address to '0'
                     0xdc,0x00,  # Set display start-line to '0'
                     0x81,0x20,  # Set the contrast to 32
                     0x20,       # Address mode: page addressing
                     0xa0,       # Scan direction (horizontal mirroring)
                     0xc0,       # Vertical mirroring (c0 - c8)
                     0xa4,       # Display RAM contents (not all white)
                     0xa6,       # Display not inverted
                     0xa8,0x3f,  # Multiplex ratio to 0x3f
                     0xd3,0x60,  # Display offset (start line)
                     0xd5,0x41,  # Sets oscillator freq and clock divide ratio
                     0xd5,0x50,  # Sets oscillator freq and clock divide ratio (set to default)
                     0xd9,0x22,  # Pre and dis-charge period (set to default)
                     0xdb,0x35,  # Sets some display voltage (set to default)
                     0xad,0x80,  # Disable the internal DC/DC converter
                                 # (There is probably an external one)
                     0xaf        # Turn on the display 
                    ]

        for cmd in commands:
            self._wr_cmd( cmd )

        sleep(0.1)               # the data sheet wants this.


    def getFramebuffer(self):
        return self.framebuffer

    def setLandscape( self, ls = True ):
        if ls:
            if not self.bufr:
                self.bufr = bytearray( 128*16)
            self.landscape = True
        else:
            self.landscape = False

    def rotateFramebuffer( self, buf ):
    '''The SH1107 controller chip has no methods to rotate the screen.
        This is why we have to do this ourselves if we want to use the display
        in landscape mode (which for most of the applications is nicer...)
        This is pretty horrible spaghetti code here...but it is fast. If you
        have a more elegant solution, try it out! (Matrix multiplication may be?)
        '''
        bufr = self.bufr
        for ip in range(0,16):
            for ic in range(0,8):
                bufr[ip*128+ic*8+7] = ((buf[ic*128+120-ip*8]&0x80)   ) + ((buf[ic*128+121-ip*8]&0x80)>>1) + ((buf[ic*128+122-ip*8]&0x80)>>2) + ((buf[ic*128+123-ip*8]&0x80)>>3) + ((buf[ic*128+124-ip*8]&0x80)>>4) + ((buf[ic*128+125-ip*8]&0x80)>>5) + ((buf[ic*128+126-ip*8]&0x80)>>6) + ((buf[ic*128+127-ip*8]&0x80)>>7)
                bufr[ip*128+ic*8+6] = ((buf[ic*128+120-ip*8]&0x40)<<1) + ((buf[ic*128+121-ip*8]&0x40)   ) + ((buf[ic*128+122-ip*8]&0x40)>>1) + ((buf[ic*128+123-ip*8]&0x40)>>2) + ((buf[ic*128+124-ip*8]&0x40)>>3) + ((buf[ic*128+125-ip*8]&0x40)>>4) + ((buf[ic*128+126-ip*8]&0x40)>>5) + ((buf[ic*128+127-ip*8]&0x40)>>6)
                bufr[ip*128+ic*8+5] = ((buf[ic*128+120-ip*8]&0x20)<<2) + ((buf[ic*128+121-ip*8]&0x20)<<1) + ((buf[ic*128+122-ip*8]&0x20)   ) + ((buf[ic*128+123-ip*8]&0x20)>>1) + ((buf[ic*128+124-ip*8]&0x20)>>2) + ((buf[ic*128+125-ip*8]&0x20)>>3) + ((buf[ic*128+126-ip*8]&0x20)>>4) + ((buf[ic*128+127-ip*8]&0x20)>>5)
                bufr[ip*128+ic*8+4] = ((buf[ic*128+120-ip*8]&0x10)<<3) + ((buf[ic*128+121-ip*8]&0x10)<<2) + ((buf[ic*128+122-ip*8]&0x10)<<1) + ((buf[ic*128+123-ip*8]&0x10)   ) + ((buf[ic*128+124-ip*8]&0x10)>>1) + ((buf[ic*128+125-ip*8]&0x10)>>2) + ((buf[ic*128+126-ip*8]&0x10)>>3) + ((buf[ic*128+127-ip*8]&0x10)>>4)
                bufr[ip*128+ic*8+3] = ((buf[ic*128+120-ip*8]&0x08)<<4) + ((buf[ic*128+121-ip*8]&0x08)<<3) + ((buf[ic*128+122-ip*8]&0x08)<<2) + ((buf[ic*128+123-ip*8]&0x08)<<1) + ((buf[ic*128+124-ip*8]&0x08)   ) + ((buf[ic*128+125-ip*8]&0x08)>>1) + ((buf[ic*128+126-ip*8]&0x08)>>2) + ((buf[ic*128+127-ip*8]&0x08)>>3)
                bufr[ip*128+ic*8+2] = ((buf[ic*128+120-ip*8]&0x04)<<5) + ((buf[ic*128+121-ip*8]&0x04)<<4) + ((buf[ic*128+122-ip*8]&0x04)<<3) + ((buf[ic*128+123-ip*8]&0x04)<<2) + ((buf[ic*128+124-ip*8]&0x04)<<1) + ((buf[ic*128+125-ip*8]&0x04)   ) + ((buf[ic*128+126-ip*8]&0x04)>>1) + ((buf[ic*128+127-ip*8]&0x04)>>2)
                bufr[ip*128+ic*8+1] = ((buf[ic*128+120-ip*8]&0x02)<<6) + ((buf[ic*128+121-ip*8]&0x02)<<5) + ((buf[ic*128+122-ip*8]&0x02)<<4) + ((buf[ic*128+123-ip*8]&0x02)<<3) + ((buf[ic*128+124-ip*8]&0x02)<<2) + ((buf[ic*128+125-ip*8]&0x02)<<1) + ((buf[ic*128+126-ip*8]&0x02)   ) + ((buf[ic*128+127-ip*8]&0x02)>>1)
                bufr[ip*128+ic*8+0] = ((buf[ic*128+120-ip*8]&0x01)<<7) + ((buf[ic*128+121-ip*8]&0x01)<<6) + ((buf[ic*128+122-ip*8]&0x01)<<5) + ((buf[ic*128+123-ip*8]&0x01)<<4) + ((buf[ic*128+124-ip*8]&0x01)<<3) + ((buf[ic*128+125-ip*8]&0x01)<<2) + ((buf[ic*128+126-ip*8]&0x01)<<1) + ((buf[ic*128+127-ip*8]&0x01)   )


    def copyFramebuf(self, buf = None):
        '''copies a framebuffer to the Display so that the 
        contents gets displayed.'''

        if not buf:
            buf = self.buf

        if self.landscape:
            self.rotateFramebuffer( buf )
            buf = self.bufr

    # We need to write the data page by page (there are 16 pages for 16x8=128 pixels vertically) 
        # For every page we need to set the page and the start column address. Since we always transfer
    # the entire display we set the column address to '0' (see data sheet for the commands used)
        for ip in range(0, 16):
            self._wr_cmd(0xB0 + ip) # page addr
            self._wr_cmd(0x00)      # col addr low
            self._wr_cmd(0x10)      # col addr high

        # We copy the page data into a temporary buffer but the first byte will be 0x40
        # This will indicate to the controller that all following bytes are Pixel data. 
        # (We should use a memoryview to avoid the copying of the framebuffer every time
        # this would be more elegant...)
            tmp = bytearray( 129 )
            tmp[0] = 0x40
            for i in range(0,128):
                tmp[i+1] = buf[ip*128+i]
            self.i2c.writeto( self.oled_adr, tmp )

    def invert( self, inv ):
        if inv:
            self._wr_cmd( 0xA7 )
        else:
            self._wr_cmd( 0xA6 )